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 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
3f8bba0
BIG CHANGE - state as class
GermanJablo Feb 7, 2025
4624e2f
improvements
GermanJablo Feb 10, 2025
a62cf45
Refactor with optimizations and collab support
etrepum Feb 13, 2025
bdd3971
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 13, 2025
27910f2
Minimize the API and move convenience methods to @lexical/utils
etrepum Feb 13, 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
138 changes: 82 additions & 56 deletions packages/lexical-playground/src/nodes/PollNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

import type {JSX} from 'react';

import {makeStateWrapper} from '@lexical/utils';
import {
createState,
DecoratorNode,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
Expand Down Expand Up @@ -76,16 +77,44 @@ function $convertPollElement(domNode: HTMLElement): DOMConversionOutput | null {
return null;
}

export class PollNode extends DecoratorNode<JSX.Element> {
__question: string;
__options: Options;
function parseOptions(json: unknown): Options {
const options = [];
if (Array.isArray(json)) {
for (const row of json) {
if (
row &&
typeof row.text === 'string' &&
typeof row.uid === 'string' &&
Array.isArray(row.votes) &&
row.votes.every((v: unknown) => typeof v === 'number')
) {
options.push(row);
}
}
}
return options;
}

const questionState = makeStateWrapper(
createState('question', {
parse: (v) => (typeof v === 'string' ? v : ''),
}),
);
const optionsState = makeStateWrapper(
createState('options', {
isEqual: (a, b) =>
a.length === b.length && JSON.stringify(a) === JSON.stringify(b),
parse: parseOptions,
}),
);

export class PollNode extends DecoratorNode<JSX.Element> {
static getType(): string {
return 'poll';
}

static clone(node: PollNode): PollNode {
return new PollNode(node.__question, node.__options, node.__key);
return new PollNode(node.__key);
}

static importJSON(serializedNode: SerializedPollNode): PollNode {
Expand All @@ -95,59 +124,56 @@ export class PollNode extends DecoratorNode<JSX.Element> {
).updateFromJSON(serializedNode);
}

constructor(question: string, options: Options, key?: NodeKey) {
super(key);
this.__question = question;
this.__options = options;
}

exportJSON(): SerializedPollNode {
return {
...super.exportJSON(),
options: this.__options,
question: this.__question,
};
}
getQuestion = questionState.makeGetterMethod<this>();
setQuestion = questionState.makeSetterMethod<this>();
getOptions = optionsState.makeGetterMethod<this>();
setOptions = optionsState.makeSetterMethod<this>();

addOption(option: Option): void {
const self = this.getWritable();
const options = Array.from(self.__options);
options.push(option);
self.__options = options;
addOption(option: Option): this {
return this.setOptions((options) => [...options, option]);
}

deleteOption(option: Option): void {
const self = this.getWritable();
const options = Array.from(self.__options);
const index = options.indexOf(option);
options.splice(index, 1);
self.__options = options;
deleteOption(option: Option): this {
return this.setOptions((prevOptions) => {
const index = prevOptions.indexOf(option);
if (index === -1) {
return prevOptions;
}
const options = Array.from(prevOptions);
options.splice(index, 1);
return options;
});
}

setOptionText(option: Option, text: string): void {
const self = this.getWritable();
const clonedOption = cloneOption(option, text);
const options = Array.from(self.__options);
const index = options.indexOf(option);
options[index] = clonedOption;
self.__options = options;
setOptionText(option: Option, text: string): this {
return this.setOptions((prevOptions) => {
const clonedOption = cloneOption(option, text);
const options = Array.from(prevOptions);
const index = options.indexOf(option);
options[index] = clonedOption;
return options;
});
}

toggleVote(option: Option, clientID: number): void {
const self = this.getWritable();
const votes = option.votes;
const votesClone = Array.from(votes);
const voteIndex = votes.indexOf(clientID);
if (voteIndex === -1) {
votesClone.push(clientID);
} else {
votesClone.splice(voteIndex, 1);
}
const clonedOption = cloneOption(option, option.text, votesClone);
const options = Array.from(self.__options);
const index = options.indexOf(option);
options[index] = clonedOption;
self.__options = options;
toggleVote(option: Option, clientID: number): this {
return this.setOptions((prevOptions) => {
const index = prevOptions.indexOf(option);
if (index === -1) {
return prevOptions;
}
const votes = option.votes;
const votesClone = Array.from(votes);
const voteIndex = votes.indexOf(clientID);
if (voteIndex === -1) {
votesClone.push(clientID);
} else {
votesClone.splice(voteIndex, 1);
}
const clonedOption = cloneOption(option, option.text, votesClone);
const options = Array.from(prevOptions);
options[index] = clonedOption;
return options;
});
}

static importDOM(): DOMConversionMap | null {
Expand All @@ -166,10 +192,10 @@ export class PollNode extends DecoratorNode<JSX.Element> {

exportDOM(): DOMExportOutput {
const element = document.createElement('span');
element.setAttribute('data-lexical-poll-question', this.__question);
element.setAttribute('data-lexical-poll-question', this.getQuestion());
element.setAttribute(
'data-lexical-poll-options',
JSON.stringify(this.__options),
JSON.stringify(this.getOptions()),
);
return {element};
}
Expand All @@ -188,8 +214,8 @@ export class PollNode extends DecoratorNode<JSX.Element> {
return (
<Suspense fallback={null}>
<PollComponent
question={this.__question}
options={this.__options}
question={this.getQuestion()}
options={this.getOptions()}
nodeKey={this.__key}
/>
</Suspense>
Expand All @@ -198,7 +224,7 @@ export class PollNode extends DecoratorNode<JSX.Element> {
}

export function $createPollNode(question: string, options: Options): PollNode {
return new PollNode(question, options);
return new PollNode().setQuestion(question).setOptions(options);
}

export function $isPollNode(
Expand Down
86 changes: 86 additions & 0 deletions packages/lexical-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
$getRoot,
$getSelection,
$getSiblingCaret,
$getState,
$isChildCaret,
$isElementNode,
$isRangeSelection,
Expand All @@ -25,6 +26,7 @@ import {
$isTextNode,
$rewindSiblingCaret,
$setSelection,
$setState,
$splitNode,
type CaretDirection,
type EditorState,
Expand All @@ -37,6 +39,7 @@ import {
type NodeKey,
RootMode,
type SiblingCaret,
StateConfig,
} from 'lexical';
// This underscore postfixing is used as a hotfix so we do not
// export shared types from this module #5918
Expand Down Expand Up @@ -860,3 +863,86 @@ export function $getAdjacentSiblingOrParentSiblingCaret<
}
return nextCaret && [nextCaret, depthDiff];
}

/**
* A wrapper that creates bound functions and methods for the
* StateConfig to save some boilerplate when defining methods
* or exporting only the accessors from your modules rather
* than exposing the StateConfig directly.
*/
export interface StateConfigWrapper<K extends string, V> {
/** A reference to the stateConfig */
readonly stateConfig: StateConfig<K, V>;
/** `(node) => $getState(node, stateConfig)` */
readonly $get: <T extends LexicalNode>(node: T) => V;
/** `(node, valueOrUpdater) => $setState(node, stateConfig, valueOrUpdater)` */
readonly $set: <T extends LexicalNode>(
node: T,
valueOrUpdater: V | ((prevValue: V) => V),
) => T;
/** `[$get, $set]` */
readonly accessors: readonly [$get: this['$get'], $set: this['$set']];
/**
* `() => function () { return $get(this) }`
*
* Should be called with an explicit `this` type parameter.
*
* @example
* ```ts
* class MyNode {
* // …
* myGetter = myWrapper.makeGetterMethod<this>();
* }
* ```
*/
makeGetterMethod<T extends LexicalNode>(): (this: T) => V;
/**
* `() => function (valueOrUpdater) { return $set(this, valueOrUpdater) }`
*
* Must be called with an explicit `this` type parameter.
*
* @example
* ```ts
* class MyNode {
* // …
* mySetter = myWrapper.makeSetterMethod<this>();
* }
* ```
*/
makeSetterMethod<T extends LexicalNode>(): (
this: T,
valueOrUpdater: V | ((prevValue: V) => V),
) => T;
}

/**
* EXPERIMENTAL
*
* A convenience interface for working with {@link $getState} and
* {@link $setState}.
*
* @param stateConfig The stateConfig to wrap with convenience functionality
* @returns a StateWrapper
*/
export function makeStateWrapper<K extends string, V>(
stateConfig: StateConfig<K, V>,
): StateConfigWrapper<K, V> {
const $get: StateConfigWrapper<K, V>['$get'] = (node) =>
$getState(node, stateConfig);
const $set: StateConfigWrapper<K, V>['$set'] = (node, valueOrUpdater) =>
$setState(node, stateConfig, valueOrUpdater);
return {
$get,
$set,
accessors: [$get, $set],
makeGetterMethod: () =>
function $getter() {
return $get(this);
},
makeSetterMethod: () =>
function $setter(valueOrUpdater) {
return $set(this, valueOrUpdater);
},
stateConfig,
};
}
53 changes: 52 additions & 1 deletion packages/lexical-website/docs/concepts/node-replacement.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,63 @@
# 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.

## Node State (Experimental)

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.

```ts
// IMPLEMENTATION
const colorState = createState('color', {
parse: (value: unknown) => (typeof value === 'string' ? value : undefined),
});

// USAGE
const textNode = $createTextNode();
$setState(textNode, colorState, 'blue');
const textColor = $getState(textNode, colorState) // -> "blue"
```

Inside state, you can use any serializable json value. For advanced use cases
with values that are not primitive values like string, number, boolean, null
you may want or need to implement more than just the parse method in the
value configuration.

While this is still experimental, the API is subject to change and the
documentation will primarily be API documentation.

### Important

We recommend that you use prefixes with low collision probability when defining
state that will be applied to node classes that you don't fully control. It is
a runtime error in dev mode when two distinct separate StateConfig with the
same key are used on the same node.

For example, if you are making a plugin called `awesome-lexical`, you could do:

```ts
const color = createState('awesome-lexical-color', /** your parse fn */)
const bgColor = createState('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 = createState('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
Loading