-
Notifications
You must be signed in to change notification settings - Fork 1.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[lexical][@lexical/selection] Feature: add a generic classes
property to all nodes for easier customization
#6929
base: main
Are you sure you want to change the base?
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
size-limit report 📦
|
@@ -44,49 +44,52 @@ import {createRoot, Root} from 'react-dom/client'; | |||
import * as ReactTestUtils from 'shared/react-test-utils'; | |||
|
|||
type SerializedCustomTextNode = Spread< | |||
{type: ReturnType<typeof CustomTextNode.getType>; classes: string[]}, | |||
{type: ReturnType<typeof CustomTextNode.getType>; classList: string[]}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changes in this file are only a rename to avoid collisions and can be ignored.
I have also wondered what would happen if a user has extended a node by adding the classes property to it. Perhaps we could reserve a prefix and use something like $classes
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the general idea here but I am not sure that this is the ideal API design (I do not have a concrete alternate proposal, this isn't something I have thought much about). Some initial thoughts:
- There's no consideration for more semantic meaning or how this interacts with themes
- It may not work very well for users who are using utility classes (e.g. tailwind)
- Namespace collisions (as you've identified in the example)
- Doesn't really distinguish between classes used for createDOM or exportDOM
- The implementation seems to copy more often than is necessary, given the access patterns __classes should be the readonly type and mutateClasses should have you work on a copy (and it should allow you to return a different object if you want)
- The prefixed variant seems useful for semantic reasons (e.g.
state: 'active'
andstate: 'inactive'
being mutually exclusive without having to toggle them separately) but is maybe a bit too prescriptive for exactly what the output class name looks like (although it is BEM compliant so it's not without precedent) - It's likely that a lot of createDOM/updateDOM implementations won't be compatible with this up front particularly because LexicalNode doesn't have an implementation of it and this PR only considers TextNode
- No consideration for importDOM (as identified in the description)
Thank you for your thoughts! (1), (2) and (6): I see no problem. Users can define the classes they want without any kind of prescription. They can choose (3): I think it would be good to reserve a prefix, since this problem actually applies every time Lexical adds a property or method. Or these occasions can also be marked as breaking changes... (4) and (8): whether we should export the classes to the DOM is debatable. I don't think we should import them by default. It is left to the user to define the conversion, something they can do without replacing the nodes. (5) that would involve making a copy in each call to mutateClasses. I'm not sure I follow you, but if you have a suggestion on the API and could put it in the code comments or commit it would be great. (7): This PR works with all nodes. I made As a side note, I would like to add that I have reviewed all the nodes I have replaced and talked to other users who have heavily customized their nodes, and in all cases we have found that this API could have saved us the work. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think overall I'm -0 on this proposal. I like the idea behind it, I think it would be great to have a bit more generic control over DOM nodes without subclassing (especially when mixing in some additional state). However, this approach seems like it's baked in at an awkward level of abstraction. Technically I think it should work if other maintainers agree that this is the approach we should adopt.
I would prefer to have something that is integrated with the normal lifecycle of createDOM/updateDOM/exportDOM. Yes, this would require more careful plumbing, and wouldn't necessarily "just work" for third party node classes that aren't calling the super methods to a base class in lexical or otherwise have explicit support for this, but I think that the trade-off for consistency and future-proofing would be worth it.
Object.keys(node1Classes).length === Object.keys(node2Classes).length && | ||
Object.keys(node1Classes).every( | ||
(key) => node1Classes[key] === node2Classes[key], | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking the equivalence between to classes objects should really be in a function where it's easier to early exit and use a more performant for loop instead of every
Object.keys(node1Classes).length === Object.keys(node2Classes).length && | |
Object.keys(node1Classes).every( | |
(key) => node1Classes[key] === node2Classes[key], | |
) | |
(node1ClassKeys === null || | |
(node1ClassKeys.length === Object.keys(node2Classes).length && | |
node1ClassKeys.every((key) => node1Classes[key] === node2Classes[key]))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Array.every
exits early if it finds a counterexample
// Update node. If it returns true, we need to unmount and re-create the node | ||
if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { | ||
if (nextNode.updateDOM(prevNode, dom, activeEditorConfig) || classesChanged) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really want to re-create the node every time classes change? That seems odd.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well... I could rebuild just the class attribute, yes. But I don't think this behavior is odd considering that this is how updateDOM
works with any other property in Lexical.
const nextClasses = nextNode.__classes || {}; | ||
const prevClasses = prevNode.__classes || {}; | ||
const classesChanged = !( | ||
Object.keys(nextClasses).length === Object.keys(prevClasses).length && | ||
Object.keys(nextClasses).every( | ||
(_key) => nextClasses[_key] === prevClasses[_key], | ||
) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should all be in an optimized function per previous comment in LexicalNormalizer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Array.every
exits early if it finds a counterexample
@@ -173,6 +173,15 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { | |||
invariant(false, 'createNode: node does not exist in nodeMap'); | |||
} | |||
const dom = node.createDOM(activeEditorConfig, activeEditor); | |||
if (node.__classes) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The more I look at this the less I like that this adds DOM manipulation to every node that's outside of createDOM and doesn't give the author of the node class any control over what's happening
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is an opt-in feature. If you do not use setClass none will appear.
The same happens with __indent
.
Regarding prefixes I think the simplest thing to do would be to document that the two-underscore prefix is reserved for use by Lexical, and implementing their own classes in the same style may result in namespace collisions. However, we also add methods occasionally without any sort of prefix, so I guess the real answer is that users just have to live with possible compatibility issues over time unless they do their own prefixing because that's just how OOP works. There are plenty of use cases for aria roles, dataset, and other attributes (particularly id, maybe title or microdata related attributes) with the existing nodes. For aria we don't really have semantic elements so you may want to specify the role or region. For other attributes the most obvious one would be |
Co-authored-by: Bob Ippolito <[email protected]>
Co-authored-by: Bob Ippolito <[email protected]>
Co-authored-by: Bob Ippolito <[email protected]>
…I just needed to remove the return type
Thanks for your feedback again Etrepum. Extending nodes is a very complex thing to do and with a lot of footguns. On top of that, there is another additional problem that I didn't emphasize in the description, and that is that extending nodes is not composable. For example, in Payload the community can create their own plugins. If two of them extend the same node, it doesn't work. This PR handles a lot of things for the user in a composable way. I've seen experienced users fail at many of those things, like making JSON serialization lightweight. I don't know if this API is perfect, but I think it adds value and solves a long-recognized problem at Lexical. Does this mean that users will never have to use Node Replacement again? Not at all. That's not the purpose of this PR either. If they have to add an aria or any other attribute for some reason, they can extend the node. And if a very common use case emerges (which I doubt), a similar API for another attribute may be considered. But I think this will make life easier for all users in the vast majority of cases. I join you in waiting to hear what the other team members think. |
You're right that extending nodes is not composable, so maybe the right solution is to come up with an extension mechanism that is composable rather than hack in support to only mess with the class attribute in particular. |
If we add an arbitrary bag of data that is guaranteed to be serialized/deserialized (at least with JSON) to the LexicalNode, then you could write a plugin that performs everything this proposal accomplishes with a similar interface, but also unlocks other possibilities.
Missing pieces are:
For full fidelity you'd probably want similar extension points for importDOM and exportDOM, something like middleware where you are able to get the result of what would happen without the plugin, and then have the opportunity to modify the result based on the context. This would probably be useful for other purposes. This data property would also be supported by RootNode (just by virtue of where it would be implemented in the class hierarchy) so we would also finally have a way to store versioned document level data too (this pretty much covers any use case for isolated decorator nodes which don't really work). |
I find the idea interesting, although it feels like a major abstraction for a feature that no one has asked for and that it could be implemented without breaking changes even if this PR had already been merged. As long as the JSON serialization ends up being the same, you could have the styles stored in the format that exists today, and the classes in that of this PR. And speaking of format, one of the things I like most about this PR is that classes follow the key-value format with the option to result in prefix-suffix, since manipulating an array of strings is often awkward. Maybe for things whose real abstraction is not a boolean but a set of options like You also mentioned a ClassPlugin. I would like to avoid that. However this is implemented, I would love for users to be able to set a class to the node and be done.
Same thing I said just now. I would love exportDOM to work out of the box. One of the main points of all this is to reduce the complexity of customizing nodes. ImportDOM is trickier because the content can come from an external source like the clipboard, but for that there is already this option. |
Thank you German, we appreciate your contributions as usual! We discussed this PR in the previous session. Overall, there's some benefit in the newly introduced Cost The recent DOM Slot API would instead fall into the category of low-use but a must-have to enable certain use cases. Similarly, I'm personally not keen on the Backward compatibility It is fair to say we have done many backward incompatible changes in the past year but storage is an area that must be backward compatible, or at least fully understand the impact that these can have on existing products. We still think there is value in the class shortcut, do you think we should explore it as a utility function? We can also use the VC time this week to discuss it over if you prefer, I would love to hear more about your actual use case |
Hi @zurfyx, thanks for the review!
Even if we patched the DOM outside of Lexical, the classes would have to be properties of the nodes. How are you visualizing the persistence of the classes if this is not the case?
What do you mean by redundant and budget size? I understand that Meta or other users may have lost interest if they have already achieved their goals in other ways. But I am trying to understand the disadvantages of introducing something like this. The cost in bundle size and runtime performance is negligible.
It helps that you talk about it as a "must-have". That PR started after my proposal for a scrollable-node. Yes, If that API was accepted, I think this one has much more merit to be so. As I said before, "I have reviewed all the nodes I have replaced and talked to other users who have heavily customized their nodes, and in all cases we have found that this API could have saved us the work.". And to put into context the usefulness of this, it would be good to remember everything a user has to do today if he wants to make a colored TextNode: Expand to see exampleexport type SerializedColoredTextNode = Spread<
{
color: string;
type: 'colored';
version: 1;
},
SerializedTextNode
>;
export class ColoredNode extends TextNode {
__color: string;
constructor(text: string, color?: string, key?: NodeKey): void {
super(text, key);
this.__color = color || "defaultColor";
}
static getType(): string {
return 'colored';
}
setColor(color: string) {
const self = this.getWritable();
self.__color = color;
}
getColor(): string {
const self = this.getLatest();
return self.__color;
}
static clone(node: ColoredNode): ColoredNode {
return new ColoredNode(node.__text, node.__color, node.__key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
element.style.color = this.__color || "defaultColor";
return element;
}
updateDOM(prevNode: ColoredNode, dom: HTMLElement, config: EditorConfig): boolean {
const isUpdated = super.updateDOM(prevNode, dom, config);
if (prevNode.__color !== this.__color) {
dom.style.color = this.__color;
}
return isUpdated;
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
const node = new ColoredNode(serializedNode.text, node.serializedNode.color);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedHeadingNode {
return {
...super.exportJSON(),
color: this.getColor()
tag: this.getTag(),
type: 'colored',
version: 1,
};
}
}
export const nodes = [
ColoredNode,
{
replace: TextNode,
with: (node: TextNode) => new ColoredNode(node.__text),
withKlass: ColoredNode,
},
// ...
] I could list 100 common mistakes that aren't obvious and could go wrong when doing that. It doesn't seem like this should be the preferred way to customize a node. But still, this isn't my main reason for wanting to introduce a new API (more on that below).
Are you seeing some incompatibility that I am not? How could this API break existing products?
Improving DX is important, no doubt. But my main interest is what I mentioned above: "extending nodes is not composable. For example, in Payload the community can create their own plugins. If two of them extend the same node, it doesn't work". This may not be crucial for a user like Meta who has full control over the editor's source code. But on platforms like ours this composability is crucial. |
To your point I think that extending/overriding nodes in a more composable fashion is a great idea, and an ideal to move towards, but I don't think that adding this really gets us much closer to that except for one opinionated use case. It just gives us a property that's managed in a different way than almost everything else is that we would have to support, and it doesn't really compose well with the existing ad-hoc direct classList manipulation or theme facilities. I think if we were to do something like this it should be a more holistic proposal that improves and/or integrates with all of it. What about having PayloadCMS override TextNode, provide any extensibility hooks you want in whatever fashion you think would make sense for that ecosystem, and have plugin authors extend or otherwise augment your implementation instead of baking this directly into the reconciler? |
For the record, I am super fine with introducing a more general or “holistic” API like the one you mention. Also, I think you have convinced me that type Attributes = {
classList: string[],
id: string,
data: Record<string, string>,
aria: Record<string, string>,
}
class LexicalNode {
attributes: Attributes
} Or maybe just About the second paragraph, the problem is that it would break users who are already extending TextNode. |
Regarding existing users who have already extended If you really did want to simplify the // getStyleObjectFromCSS and getCSSFromStyleObject are not currently exported but are in lexical-selection
function $getTextColor(node: TextNode): string {
return getStyleObjectFromCSS(node.getStyle()).color || 'defaultColor';
}
function $setTextColor(node: TextNode, color: string): void {
node.setStyle(getCSSFromStyleObject({...getStyleObjectFromCSS(node.getStyle()), color}));
} I think it would be a bad idea to commit to a specific representation for managing attributes, because it prevents future optimization and refactoring. I think the API should be some combination of methods and/or $functions to read or manipulate them. IIRC in order to support today's collab this data structure would have to be both JSON serializable and read-only (or managed carefully with properties). There are a couple things to consider here:
I don't have a concrete proposal for an API that covers everything well. |
Yes, it works for styles, but that wasn't the point of the example. Any case where you need to add another common property (like a class) to a node requires all that ceremony.
That's a good point. Many DOM APIs make a lot of sense in specific contexts and could be easily replicated. The lowest level ones are My suggestion would be to provide That would allow us to optimize or refactor some APIs later. For example, if we wanted to add |
Many places have discussed how node customization and extension could be made simpler (e.g. [1], [2]).
We can divide the solutions into 2 groups:
Introducing Classes
Most of the time when users want to customize a node they just want to add a property to it, which ends up being reflected as a class in the dom element.
To satisfy that need we can introduced two new methods to all nodes:
getClasses
andsetClass
.EDITED: expand this to see the first API proposal
Under the hood the way I am implementing it is with a flat object whose values can be just strings or true.
Considerations
Element.classList
API?__classes
in thecreateDOM
,exportJSON
,importJSON
,clone
andexportDOM
of each node, but in the places where those methods are invoked for 3 reasons:__indent
is applied in the reconciler and users do not have access to it.getClasses<T>
andmuteClasses<T>
.$forEachSelectedTextNode
from$patchStyleText
. There are other methods that could use it because they are very similar, such as$RangeSelection.formatText
.Test plan
The PR contains tests.
Also, if you want to test it in the browser you can paste the
CoolRedPlugin
above in the playground editor.