Skip to content

[lexical] Feature: add a generic state property to all nodes #7117

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

Merged
merged 45 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
9499667
Rename valueToJSON to unparse, use longer stateConfig naming for vari…
etrepum Feb 14, 2025
cb2369b
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 14, 2025
62eac57
implement $getStateChange and the version argument to $getState
etrepum Feb 14, 2025
4f2005f
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 14, 2025
62d4eaa
Accommodate TextNode merging
etrepum Feb 15, 2025
26140ad
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 15, 2025
48ba0f3
Parameterize the state key in JSON and choose "$" instead of "state"
etrepum Feb 18, 2025
7d737ec
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 18, 2025
f82807e
add **/build** to tsconfig exclude
etrepum Feb 18, 2025
858c115
upgrade astro to fix mysterious integration test failure
etrepum Feb 18, 2025
f37ab4c
remove --no-package-lock
etrepum Feb 18, 2025
6d43a56
disable cache for test-integration?
etrepum Feb 18, 2025
0529925
try using more recent node for integration
etrepum Feb 18, 2025
ffe2bed
remove astro playwright tests for now
etrepum Feb 18, 2025
44773aa
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 20, 2025
ba04882
Merge remote-tracking branch 'origin/main' into state
etrepum Feb 23, 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: '^_'},
],
'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
3 changes: 1 addition & 2 deletions .github/workflows/call-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.15.1]
node-version: [22.14.0]
env:
CI: true
steps:
Expand All @@ -17,7 +17,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- run: npm run test-integration
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 @@ -75,16 +76,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,
}),
);
Comment on lines +97 to +108
Copy link
Collaborator

Choose a reason for hiding this comment

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

The state wrapper is only used to generate the accessors on the class, mostly to save a bit of code and make it easier to get the types right. I'd be fine removing the state wrapper utility if we want to scope it down, but it's already not in the core.


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 @@ -94,59 +123,56 @@ export class PollNode extends DecoratorNode<JSX.Element> {
).updateFromJSON(serializedNode);
}

constructor(question: string, options: Options, key?: NodeKey) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Switching to a zero required arg constructor is more compatible with our longer term goals and makes the yjs stuff more sound since it's going to call the constructor with zero args anyway

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>();
Comment on lines +126 to +129
Copy link
Collaborator

Choose a reason for hiding this comment

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

These are just conveniences, could be written out explicitly


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 @@ -165,10 +191,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 @@ -186,16 +212,16 @@ export class PollNode extends DecoratorNode<JSX.Element> {
decorate(): JSX.Element {
return (
<PollComponent
question={this.__question}
options={this.__options}
question={this.getQuestion()}
options={this.getOptions()}
nodeKey={this.__key}
/>
);
}
}

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
87 changes: 87 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,8 @@ import {
type NodeKey,
RootMode,
type SiblingCaret,
StateConfig,
ValueOrUpdater,
} 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 +864,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: ValueOrUpdater<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: ValueOrUpdater<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,
};
}
Loading
Loading