Skip to content

Commit e2b9338

Browse files
committed
[lexical] Refactor: Port node classes to the $config() protocol
## Description Building on the additive $config() protocol changes in the previous commit, this ports the node classes to `$config()` and removes the now-redundant boilerplate static methods (`getType`, `clone`, `importJSON`, `importDOM`) that `getStaticNodeConfig` already synthesizes at runtime, moving each node's `importDOM` map into the `$config` `importDOM` option while leaving `updateFromJSON`/`exportJSON`/`createDOM`/`updateDOM` in place. (It intentionally does not adopt the separate JSON-schema serialization work — serialization methods are unchanged.) Ported across `lexical` (TextNode, TabNode, LineBreakNode, ParagraphNode, RootNode, ArtificialNode), `@lexical/rich-text` (Heading, Quote), `@lexical/link` (Link, AutoLink), `@lexical/mark`, `@lexical/hashtag`, `@lexical/table` (Table, Row, Cell), `@lexical/code` (CodeNode, CodeHighlightNode), `@lexical/react` and `@lexical/extension` HorizontalRule, the playground nodes, and the examples. Decorator nodes whose constructors take required arguments keep a minimal `clone`/`importJSON` (they must construct with arguments). The base `LexicalNode` and the test fixtures that exist to verify the legacy static-method path are left as-is. - Now that a node records its own `type` via the `$config` accessor introduced in the previous commit, the structurally-identical `TabNode` stays nominally distinct from `TextNode` without the statics it previously relied on, so guards like `$isTabNode()` keep narrowing correctly. - Nodes relying on the synthesized `clone` need a zero-argument constructor, so key-only/leading parameters were given explicit defaults (or redundant pure-delegation constructors were removed) so `Node.length === 0`. - Fixed code that relied on `TextNode` being assignable to `TabNode`: `$getFirstCodeNodeOfLine` is now generic over its anchor type. - `__type` is now `readonly` (it is only ever assigned once, in the constructor); `@lexical/yjs`'s generic property writer casts through a mutable record accordingly (it never actually writes `__type`, which is intrinsic and filtered out by its equal-value guard). Tests: - Clone tests use the public `$cloneWithProperties` helper instead of manually pairing `clone()` with `afterCloneFrom()`. - DOM-conversion tests use a new shared `$runDOMConversion` util that drives the editor's registered conversion cache (the paste path) rather than calling a node's static `importDOM()` directly. ## Test plan `pnpm run tsc`, `pnpm run flow`, ESLint, and Prettier all pass. The full unit suite is green: 3728 passed / 1 skipped across `--project unit` and `--project scripts-unit`.
1 parent 8367948 commit e2b9338

44 files changed

Lines changed: 431 additions & 690 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/node-replacement/src/nodes/CustomParagraphNode.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,11 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
*/
8-
import {
9-
$applyNodeReplacement,
10-
EditorConfig,
11-
ParagraphNode,
12-
SerializedParagraphNode,
13-
} from 'lexical';
8+
import {$applyNodeReplacement, EditorConfig, ParagraphNode} from 'lexical';
149

