Slate文档编辑器-Node节点与Path路径映射

在之前我们聊到了slate中的Decorator装饰器实现,装饰器可以为我们方便地在编辑器渲染调度时处理range的渲染,这在实现搜索替换、代码高亮等场景非常有用。那么在这篇文章中,我们聊一下Node节点与Path路径映射,这里的Node指的是渲染的节点对象,Path则是节点对象在当前JSON中的路径,即本文的重点是如何确定渲染出的节点处于文档数据定义中的位置。

关于slate文档编辑器项目的相关文章:

渲染与命令

slate的文档中的03-defining-custom-elements一节中,我们可以看到我们可以看到slate中的Element节点是可以自定义渲染的,渲染的逻辑是需要我们根据propselement对象来判断类型,如果类型是code的话那就要渲染我们预定义好的CodeElement组件,否则渲染DefaultElement组件,这里的type是我们预设的init数据结构值,是数据结构的形式约定。

// https://docs.slatejs.org/walkthroughs/03-defining-custom-elements
const App = () => {
const [editor] = useState(() => withReact(createEditor())) // Define a rendering function based on the element passed to `props`. We use
// `useCallback` here to memoize the function for subsequent renders.
const renderElement = useCallback(props => {
switch (props.element.type) {
case 'code':
return <CodeElement {...props} />
default:
return <DefaultElement {...props} />
}
}, []) return (
<Slate editor={editor} initialValue={initialValue}>
<Editable
// Pass in the `renderElement` function.
renderElement={renderElement}
/>
</Slate>
)
}

那么这里的渲染自然是不会有什么问题,我们的编辑器实际上必然不仅仅是要渲染内容,执行命令来变更文档结构/内容也是非常重要的事情。那么在05-executing-commands中一节中,我们可以看到对于文本内容加粗与代码块的切换分别是执行了addMark/removeMark以及Transforms.setNodes的函数来执行的。

// https://docs.slatejs.org/walkthroughs/05-executing-commands
toggleBoldMark(editor) {
const isActive = CustomEditor.isBoldMarkActive(editor)
if (isActive) {
Editor.removeMark(editor, 'bold')
} else {
Editor.addMark(editor, 'bold', true)
}
} toggleCodeBlock(editor) {
const isActive = CustomEditor.isCodeBlockActive(editor)
Transforms.setNodes(
editor,
{ type: isActive ? null : 'code' },
{ match: n => Editor.isBlock(editor, n) }
)
}

路径映射

在上述的例子中看起来并没有什么问题,似乎我们对于编辑器基础的节点渲染与变更执行都已经完备了。然而,这里我们却可能忽略一个问题,为什么我们执行命令的时候slate可以知道我们要操作的是哪个节点,这是个很有趣的问题。如果将上述的例子运行起来的话,就可以发现我们直接执行上述操作非常依赖与光标的位置,这是因为在默认参数缺省的情况下就是取的选区位置来执行变更操作。这对于普通的节点渲染自然是没有问题的,但是当我们想实现比较复杂的模块或者交互时,例如表格模块与图片的异步上传等场景时,这可能并不足以让我们完成这些功能。

我们的文档编辑器当然并不是特别简单的场景,那么如果我们需要深入实现编辑器的复杂操作时,完全依赖选区来执行操作显然不够现实,例如我们希望在在代码块元素下面插入一个空行,由于选区必须要在Text节点上,我们不能直接操作选区到Node节点上,这种实现就不能直接依靠选区来完成。以及在单元格中得知当前处于表格的位置也不是件易事,因为此时的渲染调度是由框架来实现的,我们无法直接获取parent的数据对象。那么经常使用slate的同学都知道,无论是RenderElementProps还是RenderLeafProps在渲染的时候,除了attributes以及children等数据之外,是没有Path数据的传递的。

export interface RenderElementProps {
children: any;
element: Element;
attributes: {
// ...
};
}
export interface RenderLeafProps {
children: any;
leaf: Text;
text: Text;
attributes: {
// ...
};
}

