-
Notifications
You must be signed in to change notification settings - Fork 1.8k
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] Feature: add a generic state property to all nodes #7117
base: main
Are you sure you want to change the base?
Changes from 11 commits
a6288f9
a90a719
448e2af
bf70110
6ebd4e5
e46d735
e404d32
c1cfce4
6d00c64
e0c5945
7728780
d807d4f
90a032b
c663540
113a4f1
91148f3
47435b0
fb7e164
eab152e
d2b4f21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -134,6 +134,7 @@ describe('table selection', () => { | |
__parent: null, | ||
__prev: null, | ||
__size: 1, | ||
__state: {}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps I could have used Object.defineProperty to avoid having to modify the tests, but I preferred to do this because it turned out that there were few tests affected and the changes were trivial. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this will probably be necessary to implement for correct yjs behavior There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I already followed your suggestion to copy the state object in setState, so this suggestion is no longer necessary? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not familiar with the intricacies of testing lexical-yjs off-hand, I just know its implementation is very problematic because it manipulates node properties directly rather than going through the constructor and well defined serializations. I'd find another node that uses a non-primitive property (something to do with comments or marks probably?) and see if there's any relevant testing. There might not be any good testing for this sort of thing currently. |
||
__style: '', | ||
__textFormat: 0, | ||
__textStyle: '', | ||
|
@@ -150,6 +151,7 @@ describe('table selection', () => { | |
__parent: 'root', | ||
__prev: null, | ||
__size: 1, | ||
__state: {}, | ||
__style: '', | ||
__textFormat: 0, | ||
__textStyle: '', | ||
|
@@ -163,6 +165,7 @@ describe('table selection', () => { | |
__next: null, | ||
__parent: paragraphKey, | ||
__prev: null, | ||
__state: {}, | ||
__style: '', | ||
__text: 'Hello world', | ||
__type: 'text', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,53 @@ | ||
# Node Customization | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know how you guys handle redirects on the website, but I think it would be nice to rename the url to this title. |
||
|
||
Originally the only way to customize nodes was using the node replacement API. Recently we have introduced a second way with the `state` property which has some advantages described below. | ||
|
||
## State (New) | ||
|
||
The advantages of using state over the replacement API are: | ||
1. Easier (less boilerplate) | ||
2. Composable (multiple plugins extending the same node causes failures) | ||
3. Allows metadata: useful for adding things to the RootNode. | ||
|
||
All you need to do is define keys with `createStateKey`, and then use it with the `setState` and `getState` methods. | ||
|
||
```ts | ||
// IMPLEMENTATION | ||
const color = createStateKey('color', { parse: (value) => value as string }); | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// USAGE | ||
const textNode = new TextNode(); | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
textNode.setState(color, "blue"); | ||
const textColor = textNode.getState(color) // -> "blue" | ||
``` | ||
|
||
Inside state, you can put any serializable json value except null. Our recommendation is to always use TypeScript in strict mode, so you don't have to worry about these things! | ||
|
||
### Important | ||
|
||
we recommend that you use prefixes with low collision probability when defining state keys. For example, if you are making a plugin called `awesome-lexical`, you could do: | ||
|
||
```ts | ||
const color = createStateKey('awesome-lexical-color', /** your parse fn */) | ||
const bgColor = createStateKey('awesome-lexical-bg-color', /** your parse fn */) | ||
|
||
// Or you can add all your state inside an object: | ||
type AwesomeLexical = { | ||
color?: string; | ||
bgColor?: string; | ||
padding?: number | ||
} | ||
const awesomeLexical = createStateKey('awesome-lexical', /** your parse fn which returns AwesomeLexical type */) | ||
|
||
``` | ||
|
||
# Node Overrides / Node Replacements | ||
|
||
Some of the most commonly used Lexical Nodes are owned and maintained by the core library. For example, ParagraphNode, HeadingNode, QuoteNode, List(Item)Node etc - these are all provided by Lexical packages, which provides an easier out-of-the-box experience for some editor features, but makes it difficult to override their behavior. For instance, if you wanted to change the behavior of ListNode, you would typically extend the class and override the methods. However, how would you tell Lexical to use *your* ListNode subclass in the ListPlugin instead of using the core ListNode? That's where Node Overrides can help. | ||
|
||
Node Overrides allow you to replace all instances of a given node in your editor with instances of a different node class. This can be done through the nodes array in the Editor config: | ||
|
||
``` | ||
```ts | ||
const editorConfig = { | ||
... | ||
nodes=[ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -59,6 +59,7 @@ export type SerializedLexicalNode = { | |
type: string; | ||
/** A numeric version for this schema, defaulting to 1, but not generally recommended for use */ | ||
version: number; | ||
state?: State; | ||
}; | ||
|
||
/** | ||
|
@@ -184,6 +185,39 @@ export type DOMExportOutput = { | |
|
||
export type NodeKey = string; | ||
|
||
type DeepImmutable<T> = T extends Map<infer K, infer V> | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>> | ||
: T extends Set<infer S> | ||
? ReadonlySet<DeepImmutable<S>> | ||
: T extends object | ||
? { | ||
readonly [K in keyof T]: DeepImmutable<T[K]>; | ||
} | ||
: T; | ||
type State = {[Key in string]?: string | number | boolean | State}; | ||
type StateValue = string | number | boolean | State; | ||
interface StateKey< | ||
K extends string = string, | ||
V extends StateValue = StateValue, | ||
> { | ||
key: K; | ||
// Here we are storing a default for convenience | ||
value: DeepImmutable<V>; | ||
parse: (value: unknown) => V; | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
interface StateKeyConfig<V extends StateValue = StateValue> { | ||
parse: (value: unknown) => V; | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
type StateKeyConfigValue<T extends StateKeyConfig> = ReturnType<T['parse']>; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export function createStateKey<K extends string, T extends StateKeyConfig<any>>( | ||
key: K, | ||
config: T, | ||
): StateKey<K, StateKeyConfigValue<T>> { | ||
return {key, parse: config.parse, value: config.parse(undefined)}; | ||
} | ||
|
||
export class LexicalNode { | ||
// Allow us to look up the type including static props | ||
['constructor']!: KlassConstructor<typeof LexicalNode>; | ||
|
@@ -198,6 +232,19 @@ export class LexicalNode { | |
__prev: null | NodeKey; | ||
/** @internal */ | ||
__next: null | NodeKey; | ||
/** @internal */ | ||
__state: DeepImmutable<State> = {}; | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
getState<T extends StateKey>(k: T): T['value'] | undefined { | ||
const self = this.getLatest(); | ||
// If the state is not set, return the default value | ||
return self.__state[k.key] ?? k.parse(undefined); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With a single object implementation this must always call parse because we don’t know if its directly from JSON or not
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
setState<T extends StateKey>(k: T, v: T['value']) { | ||
const self = this.getWritable(); | ||
(self.__state as State)[k.key] = v; | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// Flow doesn't support abstract classes unfortunately, so we can't _force_ | ||
// subclasses of Node to implement statics. All subclasses of Node should have | ||
|
@@ -885,6 +932,7 @@ export class LexicalNode { | |
return { | ||
type: this.__type, | ||
version: 1, | ||
...(objetcIsEmpty(this.__state) ? {} : {state: this.__state}), | ||
}; | ||
} | ||
|
||
|
@@ -934,6 +982,7 @@ export class LexicalNode { | |
updateFromJSON( | ||
serializedNode: LexicalUpdateJSON<SerializedLexicalNode>, | ||
): this { | ||
this.__state = serializedNode.state || {}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic also needs to go into afterCloneFrom, and probably worth adding tests to make sure that this feature doesn’t rewrite history (setting state shouldn’t affect previous reconciled versions of the node) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any idea how to test it? I tried comparing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let v0: RootNode;
let v1: RootNode;
const vk = createStateKey('vk', { parse: (v) => typeof v === 'number' ? v : null });
editor.update(
() => {
v0 = $getRoot();
v0.setState(vk, 0);
expect(v0.getState(vk)).toBe(0);
},
{discrete: true},
);
const state0 = editor.getEditorState();
editor.update(
() => {
v0.setState(vk, 1);
// normally would use chaining for this but setState doesn't return this
v1 = v0.getLatest();
// This is testing getLatest()
expect(v0.getState(vk)).toBe(1);
expect(v1.getState(vk)).toBe(1);
expect(v1.is(v0)).toBe(true);
},
{discrete: true},
);
const state1 = editor.getEditorState();
// Test that the correct version is returned and that they are independent
expect(state0.read(() => v0.getState(vk))).toBe(0);
expect(state1.read(() => v1.getState(vk))).toBe(1);
// Test that getLatest is used and not the __state property directly
expect(state0.read(() => v1.getState(vk))).toBe(0);
expect(state1.read(() => v0.getState(vk))).toBe(1); |
||
return this; | ||
} | ||
|
||
|
@@ -1289,3 +1338,14 @@ export function insertRangeAfter( | |
currentNode = currentNode.insertAfter(nodeToInsert); | ||
} | ||
} | ||
|
||
/** | ||
* The best way to check if an object is empty in O(1) | ||
* @see https://stackoverflow.com/a/59787784/10476393 | ||
*/ | ||
function objetcIsEmpty(obj: object) { | ||
GermanJablo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for (const key in obj) { | ||
return false; | ||
} | ||
return true; | ||
} |
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.
If I may, I would like to propose this form of opt-out which has legitimate uses. For example, in LexicalNode.test.ts when I define type variables just for the purpose of testing the types.
Also, while developing a WIP it's convenient to be able to mute warnings without having to write comments everywhere.
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 prefer this setting also, but maybe there’s some meta style guide reason for not having this? /cc @zurfyx