编辑渲染器工厂

教程里用过 get_deafult_group_editor_with_appbar 这样的函数,当时只说它「返回一个渲染器」。 这一页把这一族函数完整过一遍:每种节点类型有哪些工厂、各自的选项是什么、 一个容易写错的类型细节,以及渲染器内部可以用的 hook。

工厂替你做了什么

这一族函数的共同点是:你给一个选项对象,它返回一个可以直接注册进编辑器的渲染器组件。 组件内部已经处理好了几件每个渲染器都要做、但都很烦的事情:

所以自己从头写一个渲染器是可以的,但你会把上面这些重写一遍。 除非外观差得很远,否则从工厂出发更划算。

组节点:两种摆法

组节点有两个工厂,区别只在按钮放哪。 get_deafult_group_editor_with_appbar 把标题和八个按钮平铺在节点顶部的一条应用栏里, 适合定理、证明这种块头较大、值得占一行的节点。 get_default_group_editor_with_rightbar 则把它们收进右侧一个竖条, 按钮折叠在一个箭头后面,适合列项这种短小、频繁出现的节点,免得满屏都是工具栏。 两者接受的选项几乎一样:

选项类型作用
get_label组件返回这个节点在界面上显示的名字,默认取参数里的 label
buttons_extra组件数组追加到那八个按钮之后的自定义按钮
surrounder组件包裹内容区域,用来给可编辑区加一层自己的外观
rightbar_extra组件只有右栏版有:在按钮组旁边放一个自定义部件
const theorem_editor = get_deafult_group_editor_with_appbar({
    // 标题跟着 category 参数走:改成「引理」,界面上的标题也变成「引理」。
    get_label: () => useParameters().category,
})

const item_editor = get_default_group_editor_with_rightbar({})

get_label 是一个组件,不是一个取值函数

这里有一个必须讲清楚的细节,否则很容易写出跑不起来的代码。get_label 的类型是 React 组件,而不是「返回字符串的普通函数」。渲染器内部是把它当组件用的, 写作 <GetLabel />。上面那句熟悉的写法:

get_label: () => useParameters().category

看起来像一个普通函数,实际上它是一个没有 props 的函数组件,函数体里调用了 hook。 正因为它是组件,你才能在里面调用 useParameters()useNode() 乃至 useTheme(),标题才会随参数变化自动更新。 代价是必须遵守 hook 的规则:不能条件调用,也不能在循环里调用。

行内节点

行内节点用 get_default_inline_editor。它的选项里最常用的是 surrounder, 因为行内概念的外观通常就是一层标签:

const strikeout_editor = get_default_inline_editor({
    surrounder: (props) => <del>{props.children}</del>,
})

行内节点身上的按钮只有四个:设置参数、删除、解除、新建抽象。 组节点那几个按钮在这里不适用,接排开关是块级节点之间的关系, 加段落对混在文字中间的节点也没有意义。这四个按钮默认就是折叠的, 因为行内节点处在文字流里,展开一排按钮会把行高撑坏。

结构节点

结构节点表示多栏排版,用 get_default_struct_editor_with_rightbar。 它比别的工厂多两个选项,而且这两个选项会主动改写文档:

两者一般从同一个参数解析出来。比如约定一个 widths 参数存 "1,2" 这样的字符串,栏数就是逗号分隔的段数:

const columns_editor = get_default_struct_editor_with_rightbar({
    get_label      : () => "栏",
    get_numchildren: (node, params) => (params.widths as string).split(",").length,
    get_widths     : (node, params) => (params.widths as string).split(",").map(x => parseInt(x)),
})

注意这两个选项是普通函数,不是组件:它们接收节点和处理后的参数作为入参, 所以不需要(也不能)在里面调用 hook。这和 get_label 的区别, 看类型签名就能分清:带参数的是普通函数,不带参数的是组件。

支撑节点

支撑节点有两个工厂,对应它的两种典型用法。

get_default_spliter_editor 把节点画成一条带标题的分隔线, 适合小节线、章节线这类只起划分作用、本身没有内容的节点。选项只有一个 get_title

get_default_display_editor 把节点画成一个内嵌的小块, 适合图片、公式这类「有内容但不能在正文里直接编辑」的节点。它的关键选项是:

选项作用
render_element怎么把内容画出来,比如返回一个 img
is_empty判断内容是否为空。为空时显示一个占位图标,而不是一张破图
get_label块旁边显示的名字
rightbar_extra常用来放一个改地址的小输入框,见下一节

抽象节点与兜底渲染器

抽象节点用 get_default_abstract_editor。抽象节点自身是一棵独立的小文档, 不在正文里就地编辑,而是在浮动的抽象编辑器窗口里编辑, 所以这个工厂产生的渲染器很简单,基本上只是一个容器。

最后是 get_default_editors(),它返回一整套七种节点类型的兜底渲染器。 这些渲染器只保证「能显示、不报错」,外观极简。它的用途是填 EditorCoredefault_renderers 字段:当一个节点的概念没有注册专门的渲染器时就落到这里, 文档不会因此白屏。这在概念由后端下发的场景里尤其重要, 因为随时可能出现一个前端还不认识的概念。

UniversalExtra:随手可改的参数输入框

rightbar_extra 最常见的用途是塞一个 UniversalExtra。 它是一个小输入框,直接绑定到节点的某个参数上,省去「打开参数抽屉、找到那一项、改、关掉」的来回。 链接的地址、图片的 URL、公式末尾的编号,都适合这样改。它的三个关键属性是:

两个回调一进一出,构成一个完整的双向绑定。onNodeChange 里通常先比较 参数对象是否变过,没变就返回 undefined,免得用户正在输入时被覆盖:

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
        }}
    />,
})

按住 Alt+W 可以直接把焦点送进这个输入框,不需要用鼠标点。

渲染器里可以用的 hook

写渲染器时会反复用到几个 hook。它们都依赖一个前提:当前渲染的是哪个节点。 这个信息由 NodeInfoProvider 提供,而上面所有工厂产生的渲染器都已经在内部包好了它, 所以在 get_labelsurrounderrightbar_extra 以及你追加的按钮里,它们都可以直接用。

hook返回
useNode()当前节点本身。可以传一个比较函数,只在你关心的字段变化时才重新渲染
useParameters()当前节点处理后的参数表
useEditor()当前编辑器实例,树操作方法都在它上面
useEditorConfig()当前的样式配置,自定义部件靠它跟内置外观保持一致

useParameters() 返回的是「处理后」的参数,这一点在写渲染器时很重要。 所谓处理后,指的是固定参数已经覆盖上去、函数型参数已经求值。 教程第 5 章定义的「定理」,它的 title 是一个函数型参数 p => p.category.val;渲染器里读到的不是这段代码,而是求值后的结果。 换句话说,渲染器不需要知道某个参数是固定的、默认的还是函数算出来的,它只管用。

还有一对 hook 用在别处:useCurEditor()useCurConceptNode() 返回的是「当前处于活动状态的编辑器」和「光标所在的概念节点」。它们不依赖 NodeInfoProvider,可以在编辑器外面调用,两个浮动面板正是靠它们知道该为谁服务的。 如果你要做一个「显示当前选中节点信息」的外部面板,用的就是这两个。