这个问题实际上不光在富文本编辑器中会出现,在重前端编辑的场景下都有可能会出现,例如低代码编辑器中。其共性是我们通常都会使用插件化的形式来实现编辑器,那么此时渲染的节点不是我们直接写的组件,而是由核心层与插件自行调度渲染的内容,单个定义的组件会被渲染N次,那么我们如果需要操作组件的数据,就需要知道到底是要更新哪个位置的数据对象,即在渲染的组件中如何得知我此时处在数据对象的什么位置。诚然对每个渲染的对象都定义id是个可行的方案,但是这样就必须要迭代整个对象来查找位置,我们在这里的实现则更加高效。

那么我们对于数据操作的时候Path是非常重要的,在平时的交互处理中,我们使用editor.selection就可以满足大部分功能了。然而很多情况下单纯用selection来处理要操作的目标Path是有些捉襟见肘的。那么此时在传递的数据结构中我们可以看到与Path最相关的数据就是element/text值了,那么此时我们可以比较轻松地记起在ReactEditor中存在findPath方法,可以让我们通过Node来查找对应的Path

// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate-react/src/plugin/react-editor.ts#L90
findPath(editor: ReactEditor, node: Node): Path {
const path: Path = []
let child = node
while (true) {
const parent = NODE_TO_PARENT.get(child)
if (parent == null) {
if (Editor.isEditor(child)) return path
else break
}
const i = NODE_TO_INDEX.get(child)
if (i == null) break
path.unshift(i)
child = parent
}
}

简单压缩了代码,在这里的实现是通过两个WeakMap非常巧妙地让我们可以取得节点的Path。那么这里就需要思考一个问题,为什么我们不直接在RenderProps直接将Path传递到渲染的方法中,而是非得需要每次都得重新查找而浪费一部分性能。实际上,如果我们只是渲染文档数据,那么自然是不会有问题的,然而我们通常是需要编辑文档的,在这个时候就会出现问题。举个例子,假设我们在[10]位置有一个表格,而此时我们在[6]位置上增添了1个空白行,那么此时我们的表格Path就应该是[11]了,然而由于我们实际上并没有编辑与表格相关的内容,所以我们本身也不应该刷新表格的相关内容,自然其Props就不会变化,此时我们如果直接取值的话,则会取到[10]而不是[11]

那么同样的,即使我们用WeakMap记录NodePath的对应关系,即使表格的Node实际并没有变化,我们也无法很轻松地迭代所有的节点去更新其Path。因此我们就可以基于这个方法,在需要的时候查找即可。那么新的问题又来了,既然前边我们提到了不会更新表格相关的内容,那么应该如何更新其index的值呢,在这里就是另一个巧妙的方法了,在每次由于数据变化导致渲染的时候,我们同样会向上更新其所有的父节点,这点和immutable的模型是一致的,那么此时我们就可以更新所有影响到的索引值了。

那么如何避免其他节点的更新呢,很明显我们可以根据key去控制这个行为,对于相同的节点赋予唯一的id即可。另外在这里可以看出,useChildren是定义为Hooks的,那么其调用次数必定不会低,而在这里每次组件render都会存在findPath调用,所以这里倒也不需要太过于担心这个方法的性能问题,因为这里的迭代次数是由我们的层级决定的,通常我们都不会有太多层级的嵌套,所以性能方面还是可控的。

// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate-react/src/hooks/use-children.tsx#L90
const path = ReactEditor.findPath(editor, node)
const children = []
for (let i = 0; i < node.children.length; i++) {
const p = path.concat(i)
const n = node.children[i] as Descendant
const key = ReactEditor.findKey(editor, n)
// ...
if (Element.isElement(n)) {
children.push(
<SelectedContext.Provider key={`provider-${key.id}`} value={!!sel}>
<ElementComponent />
</SelectedContext.Provider>
)
} else {
children.push(<TextComponent />)
}
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
}

我们也可以借助这个概念来处理表格,当我们需要实现表格节点的复杂交互时,可以发现很难确定渲染节点的[RowIndex, ColIndex],即当前单元格在表格中的位置,我们需要这些信息来实现单元格选择和调整大小等功能。使用ReactEditor.findPath可以使用基于Node获取最新的Path,但是当数据嵌套层级较多时,例如表格中嵌套表格,这里就有很多不必要”的迭代。实际上两层就可以满足需求,但是使用ReactEditor.findPath会一直迭代到Editor Node,这在频繁触发的操作例如Resize中可能会导致一些性能问题。

