Skip to content

Commit

Permalink
25/01/11
Browse files Browse the repository at this point in the history
  • Loading branch information
WindRunnerMax committed Jan 11, 2025
1 parent 865b20a commit 48999e5
Show file tree
Hide file tree
Showing 31 changed files with 817 additions and 736 deletions.
4 changes: 3 additions & 1 deletion .scripts/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ const root = path.resolve(__dirname, `..`);
for (const item of value) {
const name = item.split("/").pop();
if (!name) continue;
content.push(`* [${name}](${item.replace(/ /g, "%20")}.md)`);
const zh = `* [${name}](${item.replace(/ /g, "%20")}.md)`;
const en = ` [(*en-us*)](I18N/${item.replace(/ /g, "%20")}.md)`;
content.push(zh + en);
}
content.push("");
}
Expand Down
24 changes: 21 additions & 3 deletions Backup/从零设计实现富文本编辑器.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# 从零设计实现富文本编辑器
富文本编辑器是允许用户在输入和编辑文本内容时,可以应用不同的格式、样式等功能,例如图文混排等,具有所见即所得的能力。与简单的纯文本编辑组件`<input>`等不同,富文本编辑器提供了更多的功能和灵活性,让用户可以创建更丰富和结构化的内容。现代的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、思维导图、附件、公式、格式刷等等比较复杂的功能。

- 开源地址: <https://github.com/WindRunnerMax/QuillBlocks>
- 项目笔记: <https://github.com/WindRunnerMax/QuillBlocks/blob/master/NOTE.md>

## Why?
那么为什么要从零设计实现新的富文本编辑器,因为当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计`Quill`、结合`React`视图层的`Draft`、纯粹的编辑器引擎`Slate`、高度模块化的`PromiseMirror`、开箱即用的`TinyMCE/TipTap`、集成协同解决方案的`EtherPad`等等。

而我前段时间都在专注于编辑器的应用层实现,在具体实现的过程中也遇到了很多问题,并且记录了相关文章。然而在应用层实现的过程中,遇到了很多我个人觉得可以优化的地方,特别是在数据结构层面上,希望能够将我的一些想法应用出来。举例来说,主要有下面的几个原因:

## 编辑器专栏

Expand All @@ -14,7 +20,7 @@
此外,这段时间还研究了`slate`富文本编辑器相关的实现,并且也给`slate`的仓库提过一些`PR`。还写了一些`slate`相关的文章,同样也是比较关注于应用层的实现,例如:

- [WrapNode数据结构与操作变换](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/WrapNode数据结构与操作变换.md)
- [Node节点与Path路径映射]()
- [Node节点与Path路径映射](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/Node节点与Path路径映射.md)
- ...


Expand All @@ -28,20 +34,25 @@
块级结构选择效果

### 数据结构设计
数据结构设计
数据结构设计
slate数据设计尽可能倾向于`HTML`的设计,
Piece Table
此外在`0.50`之前的版本`API`设计非常复杂,需要比较大的理解成本,虽然
normalize 很复杂,特别是脏路径标记

视图相关

协同相关

## 方案选型
实现`ContentEditable`的编辑器

### execCommand
<div contenteditable></div>
data: text/html;base64,PGRpdiBjb250ZW50ZWRpdGFibGU+PC9kaXY+

```plain
data:text/html,<div contenteditable style="border: 1px solid black"></div>
```
https://github.com/jaredreich/pell

### ContentEditable
Expand All @@ -55,6 +66,13 @@ https://github.com/jaredreich/pell


## 参考
- <https://github.com/w3c/editing>
- <https://zhuanlan.zhihu.com/p/407713779>
- <https://zhuanlan.zhihu.com/p/425265438>
- <https://zhuanlan.zhihu.com/p/259387658>
- <https://www.zhihu.com/question/38699645>
- <https://www.zhihu.com/question/404836496>
- <https://juejin.cn/post/6974609015602937870>
- <https://github.com/yoyoyohamapi/book-slate-editor-design>
- <https://github.com/grassator/canvas-text-editor-tutorial>
- <https://cdacamar.github.io/data%20structures/algorithms/benchmarking/text%20editors/c++/editor-data-structures/>
2 changes: 1 addition & 1 deletion Environment/Ubuntu20.04配置CuckooSandbox环境.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ source ./python2-conda.sh
## 安装Cuckoo

