Skip to content

Latest commit

 

History

History
219 lines (183 loc) · 13.9 KB

Node节点与Path路径映射.md

File metadata and controls

219 lines (183 loc) · 13.9 KB

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.

Related articles about the slate document editor project:

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.

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

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

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.

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

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

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

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

References