Skip to content

Commit 4a9a8e7

Browse files
committed
[lexical] Refactor: Port node classes to the $config() protocol
## Description Node classes have historically declared boilerplate static methods (`getType`, `clone`, `importJSON`, `importDOM`) that `getStaticNodeConfig` already synthesizes at runtime from a single `$config()` instance method. This ports the remaining node classes to `$config()` and removes the now-redundant statics, 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. Core changes: - Abstract base classes have no concrete node `type`, so they can now declare configuration shared with their subclasses (e.g. a `$transform` or required state) under a well-known `Symbol.for(<ClassName>)` key, which `getStaticNodeConfig` resolves via `Object.getOwnPropertySymbols`. `BaseStaticNodeConfig` is a single mapped type over `string | symbol`, and `StaticNodeConfigValue`'s `Type` parameter is widened to `string | symbol` (a symbol-keyed config never populates the string `type` field). - `TabNode` adds no public surface over `TextNode`, so dropping its statics made the two structurally identical and collapsed `$isTabNode()` narrowing to `never`. It is kept nominally distinct by refining its always-false `canInsertTextBefore()`/`canInsertTextAfter()` to the `false` literal type — a truthful refinement rather than a phantom brand, and without re-introducing `declare ['constructor']`. - 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. Tests: - Added coverage for the abstract-class Symbol `$config` mechanism. - 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. Closes # ## Test plan ### Before Every node class carried boilerplate `static getType`/`clone`/`importJSON`/ `importDOM` declarations. ### After `pnpm run tsc`, `pnpm run flow`, ESLint, and Prettier all pass. The full unit suite is green: 2866 passed / 1 skipped with `--project unit`, and 860 passed with `--project scripts-unit`.
1 parent ac3814e commit 4a9a8e7

45 files changed

Lines changed: 550 additions & 697 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)