### 安装python库
我是直接执行了`sudo pip install -U cuckoo`,然后执行过程中告诉我缺啥我都再装,虽然这样不太好但是也不是不行哈哈,文档对于这块说的还是比较清楚的,这里借鉴一下其他博客说明的安装环境,如果安装失败,搜索一下错误,我就遇到过一个编译`image`什么的错误,是在`github issue`中找到一个用`apt`安装的依赖才解决的,但是具体记不清了。
我是直接执行了`sudo pip install -U cuckoo`,然后执行过程中告诉我缺啥我都再装,虽然这样不太好但是也不是不行哈。文档对于这块说的还是比较清楚的,这里借鉴一下其他博客说明的安装环境,如果安装失败,搜索一下错误,我就遇到过一个编译`image`什么的错误,是在`github issue`中找到一个用`apt`安装的依赖才解决的,但是具体记不清了。

```bash
sudo apt-get install python python-pip python-dev libffi-dev libssl-dev
Expand Down
219 changes: 219 additions & 0 deletions I18N/RichText/Node节点与Path路径映射.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Mapping between Node and Path
Previously, we discussed the implementation of the `Decorator` decorator in `slate`. Decorators make it convenient for us to handle the rendering of `range` while editing, which is very useful for scenarios like search and replace, and code highlighting. In this article, let's talk about mapping between `Node` and `Path`. Here, `Node` refers to the rendered node object, and `Path` is the path of the node object in the current `JSON`, so the focus of this article is how to determine the position of rendered nodes in the document data definition.

* Online Editing: [Link](https://windrunnermax.github.io/DocEditor)
* Open Source: [GitHub](https://github.com/WindRunnerMax/DocEditor)

Related articles about the `slate` document editor project:

* [Building a Document Editor with Slate](https://juejin.cn/post/7265516410490830883)
* [Slate Document Editor - WrapNode Data Structure and Operations Transformation](https://juejin.cn/spost/7385752495535603727)
* [Slate Document Editor - TypeScript Type Extension and Node Type Checking](https://juejin.cn/spost/7399453742346551332)
* [Slate Document Editor - Decorator Rendering Dispatch](https://juejin.cn/post/7433069014978592819)
* [Slate Document Editor - Node and Path Mapping]()


## Rendering and Commands
In the `03-defining-custom-elements` section of the `slate` documentation, we can see that `Element` nodes in `slate` can be custom rendered. The rendering logic requires us to determine the type based on the `element` object in `props`. If the type is `code`, then we render the pre-defined `CodeElement` component, otherwise, we render the `DefaultElement` component. Here, the `type` is a pre-set value in the `init` data structure, which is a form of data structure convention.

```js
// 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`.
const renderElement = useCallback(props => {
switch (props.element.type) {
case 'code':
return <CodeElement {...props} />
default:
return <DefaultElement {...props} />
}
}, [])

