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:

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:

OptionTypeRole
get_labelcomponentReturns the name shown for this node; defaults to the label parameter
buttons_extraarray of componentsCustom buttons appended after the eight
surroundercomponentWraps the content region, for giving the editable area an appearance of your own
rightbar_extracomponentRightbar 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:

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:

OptionRole
render_elementHow to draw the content, returning an img for instance
is_emptyWhether the content is empty, so a placeholder icon shows instead of a broken image
get_labelThe name shown beside the block
rightbar_extraCommonly 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:

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.

HookReturns
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.