-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Proposal: DecoratorElementNode #5930
Description
I've wanted this feature for a long long time, today I get some ideas to implement it, so I'd like to discuss it here.
People need something like "ReactElementNode"
I've seen such questions again and again. People not only need a plain ElementNode which is a single DOM node, they want to also use other framework's generated DOM node as the parent of a set of Lexical nodes. However, this is not natively supported.
Let's first check other frameworks. Slate.js seamlessly supports it, TipTap breaks the element and the child into NodeViewWrapper and NodeViewComponent, check doc and example. All of them are handle those cases in one editorState.
Now let's go back to Lexical. Lexical offers nested editors and LexicalNestedComposer. However, after heavily develop use cases with it, I find it has a lot to improve.
Why nested editor is not a good idea?
-
Moving contents between editors is painful.
You can't directly move nodes between editors. Serialization is mandatory and I have to deal with at least twoeditor.updates. -
Create a plugin that supports multiple editors is painful.
Due to previous reason, when I created a drag and drop plugin that supports nested editors, I have to consider if the editor to be dropped is the same with the dragging node's editor. There are two cases, the first one is oneeditor.update, the second one needs twoupdatecall and serialization. -
Modifying multiple editors SIMULTANEOUSLY is poorly supported.
Suppose I move a node from one editor to the other. This is a transaction that involves two editors. However, both HistoryPlugin and CollaborationPlugin treats them as two separate transactions. I've spent lots of time modifying them.
I've been going deeper with those questions. I think, as an editor framework whose idea originates from React, the editorState should also be singleton, just like how React does. To be more precise, for a single LexicalComposer, all the nodes within it, including those nested editors, should share the same editorState.
Implementation Proposal
What nested editor is doing
I'd like to share some understanding of Lexical. First, Lexical (core) is a framework that only deals with DOM nodes. Each Lexical node is expected to represent exactly one DOM node, so when the editorState (Lexical node tree) updates, the Lexical node tree is mapped to the DOM tree. One Lexical node <=> one DOM node, element node maps to a DOM node contains a sequence of child DOM nodes. TextNode maps a DOM node with no child. Decorator node maps to a DOM node with no child as the portal, the portal can mount, for example, child React elements.
What nested editor does is simple: it adds another root DOM, then mounts its editor's DOM tree to that root. That's all.
It means, if we supports multiple root DOMs in Lexical core, then nested editors will no longer be needed, and we can merge nested node tree into one node tree. Now only one editorState, and we are able to implement DecoratorElementNode.
How the API will be like
Nested editor needs a nested LexicalContentEditable, which is for setting the root element during the first render. We can take the procedure in the core.
Example: CardNode with two editable areas title and body.
class CardNode extends DecoratorElementNode {
_titleRoot = $createNestedRootNode();
_bodyRoot = $createNestedRootNode();
rootNodes() {
return {
title: this._titleRoot.getKey(),
body: this._bodyRoot.getKey(),
}
}
decorate(_editor, _config, setRootElement) {
const setTitle = element => setRootElement('title', element);
const setBody = element => setRootElement('body', element);
return (
<div>
<div>Title:<div ref={setTitle} /></div>
<div>Body:<div ref={setBody} /></div>
</div>
);
}
}The TypeScript signature of DecoratorElementNode is like:
class DecoratorElementNode<T, RootKeys extends string> extends ElementNode {
// Both the keys and the values of the return value could change when the node updates
// Just like how a decorator with nested editors could behave
// LexicalKey must be key of LexicalNestedRootNode?
rootNodes(): Record<RootKeys, LexicalKey> {
return {};
}
decorate(
editor: LexicalEditor,
config: EditorConfig,
setRootElement: (rootKey: RootKey, element: null | HTMLElement) => void
): T {}
// Lexical core should avoid calling this method during updates
// For tree traversal utilities only, the order is meaningless
getChildren(): LexicalNestedRootNode[] {
// Seems reorder and variable length is just fine if we do not call this method in Lexical core?
// I'd like to recommend people to keep it fixed themselves, but not force to do so.
return Object.values(this.rootNodes())
}
// similar to previous method, those indexed children accessor should be overridden
// indexed children modifier should throw exceptions
getChildAtIndex(index: number): LexicalNestedRootNode | T {}
}
class LexicalNestedRootNode extends ElementNode {
isShadowRoot() { return true; }
// Those positional methods should be overridden
getNextSibling() {
return this.getParentOrThrow().getChildAtIndex(this.getIndexWithinParent() + 1);
}
}How the editor update executes
I've not thought about it thoroughly, but the idea is the same with nested editors. During the first render, the DecoratorElementNode's rootElement is null, nothing happens. Later in another micro task, the decorators are rendered by frameworks like React and root element is now available, then queue another micro task to mount the node's children in Lexical. Later on, if the rootElement is available, directly update the children.
Summary
Only one editorState for all nested editors improves DX a lot. We can implement by moving how nested editors are currently rendered to Lexical core.