return (
<Slate editor={editor} initialValue={initialValue}>
<Editable
renderElement={renderElement}
/>
</Slate>
)
}
```

When it comes to rendering, everything goes smoothly. Our editor is not just about rendering content; executing commands to change the document structure/content is also crucial. In the `05-executing-commands` section, we can see that toggling between bold text and code block is achieved through the functions `addMark/removeMark` and `Transforms.setNodes`.

```js
// 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) }
)
}
```

## Path Mapping

Looking at the example above, everything seems fine. It appears that we have covered the basics of rendering and executing changes to editor nodes. However, there is a possibility that we might have overlooked a crucial issue: how does `slate` know which node we are operating on when we execute a command? This raises an interesting question. When running the example mentioned above, one can observe that our operations heavily rely on the cursor's position. This is because by default, when parameters are omitted, the operations are performed based on the selection's position. While this is not a problem for ordinary node rendering, it might not be sufficient for implementing more complex modules or interactions, such as asynchronous uploading of tables and images.

Our document editor is certainly not a simple scenario. Therefore, when we need to implement complex operations in the editor, solely relying on the selection to execute operations is evidently not practical. For instance, if we want to insert a blank line under a code block element, since the selection must be on a `Text` node, we cannot directly operate the selection on a `Node` node. This type of implementation cannot solely rely on the selection to achieve the desired result. Additionally, it is not straightforward to determine the current position within a table cell, as the rendering schedule is managed by the framework at that moment, making it impossible to directly access the parent's data object. As many `slate` users are aware, neither `RenderElementProps` nor `RenderLeafProps` pass the `Path` data during rendering, instead only providing attributes and children data.

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

This issue is not unique to rich text editors and can arise in various front-end editing scenarios, such as low-code editors. Commonly, in such scenarios, we utilize a modular approach to implement editors. Consequently, the nodes being rendered are not components that we directly write, but instead, the content is scheduled for rendering by the core layer and plugins. If a single defined component is rendered `N` times, understanding which position of data object to update becomes crucial. While adding an `id` to each rendered object is a possible solution, this approach would require iterating through the entire object to locate the position. In this context, our implementation is more efficient.

Therefore, the `Path` data is essential for data operations. In usual interaction handling, `editor.selection` suffices for most functionalities. However, in many cases, relying solely on `selection` to determine the target `Path` for operations can be limiting. In such situations, one can notice that the most relevant data to `Path` in the data structure being passed are the `element/text` values. Consequently, one can easily recall the existence of a `findPath` method in `ReactEditor`, helping us locate the corresponding `Path` through a `Node`.

```js
// 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
}
}
```

This code snippet succinctly demonstrates a clever use of two `WeakMap` instances to retrieve a node's `Path`. It prompts us to ponder why the `Path` is not directly passed to the rendering method within `RenderProps` and instead necessitates a repetitive search, resulting in a performance cost. In reality, rendering document data alone poses no issues. However, as we typically need to edit documents, problems arise at this juncture. For instance, consider a scenario wherein at position `[10]`, there is a table, and then at position `[6]`, a blank line is added. The `Path` of our table should logically be `[11]`, yet as we did not actually edit any content related to the table, we should not refresh the table's content. Consequently, its `Props` remain unchanged. If we were to directly fetch a value at this point, we would retrieve `[10]` instead of `[11]`.

So, similarly, even if we use `WeakMap` to record the correspondence between `Node` and `Path`, and even if the `Node` of the table hasn't actually changed, we can't easily iterate through all the nodes to update their `Path`. Therefore, we can just look up when needed based on this method. Now, a new issue arises. Since we mentioned earlier that we won't update the table-related content, how should we update its `index` value? Here comes another clever method: every time a rendering is triggered due to data changes, we will also update all its parent nodes. This is consistent with the `immutable` model, so we can update all the affected index values at this point.

How can we avoid updating other nodes? It's quite clear that we can control this behavior based on the `key`. Simply assign a unique `id` to identical nodes. Additionally, here we can see that `useChildren` is defined as a `Hooks`, so it will definitely be called multiple times. Since `findPath` is called every time a component renders, we don't need to worry too much about the performance of this method. The iteration count here is determined by our hierarchy – typically we won't have too many levels of nesting, so the performance aspect is manageable.

```js
// 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)
}
```

We can also utilize this concept to handle tables. When we need to implement complex interactions for table nodes, it's challenging to determine the `[RowIndex, ColIndex]` of the rendering nodes – the current cell's position within the table. We require this information for functionalities like cell selection and resizing. Using `ReactEditor.findPath` can retrieve the latest `Path` based on the `Node`, but with nested data levels, such as nested tables within tables, there are many unnecessary iterations. In reality, just two layers are enough, but using `ReactEditor.findPath` will iterate all the way to the `Editor Node`, which may cause performance issues during frequent actions like resizing.

By leveraging this concept, we can implement two `WeakMaps` as well. When rendering at the top-level node, such as the `Table` node, we establish mapping relationships. Then, we can iterate through `Tr + Cell` elements completely. With the support of `immutable`, we can obtain the current cell's index value. In later versions of `slate`, these two `WeakMaps` have been exported, eliminating the need for us to manually establish mapping relationships. Just retrieve them when necessary.

```js
// 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)
);
}
```

However, there's no issue with obtaining the mapping between `Node` and `Path` nodes to determine their positions in this manner, efficient lookup solutions make it necessary for us to rely on rendering to know the latest position of nodes. This means that when we update a node object, calling the `findPath` method immediately will not give us the latest `Path`, as rendering behavior at that moment is asynchronous. Therefore, if needed, we must iterate through the entire data object to obtain the `Path`. However, I don't think iterating through the entire object is necessary here. After making changes using `Transforms`, we should not immediately retrieve the path value, but rather wait until `React` has finished rendering before proceeding. This allows us to execute related operations in sequence. Since there are no additional asynchronous operations in `slate`, we can easily determine when the current rendering is completed in the `useEffect` of `<Editable />`.

```js
export const WithContext: FC<{ editor: EditorKit }> = props => {
const { editor, children } = props;
const isNeedPaint = useRef(true);
// Ensures that re-render occurs every time Apply is triggered
// 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;
};
```

## Conclusion
In this discussion, we mainly focused on mapping between `Node` nodes and `Path` paths, determining where rendered nodes are located in the document data definition, which is crucial for implementing data changes in `slate`, especially for complex operations that cannot be achieved using only selections. We also analyzed the `slate` source code to explore the implementation of related issues. In the upcoming articles, we will continue discussing the lookup of table cell positions, delving into the design and interaction of the table module.

## Daily Quiz

- <https://github.com/WindRunnerMax/EveryDay>

## References

- <https://docs.slatejs.org/>
- <https://github.com/WindRunnerMax/DocEditor>
- <https://github.com/ianstormtaylor/slate/blob/25be3b/>
Loading

0 comments on commit 48999e5

Please sign in to comment.