而如果借助这个概念,我们就同样可以实现两个WeakMap,在最顶层节点即Table节点渲染时将映射关系建立好,此时就可以完全迭代Tr + Cellelement对象,在immutable的支持下,我们就可以得到当前单元格的索引值。当然在后期的slate中这两个WeakMap已经导出,不需要我们自行建立映射关系,只需要将其取出即可。

// https://github.com/ianstormtaylor/slate/pull/5657
export const Table: FC = () => {
useMemo(() => {
const table = context.element;
table.children.forEach((tr, index) => {
NODE_TO_PARENT.set(tr, table);
NODE_TO_INDEX.set(tr, index);
tr.children &&
tr.children.forEach((cell, index) => {
NODE_TO_PARENT.set(cell, tr);
NODE_TO_INDEX.set(cell, index);
});
});
}, [context.element]);
} export const Cell: FC = () => {
const parent = NODE_TO_PARENT.get(context.element);
console.log(
"RowIndex - CellIndex",
NODE_TO_INDEX.get(parent!),
NODE_TO_INDEX.get(context.element)
);
}

但是通过这种方式来获取NodePath节点的映射来获取位置就没有问题了嘛,高效的查找方案使得我们在这里必须依赖渲染后才可以得知节点最新的位置,也就是说当我们更新了节点对象后,如果此时立刻调用findPath方法是无法得到最新的Path的,因为此时的渲染行为是异步的。那么如果需要的话此时就必须要迭代整个数据对象来获取Path,当然我觉得这里倒是没有迭代整个对象的必要,在使用Transforms更改内容后,我们不应该立即获取路径值,而是等到React完成渲染后再进行下一步。这样我们可以按顺序执行相关操作,由于slate中没有额外的异步操作,我们可以轻松地在<Editable />useEffect中确定当前渲染何时完成。

export const WithContext: FC<{ editor: EditorKit }> = props => {
const { editor, children } = props;
const isNeedPaint = useRef(true);
// 保证每次触发 Apply 时都会重新渲染
// https://github.com/ianstormtaylor/slate/blob/25be3b/packages/slate-react/src/components/slate.tsx#L29
useSlate(); useEffect(() => {
const onContentChange = () => {
isNeedPaint.current = true;
};
editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, onContentChange, 1);
return () => {
editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, onContentChange);
};
}, [editor]); useEffect(() => {
if (isNeedPaint.current) {
Promise.resolve().then(() => {
// https://github.com/ianstormtaylor/slate/issues/5697
editor.event.trigger(EDITOR_EVENT.PAINT, {});
});
}
isNeedPaint.current = false;
}); return children as JSX.Element;
};

最后

在这里我们主要讨论了Node节点与Path路径映射,即如何确定渲染出的节点处于文档数据定义中的位置,这是slate中实现数据变更时的重要表达,特别是在仅使用选区无法实现的复杂操作中,并且还分析了slate源码来探究了相关问题的实现。那么在后面的文章中,我们延续当前提到的表格但单元格位置的查找,来聊聊表格模块的设计及交互。

每日一题

参考