1510
export class CustomParagraphNode extends ParagraphNode {
16-
static getType() {
17-
return 'custom-paragraph';
18-
}
19-
static clone(node: CustomParagraphNode): CustomParagraphNode {
20-
return new CustomParagraphNode(node.__key);
21-
}
22-
static importJSON(json: SerializedParagraphNode): CustomParagraphNode {
23-
return $createCustomParagraphNode().updateFromJSON(json);
11+
$config() {
12+
return this.config('custom-paragraph', {extends: ParagraphNode});
2413
}
2514
createDOM(config: EditorConfig) {
2615
const el = super.createDOM(config);

examples/vanilla-js-plugin/src/emoji-plugin/EmojiNode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ const BASE_EMOJI_URI = new URL(`@emoji-datasource-facebook/`, import.meta.url)
2424
export class EmojiNode extends TextNode {
2525
__unifiedID: string;
2626

27-
static getType(): string {
28-
return 'emoji';
27+
$config() {
28+
return this.config('emoji', {extends: TextNode});
2929
}
3030

3131
static clone(node: EmojiNode): EmojiNode {

packages/lexical-code-core/src/CodeHighlightNode.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,8 @@ export class CodeHighlightNode extends TextNode {
4747
this.__highlightType = highlightType;
4848
}
4949

50-
static getType(): string {
51-
return 'code-highlight';
52-
}
53-
54-
static clone(node: CodeHighlightNode): CodeHighlightNode {
55-
return new CodeHighlightNode(
56-
node.__text,
57-
node.__highlightType || undefined,
58-
node.__key,
59-
);
50+
$config() {
51+
return this.config('code-highlight', {extends: TextNode});
6052
}
6153

6254
afterCloneFrom(prevNode: this): void {
@@ -110,12 +102,6 @@ export class CodeHighlightNode extends TextNode {
110102
return update;
111103
}
112104

113-
static importJSON(
114-
serializedNode: SerializedCodeHighlightNode,
115-
): CodeHighlightNode {
116-
return $createCodeHighlightNode().updateFromJSON(serializedNode);
117-
}
118-
119105
updateFromJSON(
120106
serializedNode: LexicalUpdateJSON<SerializedCodeHighlightNode>,
121107
): this {

packages/lexical-code-core/src/CodeNode.ts

Lines changed: 71 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type {CodeExtension} from './CodeExtension';
1010
import type {
11-
DOMConversionMap,
1211
DOMConversionOutput,
1312
DOMExportOutput,
1413
EditorConfig,
@@ -88,15 +87,80 @@ export class CodeNode extends ElementNode {
8887
/** @internal */
8988
__isSyntaxHighlightSupported: boolean;
9089

91-
static getType(): string {
92-
return 'code';
93-
}
90+
$config() {
91+
return this.config('code', {
92+
extends: ElementNode,
93+
importDOM: {
94+
// Typically <pre> is used for code blocks, and <code> for inline code styles
95+
// but if it's a multi line <code> we'll create a block. Pass through to
96+
// inline format handled by TextNode otherwise.
97+
code: (node: Node) => {
98+
const isMultiLine =
99+
node.textContent != null &&
100+
(/\r?\n/.test(node.textContent) || hasChildDOMNodeTag(node, 'BR'));
101+
102+
return isMultiLine
103+
? {
104+
conversion: $convertPreElement,
105+
priority: 1,
106+
}
107+
: null;
108+
},
109+
div: () => ({
110+
conversion: $convertDivElement,
111+
priority: 1,
112+
}),
113+
pre: () => ({
114+
conversion: $convertPreElement,
115+
priority: 0,
116+
}),
117+
table: (node: Node) => {
118+
const table = node;
119+
// domNode is a <table> since we matched it by nodeName
120+
if (isGitHubCodeTable(table as HTMLTableElement)) {
121+
return {
122+
conversion: $convertTableElement,
123+
priority: 3,
124+
};
125+
}
126+
return null;
127+
},
128+
td: (node: Node) => {
129+
// element is a <td> since we matched it by nodeName
130+
const td = node as HTMLTableCellElement;
131+
const table: HTMLTableElement | null = td.closest('table');
132+
133+
if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) {
134+
// Return a no-op if it's a table cell in a code table, but not a code line.
135+
// Otherwise it'll fall back to the T
136+
return {
137+
conversion: convertCodeNoop,
138+
priority: 3,
139+
};
140+
}
94141

95-
static clone(node: CodeNode): CodeNode {
96-
return new CodeNode(node.__language, node.__key);
142+
return null;
143+
},
144+
tr: (node: Node) => {
145+
// element is a <tr> since we matched it by nodeName
146+
const tr = node as HTMLTableCellElement;
147+
const table: HTMLTableElement | null = tr.closest('table');
148+
if (table && isGitHubCodeTable(table)) {
149+
return {
150+
conversion: convertCodeNoop,
151+
priority: 3,
152+
};
153+
}
154+
return null;
155+
},
156+
},
157+
});
97158
}
98159

99-
constructor(language?: string | null | undefined, key?: NodeKey) {
160+
// `language` carries an explicit `undefined` default so the constructor
161+
// reports zero required arguments and `$config` can synthesize the static
162+
// `clone` from the no-argument constructor.
163+
constructor(language: string | null | undefined = undefined, key?: NodeKey) {
100164
super(key);
101165
this.__language = language || undefined;
102166
this.__isSyntaxHighlightSupported = false;
@@ -207,77 +271,6 @@ export class CodeNode extends ElementNode {
207271
return {element};
208272
}
209273

210-
static importDOM(): DOMConversionMap | null {
211-
return {
212-
// Typically <pre> is used for code blocks, and <code> for inline code styles
213-
// but if it's a multi line <code> we'll create a block. Pass through to
214-
// inline format handled by TextNode otherwise.
215-
code: (node: Node) => {
216-
const isMultiLine =
217-
node.textContent != null &&
218-
(/\r?\n/.test(node.textContent) || hasChildDOMNodeTag(node, 'BR'));
219-
220-
return isMultiLine
221-
? {
222-
conversion: $convertPreElement,
223-
priority: 1,
224-
}
225-
: null;
226-
},
227-
div: () => ({
228-
conversion: $convertDivElement,
229-
priority: 1,
230-
}),
231-
pre: () => ({
232-
conversion: $convertPreElement,
233-
priority: 0,
234-
}),
235-
table: (node: Node) => {
236-
const table = node;
237-
// domNode is a <table> since we matched it by nodeName
238-
if (isGitHubCodeTable(table as HTMLTableElement)) {
239-
return {
240-
conversion: $convertTableElement,
241-
priority: 3,
242-
};
243-
}
244-
return null;
245-
},
246-
td: (node: Node) => {
247-
// element is a <td> since we matched it by nodeName
248-
const td = node as HTMLTableCellElement;
249-
const table: HTMLTableElement | null = td.closest('table');
250-
251-
if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) {
252-
// Return a no-op if it's a table cell in a code table, but not a code line.
253-
// Otherwise it'll fall back to the T
254-
return {
255-
conversion: convertCodeNoop,
256-
priority: 3,
257-
};
258-
}
259-
260-
return null;
261-
},
262-
tr: (node: Node) => {
263-
// element is a <tr> since we matched it by nodeName
264-
const tr = node as HTMLTableCellElement;
265-
const table: HTMLTableElement | null = tr.closest('table');
266-
if (table && isGitHubCodeTable(table)) {
267-
return {
268-
conversion: convertCodeNoop,
269-
priority: 3,
270-
};
271-
}
272-
return null;
273-
},
274-
};
275-
}
276-
277-
static importJSON(serializedNode: SerializedCodeNode): CodeNode {
278-
return $createCodeNode().updateFromJSON(serializedNode);
279-
}
280-
281274
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedCodeNode>): this {
282275
return super
283276
.updateFromJSON(serializedNode)

packages/lexical-code-core/src/FlatStructureUtils.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
RangeSelection,
1515
SiblingCaret,
1616
TabNode,
17+
TextNode,
1718
} from 'lexical';
1819

1920
import invariant from '@lexical/internal/invariant';
@@ -33,11 +34,15 @@ import {
3334
$isCodeHighlightNode,
3435
} from './CodeHighlightNode';
3536

36-
function $getLastMatchingCodeNode<D extends CaretDirection>(
37-
anchor: CodeHighlightNode | TabNode | LineBreakNode,
38-
direction: D,
39-
): CodeHighlightNode | TabNode | LineBreakNode {
40-
let matchingNode: CodeHighlightNode | TabNode | LineBreakNode = anchor;
37+
// The anchor is generic (rather than the narrower
38+
// `CodeHighlightNode | TabNode | LineBreakNode`) because callers may have only
39+
// narrowed as far as TextNode; the matched siblings are always
40+
// CodeHighlightNode/TabNode, and an unmatched anchor is returned unchanged.
41+
function $getLastMatchingCodeNode<
42+
T extends TextNode | LineBreakNode,
43+
D extends CaretDirection,
44+
>(anchor: T, direction: D): T | CodeHighlightNode | TabNode {
45+
let matchingNode: T | CodeHighlightNode | TabNode = anchor;
4146
for (
4247
let caret: null | SiblingCaret<LexicalNode, D> = $getSiblingCaret(
4348
anchor,
@@ -51,15 +56,15 @@ function $getLastMatchingCodeNode<D extends CaretDirection>(
5156
return matchingNode;
5257
}
5358

54-
export function $getFirstCodeNodeOfLine(
55-
anchor: CodeHighlightNode | TabNode | LineBreakNode,
56-
): CodeHighlightNode | TabNode | LineBreakNode {
59+
export function $getFirstCodeNodeOfLine<T extends TextNode | LineBreakNode>(
60+
anchor: T,
61+
): T | CodeHighlightNode | TabNode {
5762
return $getLastMatchingCodeNode(anchor, 'previous');
5863
}
5964

60-
export function $getLastCodeNodeOfLine(
61-
anchor: CodeHighlightNode | TabNode | LineBreakNode,
62-
): CodeHighlightNode | TabNode | LineBreakNode {
65+
export function $getLastCodeNodeOfLine<T extends TextNode | LineBreakNode>(
66+
anchor: T,
67+
): T | CodeHighlightNode | TabNode {
6368
return $getLastMatchingCodeNode(anchor, 'next');
6469
}
6570

packages/lexical-extension/src/HorizontalRuleExtension.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
*/
88

99
import type {
10-
DOMConversionMap,
1110
DOMConversionOutput,
1211
DOMExportOutput,
1312
EditorConfig,
@@ -49,27 +48,20 @@ export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand<void> =
4948
createCommand('INSERT_HORIZONTAL_RULE_COMMAND');
5049

5150
export class HorizontalRuleNode extends DecoratorNode<unknown> {
52-
static getType(): string {
53-
return 'horizontalrule';
54-
}
55-
56-
static clone(node: HorizontalRuleNode): HorizontalRuleNode {
57-
return new HorizontalRuleNode(node.__key);
58-
}
59-
60-
static importJSON(
61-
serializedNode: SerializedHorizontalRuleNode,
62-
): HorizontalRuleNode {
63-
return $createHorizontalRuleNode().updateFromJSON(serializedNode);
64-
}
65-
66-
static importDOM(): DOMConversionMap | null {
67-
return {
68-
hr: () => ({
69-
conversion: $convertHorizontalRuleElement,
70-
priority: 0,
71-
}),
72-
};
51+
$config() {
52+
// `extends` is intentionally left to the runtime default (the prototype
53+
// parent) rather than declared explicitly: the deprecated
54+
// `@lexical/react` HorizontalRuleNode subclasses this one and reuses the
55+
// same 'horizontalrule' type, so both `$config()` overrides must infer a
56+
// matching shape.
57+
return this.config('horizontalrule', {
58+
importDOM: {
59+
hr: () => ({
60+
conversion: $convertHorizontalRuleElement,
61+
priority: 0,
62+
}),
63+
},
64+
});
7365
}
7466

7567
exportDOM(): DOMExportOutput {

packages/lexical-hashtag/src/LexicalHashtagNode.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,15 @@
66
*
77
*/
88

9-
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
9+
import type {EditorConfig, LexicalNode} from 'lexical';
1010

1111
import {addClassNamesToElement} from '@lexical/utils';
1212
import {$applyNodeReplacement, TextNode} from 'lexical';
1313

1414
/** @noInheritDoc */
1515
export class HashtagNode extends TextNode {
16-
static getType(): string {
17-
return 'hashtag';
18-
}
19-
20-
static clone(node: HashtagNode): HashtagNode {
21-
return new HashtagNode(node.__text, node.__key);
16+
$config() {
17+
return this.config('hashtag', {extends: TextNode});
2218
}
2319

2420
createDOM(config: EditorConfig): HTMLElement {
@@ -27,10 +23,6 @@ export class HashtagNode extends TextNode {
2723
return element;
2824
}
2925

30-
static importJSON(serializedNode: SerializedTextNode): HashtagNode {
31-
return $createHashtagNode().updateFromJSON(serializedNode);
32-
}
33-
3426
canInsertTextBefore(): boolean {
3527
return false;
3628
}

0 commit comments

Comments
 (0)