Editor Renderer Factories
The tutorial used functions such as get_deafult_group_editor_with_appbar, and all that was
said at the time is that they return a renderer. This page covers the whole family: which factories exist
for which node type, what options each takes, one type detail that is easy to get wrong, and the hooks
available inside a renderer.
What a factory does for you
What these functions have in common is that you give an options object and get back a renderer component ready to register with the editor. Inside, the component has already handled several things every renderer needs and none is pleasant to write:
- Laying out the eight operation buttons in the chosen arrangement and wiring them into keyboard navigation.
- Separating the editable region from the non-editable one. Buttons and labels must not be
selectable by the cursor, or the first Backspace would delete the toolbar; that boundary is drawn by
EditorUnselecableBoxinside the factory. - Wrapping everything in a
NodeInfoProvider, so that components you pass in the options can reach the current node throughuseNode()anduseParameters(). - Rendering the row of abstract node chips.
Writing a renderer from scratch is perfectly possible, but you will end up rewriting the list above. Unless the appearance you want is genuinely far from what a factory gives, starting from one pays off.
Group nodes: two arrangements
Group nodes have two factories, differing only in where the buttons go.
get_deafult_group_editor_with_appbar spreads the label and the eight buttons across an
appbar at the top of the node, which suits theorems and proofs, blocks large enough to be worth a whole
row. get_default_group_editor_with_rightbar collects them into a narrow strip on the right
with the buttons folded behind a chevron, which suits list items, short nodes that appear often and would
otherwise fill the screen with toolbars. The options are nearly the same for both:
| Option | Type | Role |
|---|---|---|
get_label | component | Returns the name shown for this node; defaults to the label parameter |
buttons_extra | array of components | Custom buttons appended after the eight |
surrounder | component | Wraps the content region, for giving the editable area an appearance of your own |
rightbar_extra | component | Rightbar version only: a custom widget beside the button group |
const theorem_editor = get_deafult_group_editor_with_appbar({
// The label follows the category parameter: switch it to Lemma and the label says Lemma.
get_label: () => useParameters().category,
})
const item_editor = get_default_group_editor_with_rightbar({})
get_label is a component, not a getter function
Here is a detail that has to be stated plainly, or you will write code that does not run. The type of
get_label is a React component, not "a function returning a string". The renderer uses it as
a component, written <GetLabel />. That familiar line above:
get_label: () => useParameters().category
looks like an ordinary function, but it is a function component taking no props whose body calls a hook.
Precisely because it is a component, you can call useParameters(), useNode() or
even useTheme() inside it, which is what makes the label follow its parameters. The price is
that the rules of hooks apply: no conditional calls, no calls in loops.
Inline nodes
Inline nodes use get_default_inline_editor. Its most-used option is surrounder,
since an inline concept's appearance is usually just a tag:
const strikeout_editor = get_default_inline_editor({
surrounder: (props) => <del>{props.children}</del>,
})
An inline node carries only four buttons: edit parameters, delete, unwrap, new abstract. The rest do not apply here, since chaining describes a relation between block nodes and adding paragraphs means nothing for a node sitting in the middle of a sentence. These four are folded by default, because an inline node lives in the text flow and an expanded row of buttons would wreck the line height.
Structure nodes
Structure nodes lay content out in columns and use
get_default_struct_editor_with_rightbar. It has two options the others lack, and these two
actively rewrite the document:
get_numchildrenreturns how many children the node should have. If the actual count differs at render time, the component appends or removes children to match. The column count is dictated by the parameters, so an author changing a parameter is adding or removing a column.get_widthsreturns the width ratio of each column, so[1, 2]makes the right column twice as wide as the left. A length that does not match the column count is padded or truncated.
Both are usually derived from the same parameter. Say a widths parameter holds a string like
"1,2"; then the column count is the number of comma-separated pieces:
const columns_editor = get_default_struct_editor_with_rightbar({
get_label : () => "column",
get_numchildren: (node, params) => (params.widths as string).split(",").length,
get_widths : (node, params) => (params.widths as string).split(",").map(x => parseInt(x)),
})
Note that these two options are plain functions rather than components: they receive the node and the processed parameters as arguments, so they neither need nor may call hooks. The signature tells the two kinds apart at a glance: options that take arguments are plain functions, options that take none are components.
Support nodes
Support nodes have two factories for their two typical uses.
get_default_spliter_editor draws the node as a titled divider, which suits section and
chapter breaks, nodes whose only job is to divide and which hold no content of their own. Its single
option is get_title.
get_default_display_editor draws the node as a small embedded block, which suits images and
formulas, content that exists but cannot be edited inline. Its important options are:
| Option | Role |
|---|---|
render_element | How to draw the content, returning an img for instance |
is_empty | Whether the content is empty, so a placeholder icon shows instead of a broken image |
get_label | The name shown beside the block |
rightbar_extra | Commonly a small input for editing the address, see below |
Abstract nodes and the fallbacks
Abstract nodes use get_default_abstract_editor. An abstract node is an independent little
document that is not edited inline but in the floating abstract editor, so the renderer this factory
produces is simple, essentially a container.
Last is get_default_editors(), which returns a full set of fallback renderers for all seven
node types. They guarantee no more than that something displays without error, and they look plain. Their
purpose is to fill the default_renderers field of EditorCore: a node whose
concept has no renderer registered lands here instead of blanking the document. That matters most when
concepts are delivered by a backend, since a concept the frontend has never heard of can turn up at any
time.
UniversalExtra: a parameter you can edit without opening anything
The commonest use for rightbar_extra is to put a UniversalExtra there. It is a
small input bound directly to one of the node's parameters, sparing the round trip of opening the
parameter drawer, finding the field, editing it and closing again. A link's address, an image's URL, the
tag at the end of an equation all suit this treatment. Its three important props:
onDeactivate: called when the input loses focus, receiving the current value, the editor and the node. Write the value back into the parameters here.onNodeChange: called when the node changes, returning what the input should now display, orundefinedto leave it alone. This is the opposite direction, reading the parameter into the input.accept_image: with this on, the input accepts a pasted image and converts it into a usable address.
The two callbacks, one in each direction, make up a complete two-way binding.
onNodeChange usually starts by checking whether the parameter object changed at all and
returns undefined if not, so that a user in the middle of typing does not get overwritten:
const link_editor = get_default_inline_editor({
surrounder: (props) => <u>{props.children}</u>,
rightbar_extra: () => <UniversalExtra
variation="filled" width="7rem" extra_small
onDeactivate={(value, editor, node) => {
editor.set_node(node, {parameters: {
...node.parameters,
target: {val: value, type: "string"},
}})
}}
onNodeChange={(node, prev_node) => {
if (node.parameters === prev_node?.parameters) return undefined
return node.parameters.target.val as string
}}
/>,
})
Holding Alt+W drops the focus straight into this input, with no need for the mouse.
Hooks available inside renderers
A few hooks come up constantly when writing renderers. All of them rest on one premise: knowing which
node is being rendered. That information comes from NodeInfoProvider, and every renderer
produced by the factories above already wraps itself in one, so inside get_label,
surrounder, rightbar_extra and any button you append, these are simply
available.
| Hook | Returns |
|---|---|
useNode() | The current node. Takes an optional comparison function so you re-render only when the fields you care about change |
useParameters() | The current node's processed parameters |
useEditor() | The current editor instance, which carries the tree operations |
useEditorConfig() | The current style config, which keeps custom widgets consistent with the built-in appearance |
That useParameters() returns processed parameters matters when writing renderers.
"Processed" means fixed parameters have been applied over the defaults and function-typed parameters have
been evaluated. The Theorem defined in tutorial chapter 5 has a function-typed title,
p => p.category.val; what the renderer reads is not that source but the evaluated result. A
renderer never needs to know whether a parameter was fixed, defaulted or computed. It just uses it.
One more pair of hooks belongs elsewhere: useCurEditor() and
useCurConceptNode() return the editor that is currently active and the concept node the
cursor is in. They do not depend on NodeInfoProvider and may be called outside the editor,
which is exactly how the two floating panels know whom they are serving. An external panel showing
information about the current selection would be built on these two.