编辑渲染器工厂
教程里用过 get_deafult_group_editor_with_appbar 这样的函数,当时只说它「返回一个渲染器」。
这一页把这一族函数完整过一遍:每种节点类型有哪些工厂、各自的选项是什么、
一个容易写错的类型细节,以及渲染器内部可以用的 hook。
工厂替你做了什么
这一族函数的共同点是:你给一个选项对象,它返回一个可以直接注册进编辑器的渲染器组件。 组件内部已经处理好了几件每个渲染器都要做、但都很烦的事情:
- 把八个操作按钮按选定的摆法排好,并接进键盘导航。
- 划分可编辑区域与不可编辑区域。按钮、标题这些不能被光标选中,
否则用户一按退格就把工具栏删了;这个边界由工厂内部的
EditorUnselecableBox划定。 - 包上
NodeInfoProvider,这样你在选项里写的组件能用useNode()、useParameters()拿到当前节点。 - 渲染抽象节点的标签栏。
所以自己从头写一个渲染器是可以的,但你会把上面这些重写一遍。 除非外观差得很远,否则从工厂出发更划算。
组节点:两种摆法
组节点有两个工厂,区别只在按钮放哪。
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。
它比别的工厂多两个选项,而且这两个选项会主动改写文档:
get_numchildren返回这个节点应该有几个子节点。渲染时如果实际数量对不上, 组件会自动补齐或者删掉多余的。也就是说栏数由参数说了算,写作者改参数就等于加减栏。get_widths返回各栏的宽度比例,比如[1, 2]表示右栏是左栏的两倍宽。 数组长度和栏数对不上时会自动补足或截断。
两者一般从同一个参数解析出来。比如约定一个 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(),它返回一整套七种节点类型的兜底渲染器。
这些渲染器只保证「能显示、不报错」,外观极简。它的用途是填 EditorCore
的 default_renderers 字段:当一个节点的概念没有注册专门的渲染器时就落到这里,
文档不会因此白屏。这在概念由后端下发的场景里尤其重要,
因为随时可能出现一个前端还不认识的概念。
UniversalExtra:随手可改的参数输入框
rightbar_extra 最常见的用途是塞一个 UniversalExtra。
它是一个小输入框,直接绑定到节点的某个参数上,省去「打开参数抽屉、找到那一项、改、关掉」的来回。
链接的地址、图片的 URL、公式末尾的编号,都适合这样改。它的三个关键属性是:
onDeactivate:输入框失去焦点时调用,参数是当前输入的值、编辑器和节点。 在这里把值写回参数。onNodeChange:节点变化时调用,返回输入框应该显示的新内容, 返回undefined表示保持不变。方向和上一条相反,负责把参数读进输入框。accept_image:开启后输入框接受直接粘贴图片,粘贴的图片会被转成可直接使用的地址。
两个回调一进一出,构成一个完整的双向绑定。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_label、surrounder、rightbar_extra
以及你追加的按钮里,它们都可以直接用。
| hook | 返回 |
|---|---|
useNode() | 当前节点本身。可以传一个比较函数,只在你关心的字段变化时才重新渲染 |
useParameters() | 当前节点处理后的参数表 |
useEditor() | 当前编辑器实例,树操作方法都在它上面 |
useEditorConfig() | 当前的样式配置,自定义部件靠它跟内置外观保持一致 |
useParameters() 返回的是「处理后」的参数,这一点在写渲染器时很重要。
所谓处理后,指的是固定参数已经覆盖上去、函数型参数已经求值。
教程第 5 章定义的「定理」,它的 title 是一个函数型参数
p => p.category.val;渲染器里读到的不是这段代码,而是求值后的结果。
换句话说,渲染器不需要知道某个参数是固定的、默认的还是函数算出来的,它只管用。
还有一对 hook 用在别处:useCurEditor() 和 useCurConceptNode()
返回的是「当前处于活动状态的编辑器」和「光标所在的概念节点」。它们不依赖
NodeInfoProvider,可以在编辑器外面调用,两个浮动面板正是靠它们知道该为谁服务的。
如果你要做一个「显示当前选中节点信息」的外部面板,用的就是这两个。