Slate文档编辑器-Node节点与Path路径映射的更多相关文章

  1. 基于slate构建文档编辑器

    基于slate构建文档编辑器 slate.js是一个完全可定制的框架,用于构建富文本编辑器,在这里我们使用slate.js构建专注于文档编辑的富文本编辑器. 描述 Github | Editor DE ...

  2. PowerDesigner(九)-模型文档编辑器(生成项目文档)(转)

    模型文档编辑器 PowerDesigner的模型文档(Model  Report)是基于模型的,面向项目的概览文档,提供了灵活,丰富的模型文档编辑界面,实现了设计,修改和输出模型文档的全过程. 模型文 ...

  3. 使用Swing实现简易而不简单的文档编辑器

    本文通过Swing来实现文档简易而不简单的文档编辑器,该文档编辑器的功能包括: 设置字体样式:粗体,斜体,下划线,可扩展 设置字体:宋体,黑体,可扩展 设置字号:12,14,18,20,30,40, ...

  4. Linux_文档编辑器_简介

    1. vi 2. vim 3. ubuntu 有一个 自己的图形化的 文档编辑器,用起来比较方便: gedit 4. 5.

  5. Web页面引入文档编辑器报风险

    Web页面引入文档编辑器会报风险,则需要以下操作: <system.web> <httpRuntime requestValidationMode="2.0" / ...

  6. 在线HTML文档编辑器使用入门之图片上传与图片管理的实现

    在线HTML文档编辑器使用入门之图片上传与图片管理的实现: 官方网址: http://kindeditor.net/demo.php 开发步骤: 1.开发中只需要导入选中的文件(通常在 webapp ...

  7. [Qt及Qt Quick开发实战精解] 第1章 多文档编辑器

      这一章的例子是对<Qt Creator快速人门>基础应用篇各章节知识的综合应用, 也是一个规范的实例程序.之所以说其规范,是因为在这个程序中,我们对菜单什么时候可用/什么时候不可用.关 ...

  8. Swagger UI及 Swagger editor教程 API文档搭配 Node使用

    swagger ui 是一个在线文档生成和测试的利器,目前发现最好用的.为啥好用呢?打开 demo,支持API自动生成同步的在线文档些文档可用于项目内部API审核方便测试人员了解 API这些文档可作为 ...

  9. js操作文档对象的节点

    好吧,为了能让大家不至于睡着,我们先回顾先前面的东东吧~ 1.首先我们写了javaScriput的是一门弱类型的解释性的脚本语言:弱类型:我们的变量不用申明其具体的数据类型,在使用的时候浏览器根据其存 ...

  10. 添加swagger api文档到node服务

    swagger,一款api测试工具,详细介绍参考官网:http://swagger.io/ ,这里主要记录下怎么将swagger api应用到我们的node服务中: 1.任意新建node api项目, ...

随机推荐

  1. youtube视频下载,youtube-dl

    []为选填,用的时候去掉[] 用pip装youtube-dl pip install -U youtube-dl [--proxy=127.0.0.1:7890] youtube-dl下载youtub ...

  2. vivo 游戏中心包体积优化方案与实践

    作者:来自 vivo 互联网大前端团队- Ke Jie 介绍 App 包体积优化的必要性,游戏中心 App 在实际优化过程中的有效措施,包括一些优化建议以及优化思路. 一.包体积优化的必要性 安装包大 ...

  3. token、cookie、session的区别

    token是验证用户身份的令牌,通常是用于接口的身份验证,一般获取之后放在请求头中 cookie和session的区别: cookie是服务器发送到客户端然后存储在客户端的,可以用来保持用户的登录信息 ...

  4. 100 款支持 .NET 多版本的强大 WPF 控件库

    前言 推荐一款集成了超过100款控件的流行 XAML 控件库,同时提供了一系列常用的 .NET 帮助类-CookPopularUI.它可以简化开发流程,让我们能够更加专注于核心业务逻辑的实现. 让我们 ...

  5. 【昌哥IT课堂】MySQL8.0新特性之不可见主键

    一.概述作为 MySQL DBA ,相信大家都经历过在复制模式下,如果没有主键,遇到 load data ,大事务,ddl 等有大量表数据行扫描的行为时,会带来严重的主从延迟,给数据库稳定性和数据一致 ...

  6. 解码OutOfMemoryError:PermGen Space

    本文由 ImportNew - Peter Pan 翻译自 javacodegeeks.如需转载本文,请先参见文章末尾处的转载要求. ImportNew注:如果你也对Java技术翻译分享感兴趣,欢迎加 ...

  7. 轻量虚拟机之Multipass

    官网:https://multipass.run/ 它可以快速在电脑上快速搭建一个轻量级的虚拟机,并且相比于 Vmware 更加轻量,只需一行命令快速创建 Ubuntu 虚拟机. Multipass  ...

  8. docker安装指定版本的gitlab并配置ssl证书

    安装gitlab,指定14.5.2版本,为便于与下篇文章:gitlab恢复做准备.如果不需要恢复,或不需要配置ssl证书,请自行精简以下操作. 1. 开启gitlab sh gitlab.sh doc ...

  9. day02 计算机组成

    day02 计算机组成 1.硬件 计算机硬件是指一些物理装置按照系统结构的要求构成一个有机整体为计算机软件运行提供物质基础 构成最基础的硬件有: CPU 内存 主板 IO设备 2.软件 计算机软件可以 ...

  10. 【XML编程与WEB服务】XML入门

    目录 XML -入门 XML文档组成 XML文档规则 XML元素 特殊字符数据引用 CDATA段 空白处理 XML -入门 XML(Extensible Markup Language):可扩展的标记 ...