lexical
lexical copied to clipboard
feat(core): add experimental decorator element node and nested root node
DecoratorElementNode
This is a VERY EARLY draft of DecoratorElementNode (#5930).
I submit this PR because I want to:
- Confirm the API design.
- Allow the maintenance team and the community to review the codes as early as possible, because I am not an expert of reconciliation, your guidance/your bug report may help me work faster to deal with the reconciliation correctly.
Naive Demo
- CardNode (Fixed children)
https://github.com/facebook/lexical/assets/36890796/61a83c26-02cc-47d4-ab9e-08755512b988
- ReactListNode(Variable children)
https://github.com/facebook/lexical/assets/36890796/812dd021-22a5-4a8a-bdc2-155d71c0dc76
Tasks to do before this PR is submitted
- [ ] Confirm the API design is good enough (I don't like
editor.setNestedRootElement(), could it be better?) - [ ] Fix the most conspicuous reconciliation bugs
- [x] Provide a demo including two fixed nested roots (CardNode)
- [x] Provide a demo of a node containing arbitrary number of child nested roots (ReactListNode)
- [x] Provide a demo that the nested root could be mounted and unmounted with a button (Add a show/hide button to CardNode)
Tasks I don't plan to do in this PR
Since I don't have so much spare time, I plan to deal with those issues later, maybe the community can offer some help.
- Add enough test cases
- Document the node well
- Deal with IO and copy/paste
- Deal with corner cases of the selection change (there are many many bugs with it)
- Guarantee deeply nested decorator element nodes are working correctly
- Make UI of the demo nodes beautiful
API Documentation Draft
DecoratorElementNode
To combine external framework and Lexical node trees, decorator element node could be an option. It offers a decorate() method to allow external framework to render the DOM, but the external can also have child Lexical nodes handled by Lexical.
export class CardNode extends EXPERIMENTAL_DecoratorElementNode<JSX.Element> {
static getType(): string {
return 'card';
}
static clone(node: CardNode): CardNode {
return new CardNode(node.__key);
}
constructor(key?: NodeKey) {
super(key);
// ElementNode will automatically clone children
// So we only need to set the children if the node is new
if (key === undefined) {
this.append($createNestedRootNode());
this.append($createNestedRootNode());
}
}
createDOM(): HTMLElement {
return document.createElement('div');
}
updateDOM(): false {
return false;
}
decorate(editor): JSX.Element {
const onTitleRef = (element) => {
editor.setNestedRootElement(this.getChildAtIndex(0).getKey(), element);
}
const onBodyRef = (element) => {
editor.setNestedRootElement(this.getChildAtIndex(1).getKey(), element);
}
return (
<div>
<div ref={onTitleRef} />
<div ref={onBodyRef} />
</div>
);
}
}
Take the CardNode as an example:
- DecoratorElementNode is a subclass of ElementNode, so it can have an array of LexicalNodes. However, for DecoratorElementNode, the only valid type of its children is
NestedRootNode. To use other nodes, they should be NestedRootNode's children. - Like DecoratorNode, DecoratorElementNode provides
decorate()method, where developers can implement a render function to be handled by external frameworks. - To allow Lexical handle external framework's DOMs and mount Lexical's dom to them,
LexicalEditorprovides ansetNestedRootElementmethod. When a new HTMLElement is set, Lexical will render its children DOMs.
If a DecoratorElementNode has a fixed set of children, developers are responsible to record each child's index and meaning. If it has a variable array of children, then just make sure the onRef's order in decorate function maps to each child correctly.
The latest updates on your projects. Learn more about Vercel for Git ↗︎
| Name | Status | Preview | Comments | Updated (UTC) |
|---|---|---|---|---|
| lexical | ✅ Ready (Inspect) | Visit Preview | 💬 Add feedback | May 9, 2024 9:10am |
| lexical-playground | ✅ Ready (Inspect) | Visit Preview | 💬 Add feedback | May 9, 2024 9:10am |
What are the problems you currently encounter with DecoratorNodes in your use case?
In my case they are:
- different undo managers with the editor.
- fine grained real time collaboration
I believe that these problems are solvable and that they should be addressed even if this PR goes ahead. Perhaps by talking about these problems and yours we can think of other alternatives.
In the past I tried to do something similar to what you do in this PR (on a local branch), but I ran into a lot of complications. The 1 LexicalNode = 1 DOM node model is something that is deeply rooted from the roots of Lexical.
@GermanJablo #5930
I agree I have similar troubles, but the main idea behind is decorating element nodes (instead of manipulating simple DOM node).
By the way, when realizing this is just a simplified nested editor, it is not so difficult to debug. The key is treat NestedRootNode and RootNode in the same manner in the core lib.
Still, may I know how you would describe what specific problems this solves?
Decorate an ElementNode.
Consider things like that: https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/nodes/LayoutContainerNode.ts
You have to know vanilla JS well to make a complex element node, or you can do very little stuff. Alternatively, NestedEditor is needed and there exists multiple editor states.
I'm all for adding support for the decorate method to ElementNode.
-
We often rely on React component libraries like Material-UI to keep our element nodes, such as ParagraphNode and QuoteNode, looking consistent. But without the decorate method, we end up writing more CSS just to match the styles of components in the library.
-
Trying to do things across nested editors comes with its fair share of headaches—think wonky history management, wonky selection handling, tangled data structures, plugin clashes, you name it.
Still, may I know how you would describe what specific problems this solves?
I'm new to Lexical, but I struggle with my use case: I develop basic image gallery plugin. There's a gallery node which is decorator and it has a property __children that are builtin ImageNode from playground. The issues arises when new images inserted into gallery - I create new ImageNode and append them as children prop to my GalleryNode, but doing that causes image to be non interactive(can't edit captions, no visible focus and selection etc.) - because it is not registered in the editor state which leads to $getNodeByKey to return null and all the troubles(internally ImageComponent uses $getNodeByKey to get image that is being interacted with). If I insert image nodes into editor via insertNodes() method, images will become registered in the editor and are interactive, but it leads to node duplication, because image nodes will be part of __children property of gallery and at the same time they will be appended to the root node. I don't exactly know how to go about it for now, maybe use element node which is parent for other nodes? But then user can insert any node type into my gallery including text, and I need to disable all interactions etc.
Am also dreaming of something like an ElementNode with decorate capability 🥹
A separate DecoratorElementNode as proposed here would be very welcome to not risk breaking anything existing but allowing for this additional feature where desired 🙌🏼
Agree with most if not all reasons pointed out here, where the "simplest" is being able to just re-use existing ui components as @LvChengbin has mentioned
Also nested editor situation isn't (yet) as intuitive to use imho, especially, as already mentioned, when it comes to selections, history etc.
Still, may I know how you would describe what specific problems this solves?
I'm new to Lexical, but I struggle with my use case: I develop basic image gallery plugin. There's a gallery node which is decorator and it has a property __children that are builtin ImageNode from playground. The issues arises when new images inserted into gallery - I create new ImageNode and append them as children prop to my GalleryNode, but doing that causes image to be non interactive(can't edit captions, no visible focus and selection etc.) - because it is not registered in the editor state which leads to $getNodeByKey to return null and all the troubles(internally ImageComponent uses $getNodeByKey to get image that is being interacted with). If I insert image nodes into editor via insertNodes() method, images will become registered in the editor and are interactive, but it leads to node duplication, because image nodes will be part of __children property of gallery and at the same time they will be appended to the root node. I don't exactly know how to go about it for now, maybe use element node which is parent for other nodes? But then user can insert any node type into my gallery including text, and I need to disable all interactions etc.
Answering my own question: use regular ElementNode as a parent for other nodes, i.e GalleryNode that is a wrapper(parent) for already existing ImageNode in the lexical. Create new GalleryNode from user interaction (toolbar, slash menu etc.), and handle adding new images to gallery - create ImageNode and append it as a child to GalleryNode. The problem was to create a separate gallery handler (mostly buttons, and inputs for user to select new images, set amount of columns and other settings) that is a react component and needed to be appended as a child into each gallery that existed in the editor (there can be multiple galleries in one editor). At first I used a method of document.createElement and setAttribute to set a node key on each gallery controls and createDOM which created html stucture. Then in separate react component via useEffect I found all the galleries there were in the editor and attached event listeners to it. It was tedious and proned to errors. As it later turned out I was wrong and there is a way easier method: create a separate DecoratorNode that renderes react component(decorates) and then append it as a child to GalleryNode, and voila you can use regular react without vanilla js in createDOM.