Skip to content
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

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a6288f9
state to lexicalNode
GermanJablo Dec 19, 2024
a90a719
Squashed commit of the following:
GermanJablo Jan 30, 2025
448e2af
first version with default value
GermanJablo Jan 30, 2025
bf70110
make state json serializable
GermanJablo Jan 30, 2025
6ebd4e5
add import and export json tests
GermanJablo Jan 30, 2025
e46d735
getState returns immutable types
GermanJablo Jan 31, 2025
e404d32
fix tests
GermanJablo Jan 31, 2025
c1cfce4
Merge remote-tracking branch 'origin/main' into state
GermanJablo Jan 31, 2025
6d00c64
add docs
GermanJablo Jan 31, 2025
e0c5945
remove readonly property
GermanJablo Jan 31, 2025
7728780
add paragraph in docs about json serializable values
GermanJablo Jan 31, 2025
d807d4f
better example in docs. getState does not necessarily return undefined.
GermanJablo Jan 31, 2025
90a032b
use $createTextNode()
GermanJablo Jan 31, 2025
c663540
fix type
GermanJablo Jan 31, 2025
113a4f1
create a new object in setState
GermanJablo Jan 31, 2025
91148f3
fix unit test
GermanJablo Jan 31, 2025
47435b0
Update packages/lexical/src/LexicalNode.ts
GermanJablo Jan 31, 2025
fb7e164
Update packages/lexical/src/LexicalNode.ts
GermanJablo Jan 31, 2025
eab152e
Update packages/lexical/src/LexicalNode.ts
GermanJablo Jan 31, 2025
d2b4f21
Update packages/lexical/src/LexicalNode.ts
GermanJablo Jan 31, 2025
2bc4a0e
add null to State type, fix parse function in test
GermanJablo Feb 3, 2025
fca2071
add Bob's test about previous reconciled versions of the node
GermanJablo Feb 3, 2025
8cf6855
improve parse functions in tests again
GermanJablo Feb 3, 2025
abd3d3e
add stateStore to register state keys
GermanJablo Feb 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ module.exports = {
],
'@typescript-eslint/ban-ts-comment': OFF,
'@typescript-eslint/no-this-alias': OFF,
'@typescript-eslint/no-unused-vars': [ERROR, {args: 'none'}],
'@typescript-eslint/no-unused-vars': [
ERROR,
{args: 'none', argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
],
GermanJablo marked this conversation as resolved.
Show resolved Hide resolved
'header/header': [2, 'scripts/www/headerTemplate.js'],
},
},
Expand Down Expand Up @@ -226,8 +229,6 @@ module.exports = {

'no-unused-expressions': ERROR,

'no-unused-vars': [ERROR, {args: 'none'}],

'no-use-before-define': OFF,

// Flow fails with with non-string literal keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ describe('table selection', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?
On the other hand, do you know what a unit test for yjs could look like?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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: '',
Expand All @@ -150,6 +151,7 @@ describe('table selection', () => {
__parent: 'root',
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -163,6 +165,7 @@ describe('table selection', () => {
__next: null,
__parent: paragraphKey,
__prev: null,
__state: {},
__style: '',
__text: 'Hello world',
__type: 'text',
Expand Down
45 changes: 44 additions & 1 deletion packages/lexical-website/docs/concepts/node-replacement.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,55 @@
# Node Customization
GermanJablo marked this conversation as resolved.
Show resolved Hide resolved

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: unknown) => (typeof value === 'string' ? value : undefined),
});

// USAGE
const textNode = $createTextNode();
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=[
Expand Down
83 changes: 83 additions & 0 deletions packages/lexical/src/LexicalNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -184,6 +185,47 @@ 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 | null | boolean | State};
type StateValue = string | number | boolean | null | undefined | State;
interface StateKey<
K extends string = string,
V extends StateValue = StateValue,
> {
readonly key: K;
// Here we are storing a default for convenience
readonly value: DeepImmutable<V>;
readonly parse: (value: unknown) => V;
}
interface StateKeyConfig<V extends StateValue = StateValue> {
readonly parse: (value: unknown) => V;
}
type StateKeyConfigValue<T extends StateKeyConfig> = ReturnType<T['parse']>;

const stateStore = new Map<string, StateKeyConfig>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably shouldn't have a global state store, you should be able to configure states to work differently for multiple editors on the page in the same way you can define multiple nodes with the same type. A WeakMap<Editor, Map<string, StateKeyConfig>> could work for this purpose, or it could be directly added to the editor. It would only be able to test on first use, but we can add state keys to editor/plugin/node config to detect conflicts early in a later version (e.g. with lexical builder)


// 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>> {
if (stateStore.has(key)) {
throw new Error(
`There has been an attempt to register a state with the key "${key}", but it is already registered.`,
);
}
stateStore.set(key, config);
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>;
Expand All @@ -198,6 +240,20 @@ 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'] {
const self = this.getLatest();
// If the state is not set, return the default value
return k.parse(self.__state[k.key]);
}

setState<T extends StateKey>(k: T, v: T['value']): this {
const self = this.getWritable();
self.__state = {...self.__state, [k.key]: v};
return self;
}

// Flow doesn't support abstract classes unfortunately, so we can't _force_
// subclasses of Node to implement statics. All subclasses of Node should have
Expand Down Expand Up @@ -286,6 +342,7 @@ export class LexicalNode {
this.__parent = prevNode.__parent;
this.__next = prevNode.__next;
this.__prev = prevNode.__prev;
this.__state = prevNode.__state || {};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -882,9 +939,23 @@ export class LexicalNode {
*
* */
exportJSON(): SerializedLexicalNode {
const state: State = {};
Object.entries(this.__state).forEach(([key, value]) => {
const config = stateStore.get(key);
if (!config) {
throw new Error(
`There has been an attempt to export a state with the key "${key}", but it is not registered.`,
);
}
Comment on lines +944 to +949
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better not to have an error here, particularly for versioning reasons. If you have two versions of the editor and the new version writes a document with a new key, it's better for the old version to leave it as-is rather than throw an error on save or discard it.

// We don't export state if it's the default value
if (value !== config.parse(undefined)) {
state[key] = value;
}
});
return {
type: this.__type,
version: 1,
...(objectIsEmpty(state) ? {} : {state}),
};
}

Expand Down Expand Up @@ -934,6 +1005,7 @@ export class LexicalNode {
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedLexicalNode>,
): this {
this.__state = serializedNode.state || {};
GermanJablo marked this conversation as resolved.
Show resolved Hide resolved
return this;
}

Expand Down Expand Up @@ -1289,3 +1361,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 objectIsEmpty(obj: object) {
for (const key in obj) {
return false;
}
return true;
}
9 changes: 9 additions & 0 deletions packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,7 @@ describe('LexicalEditor tests', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -1222,6 +1223,7 @@ describe('LexicalEditor tests', () => {
__parent: 'root',
__prev: null,
__size: 0,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand Down Expand Up @@ -1295,6 +1297,7 @@ describe('LexicalEditor tests', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -1311,6 +1314,7 @@ describe('LexicalEditor tests', () => {
__parent: 'root',
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -1324,6 +1328,7 @@ describe('LexicalEditor tests', () => {
__next: null,
__parent: paragraphKey,
__prev: null,
__state: {},
__style: '',
__text: 'Hello world',
__type: 'text',
Expand Down Expand Up @@ -1379,6 +1384,7 @@ describe('LexicalEditor tests', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -1395,6 +1401,7 @@ describe('LexicalEditor tests', () => {
__parent: 'root',
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -1408,6 +1415,7 @@ describe('LexicalEditor tests', () => {
__next: null,
__parent: paragraphKey,
__prev: null,
__state: {},
__style: '',
__text: 'Hello world',
__type: 'text',
Expand Down Expand Up @@ -2900,6 +2908,7 @@ describe('LexicalEditor tests', () => {
__next: null,
__parent: null,
__prev: null,
__state: {},
__style: '',
__text: 'yolo',
__type: 'text',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('LexicalEditorState tests', () => {
__parent: null,
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -78,6 +79,7 @@ describe('LexicalEditorState tests', () => {
__parent: 'root',
__prev: null,
__size: 1,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand All @@ -91,6 +93,7 @@ describe('LexicalEditorState tests', () => {
__next: null,
__parent: '1',
__prev: null,
__state: {},
__style: '',
__text: 'foo',
__type: 'text',
Expand Down Expand Up @@ -150,6 +153,7 @@ describe('LexicalEditorState tests', () => {
__parent: null,
__prev: null,
__size: 0,
__state: {},
__style: '',
__textFormat: 0,
__textStyle: '',
Expand Down
Loading
Loading