From ff95ec0b98ae855bc1c5b282c14026cba4e5d8dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 01:22:44 +0000 Subject: [PATCH] [lexical] Refactor: Port node classes to the $config() protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building on the $config() protocol (added in #8645, now on main), 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. (List, overflow, and the newer playground nodes — Card/PullQuote/Review/SlotContainer — already adopt `$config` on main and are unchanged here.) - Now that a node records its own `type` via the `$config` accessor (#8645), 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). - `@lexical/yjs` derives a node's syncable properties from `Object.keys(new Klass())`, so any property the removed constructor used to initialize must stay materialized. `TableNode.__colWidths` was the only one that regressed; it now has an unconditional field initializer so table column widths sync over collaboration. - `examples/react-rich` reads `TextNode.getType()` before `TextNode.importDOM()` in its import-map builder, since a `$config` node's static methods are generated on first static access (the example runs before any editor, and therefore registration, exists). - The Flow `.js.flow` shadow types are brought back in sync: each ported node drops the `getType`/`clone`/`importDOM` declarations that are now inherited from the base `LexicalNode` (which gains the previously-missing `static importJSON` inheritable signature), keeps its precise `static importJSON` where it already declared one, and relaxes the constructor parameters that gained defaults. OverflowNode's shadow (already on `$config` on main) is cleaned up the same way. 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. `pnpm run tsc`, `pnpm run flow`, `build-types` (declaration emit), ESLint, and Prettier all pass. The full unit suite is green (4190 passed / 1 skipped across `--project unit` and `--project scripts-unit`), and the rich-text-with-collab Tables e2e suite passes (71 passed / 16 skipped), confirming the `__colWidths` collab fix. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VENzGdQRLnZdmDSDxGk3yg --- .../src/nodes/CustomParagraphNode.ts | 17 +- examples/react-rich/src/App.tsx | 12 +- .../src/emoji-plugin/EmojiNode.ts | 4 +- .../flow/LexicalCodeCore.js.flow | 8 +- .../src/CodeHighlightNode.ts | 18 +-- packages/lexical-code-core/src/CodeNode.ts | 149 +++++++++--------- .../src/FlatStructureUtils.ts | 27 ++-- .../lexical-code/flow/LexicalCode.js.flow | 8 +- .../flow/LexicalExtension.js.flow | 2 - .../src/HorizontalRuleExtension.ts | 36 ++--- .../flow/LexicalHashtag.js.flow | 4 +- .../lexical-hashtag/src/LexicalHashtagNode.ts | 14 +- .../lexical-link/flow/LexicalLink.js.flow | 8 +- packages/lexical-link/src/LexicalLinkNode.ts | 60 ++----- .../unit/LexicalAutoLinkNode.test.ts | 3 +- .../__tests__/unit/LexicalLinkNode.test.ts | 3 +- .../lexical-mark/flow/LexicalMark.js.flow | 4 +- packages/lexical-mark/src/MarkNode.ts | 16 +- .../flow/LexicalOverflow.js.flow | 2 - .../src/nodes/EmojiNode.tsx | 4 +- .../src/nodes/EquationNode.tsx | 8 +- .../src/nodes/ExcalidrawNode/index.tsx | 13 +- .../src/nodes/FigmaNode.tsx | 4 +- .../src/nodes/ImageNode.tsx | 4 +- .../src/nodes/KeywordNode.ts | 12 +- .../src/nodes/LayoutContainerNode.ts | 4 +- .../src/nodes/LayoutItemNode.ts | 12 +- .../src/nodes/MentionNode.ts | 4 +- .../src/nodes/PageBreakNode/index.tsx | 12 +- .../src/nodes/SpecialTextNode.tsx | 14 +- .../src/nodes/StickyNode.tsx | 4 +- .../src/nodes/TweetNode.tsx | 4 +- .../src/nodes/YouTubeNode.tsx | 4 +- .../CollapsibleContainerNode.ts | 4 +- .../flow/LexicalHorizontalRuleNode.js.flow | 2 - .../src/LexicalHorizontalRuleNode.tsx | 36 ++--- .../flow/LexicalRichText.js.flow | 7 +- packages/lexical-rich-text/src/index.ts | 133 ++++++---------- .../lexical-table/flow/LexicalTable.js.flow | 6 - .../lexical-table/src/LexicalTableCellNode.ts | 43 ++--- .../lexical-table/src/LexicalTableNode.ts | 52 +++--- .../lexical-table/src/LexicalTableRowNode.ts | 34 ++-- .../unit/LexicalTableCellNode.test.ts | 46 +++--- packages/lexical-yjs/src/Utils.ts | 9 +- packages/lexical/flow/Lexical.js.flow | 15 +- packages/lexical/src/LexicalNode.ts | 4 +- .../lexical/src/__tests__/utils/index.tsx | 35 ++++ packages/lexical/src/nodes/ArtificialNode.ts | 4 +- .../lexical/src/nodes/LexicalLineBreakNode.ts | 50 ++---- .../lexical/src/nodes/LexicalParagraphNode.ts | 30 ++-- packages/lexical/src/nodes/LexicalRootNode.ts | 8 +- packages/lexical/src/nodes/LexicalTabNode.ts | 23 +-- packages/lexical/src/nodes/LexicalTextNode.ts | 117 +++++++------- .../unit/LexicalParagraphNode.test.ts | 31 ++-- .../__tests__/unit/LexicalRootNode.test.ts | 3 +- 55 files changed, 457 insertions(+), 733 deletions(-) diff --git a/examples/node-replacement/src/nodes/CustomParagraphNode.ts b/examples/node-replacement/src/nodes/CustomParagraphNode.ts index a044f59d4db..e5491e6ce0b 100644 --- a/examples/node-replacement/src/nodes/CustomParagraphNode.ts +++ b/examples/node-replacement/src/nodes/CustomParagraphNode.ts @@ -5,22 +5,11 @@ * LICENSE file in the root directory of this source tree. * */ -import { - $applyNodeReplacement, - EditorConfig, - ParagraphNode, - SerializedParagraphNode, -} from 'lexical'; +import {$applyNodeReplacement, EditorConfig, ParagraphNode} from 'lexical'; export class CustomParagraphNode extends ParagraphNode { - static getType() { - return 'custom-paragraph'; - } - static clone(node: CustomParagraphNode): CustomParagraphNode { - return new CustomParagraphNode(node.__key); - } - static importJSON(json: SerializedParagraphNode): CustomParagraphNode { - return $createCustomParagraphNode().updateFromJSON(json); + $config() { + return this.config('custom-paragraph', {extends: ParagraphNode}); } createDOM(config: EditorConfig) { const el = super.createDOM(config); diff --git a/examples/react-rich/src/App.tsx b/examples/react-rich/src/App.tsx index 9246e47df09..32d400f0144 100644 --- a/examples/react-rich/src/App.tsx +++ b/examples/react-rich/src/App.tsx @@ -82,9 +82,19 @@ const getExtraStyles = (element: HTMLElement): string => { const constructImportMap = (): DOMConversionMap => { const importMap: DOMConversionMap = {}; + // With the $config() protocol a node's static methods (including importDOM) + // are generated on first static access rather than declared up front, so read + // getType() to ensure TextNode.importDOM is populated before we wrap its + // importers here (this runs before any editor, and therefore registration, + // exists). + TextNode.getType(); + const importDOMFn = TextNode.importDOM; + // Wrap all TextNode importers with a function that also imports // the custom styles implemented by the playground - for (const [tag, fn] of Object.entries(TextNode.importDOM() || {})) { + for (const [tag, fn] of Object.entries( + importDOMFn ? importDOMFn() || {} : {}, + )) { importMap[tag] = importNode => { const importer = fn(importNode); if (!importer) { diff --git a/examples/vanilla-js-plugin/src/emoji-plugin/EmojiNode.ts b/examples/vanilla-js-plugin/src/emoji-plugin/EmojiNode.ts index 23e50185b63..35eac1224bd 100644 --- a/examples/vanilla-js-plugin/src/emoji-plugin/EmojiNode.ts +++ b/examples/vanilla-js-plugin/src/emoji-plugin/EmojiNode.ts @@ -24,8 +24,8 @@ const BASE_EMOJI_URI = new URL(`@emoji-datasource-facebook/`, import.meta.url) export class EmojiNode extends TextNode { __unifiedID: string; - static getType(): string { - return 'emoji'; + $config() { + return this.config('emoji', {extends: TextNode}); } static clone(node: EmojiNode): EmojiNode { diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow index fa496dd983a..fdea6de727d 100644 --- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow +++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow @@ -54,9 +54,7 @@ declare export function $isCodeHighlightNode( declare export class CodeHighlightNode extends TextNode { __highlightType: ?string; - constructor(text: string, highlightType?: string, key?: NodeKey): void; - static getType(): string; - static clone(node: CodeHighlightNode): CodeHighlightNode; + constructor(text?: string, highlightType?: string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; setFormat(format: number): this; } @@ -99,9 +97,7 @@ declare export function $isCodeNode( declare export class CodeNode extends ElementNode { __language: string | null | void; - static getType(): string; - static clone(node: CodeNode): CodeNode; - constructor(language: ?string, key?: NodeKey): void; + constructor(language?: ?string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter( selection: RangeSelection, diff --git a/packages/lexical-code-core/src/CodeHighlightNode.ts b/packages/lexical-code-core/src/CodeHighlightNode.ts index 47b197fc035..f68bef5744c 100644 --- a/packages/lexical-code-core/src/CodeHighlightNode.ts +++ b/packages/lexical-code-core/src/CodeHighlightNode.ts @@ -47,16 +47,8 @@ export class CodeHighlightNode extends TextNode { this.__highlightType = highlightType; } - static getType(): string { - return 'code-highlight'; - } - - static clone(node: CodeHighlightNode): CodeHighlightNode { - return new CodeHighlightNode( - node.__text, - node.__highlightType || undefined, - node.__key, - ); + $config() { + return this.config('code-highlight', {extends: TextNode}); } afterCloneFrom(prevNode: this): void { @@ -110,12 +102,6 @@ export class CodeHighlightNode extends TextNode { return update; } - static importJSON( - serializedNode: SerializedCodeHighlightNode, - ): CodeHighlightNode { - return $createCodeHighlightNode().updateFromJSON(serializedNode); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 7f8eb4cd925..8a7a5cd3098 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -8,7 +8,6 @@ import type {CodeExtension} from './CodeExtension'; import type { - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -88,15 +87,80 @@ export class CodeNode extends ElementNode { /** @internal */ __isSyntaxHighlightSupported: boolean; - static getType(): string { - return 'code'; - } + $config() { + return this.config('code', { + extends: ElementNode, + importDOM: { + // Typically
 is used for code blocks, and  for inline code styles
+        // but if it's a multi line  we'll create a block. Pass through to
+        // inline format handled by TextNode otherwise.
+        code: (node: Node) => {
+          const isMultiLine =
+            node.textContent != null &&
+            (/\r?\n/.test(node.textContent) || hasChildDOMNodeTag(node, 'BR'));
+
+          return isMultiLine
+            ? {
+                conversion: $convertPreElement,
+                priority: 1,
+              }
+            : null;
+        },
+        div: () => ({
+          conversion: $convertDivElement,
+          priority: 1,
+        }),
+        pre: () => ({
+          conversion: $convertPreElement,
+          priority: 0,
+        }),
+        table: (node: Node) => {
+          const table = node;
+          // domNode is a  since we matched it by nodeName
+          if (isGitHubCodeTable(table as HTMLTableElement)) {
+            return {
+              conversion: $convertTableElement,
+              priority: 3,
+            };
+          }
+          return null;
+        },
+        td: (node: Node) => {
+          // element is a  since we matched it by nodeName
+          const tr = node as HTMLTableCellElement;
+          const table: HTMLTableElement | null = tr.closest('table');
+          if (table && isGitHubCodeTable(table)) {
+            return {
+              conversion: convertCodeNoop,
+              priority: 3,
+            };
+          }
+          return null;
+        },
+      },
+    });
   }
 
-  constructor(language?: string | null | undefined, key?: NodeKey) {
+  // `language` carries an explicit `undefined` default so the constructor
+  // reports zero required arguments and `$config` can synthesize the static
+  // `clone` from the no-argument constructor.
+  constructor(language: string | null | undefined = undefined, key?: NodeKey) {
     super(key);
     this.__language = language || undefined;
     this.__isSyntaxHighlightSupported = false;
@@ -207,77 +271,6 @@ export class CodeNode extends ElementNode {
     return {element};
   }
 
-  static importDOM(): DOMConversionMap | null {
-    return {
-      // Typically 
 is used for code blocks, and  for inline code styles
-      // but if it's a multi line  we'll create a block. Pass through to
-      // inline format handled by TextNode otherwise.
-      code: (node: Node) => {
-        const isMultiLine =
-          node.textContent != null &&
-          (/\r?\n/.test(node.textContent) || hasChildDOMNodeTag(node, 'BR'));
-
-        return isMultiLine
-          ? {
-              conversion: $convertPreElement,
-              priority: 1,
-            }
-          : null;
-      },
-      div: () => ({
-        conversion: $convertDivElement,
-        priority: 1,
-      }),
-      pre: () => ({
-        conversion: $convertPreElement,
-        priority: 0,
-      }),
-      table: (node: Node) => {
-        const table = node;
-        // domNode is a 
since we matched it by nodeName + const td = node as HTMLTableCellElement; + const table: HTMLTableElement | null = td.closest('table'); + + if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) { + // Return a no-op if it's a table cell in a code table, but not a code line. + // Otherwise it'll fall back to the T + return { + conversion: convertCodeNoop, + priority: 3, + }; + } - static clone(node: CodeNode): CodeNode { - return new CodeNode(node.__language, node.__key); + return null; + }, + tr: (node: Node) => { + // element is a
since we matched it by nodeName - if (isGitHubCodeTable(table as HTMLTableElement)) { - return { - conversion: $convertTableElement, - priority: 3, - }; - } - return null; - }, - td: (node: Node) => { - // element is a since we matched it by nodeName - const tr = node as HTMLTableCellElement; - const table: HTMLTableElement | null = tr.closest('table'); - if (table && isGitHubCodeTable(table)) { - return { - conversion: convertCodeNoop, - priority: 3, - }; - } - return null; - }, - }; - } - - static importJSON(serializedNode: SerializedCodeNode): CodeNode { - return $createCodeNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super .updateFromJSON(serializedNode) diff --git a/packages/lexical-code-core/src/FlatStructureUtils.ts b/packages/lexical-code-core/src/FlatStructureUtils.ts index 15459423a46..de86e030139 100644 --- a/packages/lexical-code-core/src/FlatStructureUtils.ts +++ b/packages/lexical-code-core/src/FlatStructureUtils.ts @@ -14,6 +14,7 @@ import type { RangeSelection, SiblingCaret, TabNode, + TextNode, } from 'lexical'; import invariant from '@lexical/internal/invariant'; @@ -33,11 +34,15 @@ import { $isCodeHighlightNode, } from './CodeHighlightNode'; -function $getLastMatchingCodeNode( - anchor: CodeHighlightNode | TabNode | LineBreakNode, - direction: D, -): CodeHighlightNode | TabNode | LineBreakNode { - let matchingNode: CodeHighlightNode | TabNode | LineBreakNode = anchor; +// The anchor is generic (rather than the narrower +// `CodeHighlightNode | TabNode | LineBreakNode`) because callers may have only +// narrowed as far as TextNode; the matched siblings are always +// CodeHighlightNode/TabNode, and an unmatched anchor is returned unchanged. +function $getLastMatchingCodeNode< + T extends TextNode | LineBreakNode, + D extends CaretDirection, +>(anchor: T, direction: D): T | CodeHighlightNode | TabNode { + let matchingNode: T | CodeHighlightNode | TabNode = anchor; for ( let caret: null | SiblingCaret = $getSiblingCaret( anchor, @@ -51,15 +56,15 @@ function $getLastMatchingCodeNode( return matchingNode; } -export function $getFirstCodeNodeOfLine( - anchor: CodeHighlightNode | TabNode | LineBreakNode, -): CodeHighlightNode | TabNode | LineBreakNode { +export function $getFirstCodeNodeOfLine( + anchor: T, +): T | CodeHighlightNode | TabNode { return $getLastMatchingCodeNode(anchor, 'previous'); } -export function $getLastCodeNodeOfLine( - anchor: CodeHighlightNode | TabNode | LineBreakNode, -): CodeHighlightNode | TabNode | LineBreakNode { +export function $getLastCodeNodeOfLine( + anchor: T, +): T | CodeHighlightNode | TabNode { return $getLastMatchingCodeNode(anchor, 'next'); } diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index 89e4af0f7bc..6473c7f2da4 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -40,9 +40,7 @@ declare export function $isCodeHighlightNode( declare export class CodeHighlightNode extends TextNode { __highlightType: ?string; - constructor(text: string, highlightType?: string, key?: NodeKey): void; - static getType(): string; - static clone(node: CodeHighlightNode): CodeHighlightNode; + constructor(text?: string, highlightType?: string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; setFormat(format: number): this; } @@ -95,9 +93,7 @@ declare export function $isCodeNode( declare export class CodeNode extends ElementNode { __language: string | null | void; - static getType(): string; - static clone(node: CodeNode): CodeNode; - constructor(language: ?string, key?: NodeKey): void; + constructor(language?: ?string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter( selection: RangeSelection, diff --git a/packages/lexical-extension/flow/LexicalExtension.js.flow b/packages/lexical-extension/flow/LexicalExtension.js.flow index dc7999bb2fe..78298e4edf5 100644 --- a/packages/lexical-extension/flow/LexicalExtension.js.flow +++ b/packages/lexical-extension/flow/LexicalExtension.js.flow @@ -139,8 +139,6 @@ declare export function getPeerDependencyFromEditorOrThrow< export type SerializedHorizontalRuleNode = SerializedLexicalNode; declare export class HorizontalRuleNode extends DecoratorNode { - static getType(): string; - static clone(node: HorizontalRuleNode): HorizontalRuleNode; createDOM(): HTMLElement; getTextContent(): '\n'; updateDOM(): false; diff --git a/packages/lexical-extension/src/HorizontalRuleExtension.ts b/packages/lexical-extension/src/HorizontalRuleExtension.ts index 047e178f232..2cca9198f88 100644 --- a/packages/lexical-extension/src/HorizontalRuleExtension.ts +++ b/packages/lexical-extension/src/HorizontalRuleExtension.ts @@ -7,7 +7,6 @@ */ import type { - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -58,27 +57,20 @@ export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand = /* @__PURE__ */ createCommand('INSERT_HORIZONTAL_RULE_COMMAND'); export class HorizontalRuleNode extends DecoratorNode { - static getType(): string { - return 'horizontalrule'; - } - - static clone(node: HorizontalRuleNode): HorizontalRuleNode { - return new HorizontalRuleNode(node.__key); - } - - static importJSON( - serializedNode: SerializedHorizontalRuleNode, - ): HorizontalRuleNode { - return $createHorizontalRuleNode().updateFromJSON(serializedNode); - } - - static importDOM(): DOMConversionMap | null { - return { - hr: () => ({ - conversion: $convertHorizontalRuleElement, - priority: 0, - }), - }; + $config() { + // `extends` is intentionally left to the runtime default (the prototype + // parent) rather than declared explicitly: the deprecated + // `@lexical/react` HorizontalRuleNode subclasses this one and reuses the + // same 'horizontalrule' type, so both `$config()` overrides must infer a + // matching shape. + return this.config('horizontalrule', { + importDOM: { + hr: () => ({ + conversion: $convertHorizontalRuleElement, + priority: 0, + }), + }, + }); } exportDOM(): DOMExportOutput { diff --git a/packages/lexical-hashtag/flow/LexicalHashtag.js.flow b/packages/lexical-hashtag/flow/LexicalHashtag.js.flow index c9937b53e1f..8cc9ab7795e 100644 --- a/packages/lexical-hashtag/flow/LexicalHashtag.js.flow +++ b/packages/lexical-hashtag/flow/LexicalHashtag.js.flow @@ -18,10 +18,8 @@ import type { import {TextNode} from 'lexical'; declare export class HashtagNode extends TextNode { - static getType(): string; - static clone(node: HashtagNode): HashtagNode; static importJSON(serializedNode: SerializedTextNode): HashtagNode; - constructor(text: string, key?: NodeKey): void; + constructor(text?: string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; canInsertTextBefore(): boolean; isTextEntity(): true; diff --git a/packages/lexical-hashtag/src/LexicalHashtagNode.ts b/packages/lexical-hashtag/src/LexicalHashtagNode.ts index 63532b79bb7..321337e5696 100644 --- a/packages/lexical-hashtag/src/LexicalHashtagNode.ts +++ b/packages/lexical-hashtag/src/LexicalHashtagNode.ts @@ -6,18 +6,14 @@ * */ -import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical'; +import type {EditorConfig, LexicalNode} from 'lexical'; import {$applyNodeReplacement, addClassNamesToElement, TextNode} from 'lexical'; /** @noInheritDoc */ export class HashtagNode extends TextNode { - static getType(): string { - return 'hashtag'; - } - - static clone(node: HashtagNode): HashtagNode { - return new HashtagNode(node.__text, node.__key); + $config() { + return this.config('hashtag', {extends: TextNode}); } createDOM(config: EditorConfig): HTMLElement { @@ -26,10 +22,6 @@ export class HashtagNode extends TextNode { return element; } - static importJSON(serializedNode: SerializedTextNode): HashtagNode { - return $createHashtagNode().updateFromJSON(serializedNode); - } - canInsertTextBefore(): boolean { return false; } diff --git a/packages/lexical-link/flow/LexicalLink.js.flow b/packages/lexical-link/flow/LexicalLink.js.flow index 136f236eabc..33d6ad9a9ad 100644 --- a/packages/lexical-link/flow/LexicalLink.js.flow +++ b/packages/lexical-link/flow/LexicalLink.js.flow @@ -38,11 +38,8 @@ declare export class LinkNode extends ElementNode { __target: null | string; __rel: null | string; __title: null | string; - static getType(): string; - static clone(node: LinkNode): LinkNode; - constructor(url: string, attributes?: LinkAttributes, key?: NodeKey): void; + constructor(url?: string, attributes?: LinkAttributes, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - static importDOM(): DOMConversionMap | null; exportJSON(): SerializedLinkNode; getURL(): string; setURL(url: string): void; @@ -74,9 +71,6 @@ export type SerializedAutoLinkNode = { ... }; declare export class AutoLinkNode extends LinkNode { - static getType(): string; - // $FlowFixMe[incompatible-type] clone method inheritance - static clone(node: AutoLinkNode): AutoLinkNode; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical-link/src/LexicalLinkNode.ts b/packages/lexical-link/src/LexicalLinkNode.ts index 7cba48af427..f41f4cff835 100644 --- a/packages/lexical-link/src/LexicalLinkNode.ts +++ b/packages/lexical-link/src/LexicalLinkNode.ts @@ -8,7 +8,6 @@ import type { BaseSelection, - DOMConversionMap, DOMConversionOutput, EditorConfig, LexicalCommand, @@ -85,16 +84,16 @@ export class LinkNode extends ElementNode { /** @internal */ __title: null | string; - static getType(): string { - return 'link'; - } - - static clone(node: LinkNode): LinkNode { - return new LinkNode( - node.__url, - {rel: node.__rel, target: node.__target, title: node.__title}, - node.__key, - ); + $config() { + return this.config('link', { + extends: ElementNode, + importDOM: { + a: () => ({ + conversion: $convertAnchorElement, + priority: 1, + }), + }, + }); } constructor( @@ -157,19 +156,6 @@ export class LinkNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - a: (node: Node) => ({ - conversion: $convertAnchorElement, - priority: 1, - }), - }; - } - - static importJSON(serializedNode: SerializedLinkNode): LinkNode { - return $createLinkNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super .updateFromJSON(serializedNode) @@ -492,21 +478,8 @@ export class AutoLinkNode extends LinkNode { this.__isUnlinked = prevNode.__isUnlinked; } - static getType(): string { - return 'autolink'; - } - - static clone(node: AutoLinkNode): AutoLinkNode { - return new AutoLinkNode( - node.__url, - { - isUnlinked: node.__isUnlinked, - rel: node.__rel, - target: node.__target, - title: node.__title, - }, - node.__key, - ); + $config() { + return this.config('autolink', {extends: LinkNode}); } shouldMergeAdjacentLink(_otherLink: LinkNode): boolean { @@ -542,10 +515,6 @@ export class AutoLinkNode extends LinkNode { ); } - static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode { - return $createAutoLinkNode().updateFromJSON(serializedNode); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { @@ -554,11 +523,6 @@ export class AutoLinkNode extends LinkNode { .setIsUnlinked(serializedNode.isUnlinked || false); } - static importDOM(): null { - // TODO: Should link node should handle the import over autolink? - return null; - } - exportJSON(): SerializedAutoLinkNode { return { ...super.exportJSON(), diff --git a/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts index 1155b706c84..a5fb60cc037 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -14,6 +14,7 @@ import { SerializedAutoLinkNode, } from '@lexical/link'; import { + $cloneWithProperties, $createParagraphNode, $createRangeSelection, $getRoot, @@ -79,7 +80,7 @@ describe('LexicalAutoAutoLinkNode tests', () => { await editor.update(() => { const autoLinkNode = new AutoLinkNode('/'); - const clone = AutoLinkNode.clone(autoLinkNode); + const clone = $cloneWithProperties(autoLinkNode); expect(clone).not.toBe(autoLinkNode); expect(clone).toStrictEqual(autoLinkNode); diff --git a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts index 724e13e8ca3..0f2728ca751 100644 --- a/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts +++ b/packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts @@ -24,6 +24,7 @@ import { RichTextExtension, } from '@lexical/rich-text'; import { + $cloneWithProperties, $createLineBreakNode, $createParagraphNode, $createRangeSelection, @@ -82,7 +83,7 @@ describe('LexicalLinkNode tests', () => { await editor.update(() => { const linkNode = $createLinkNode('/'); - const linkNodeClone = LinkNode.clone(linkNode); + const linkNodeClone = $cloneWithProperties(linkNode); expect(linkNodeClone).not.toBe(linkNode); expect(linkNodeClone).toStrictEqual(linkNode); diff --git a/packages/lexical-mark/flow/LexicalMark.js.flow b/packages/lexical-mark/flow/LexicalMark.js.flow index df2202f6a2d..6a605729d0f 100644 --- a/packages/lexical-mark/flow/LexicalMark.js.flow +++ b/packages/lexical-mark/flow/LexicalMark.js.flow @@ -24,9 +24,7 @@ export type SerializedMarkNode = SerializedElementNode & { declare export class MarkNode extends ElementNode { __ids: string[]; - // $FlowFixMe[incompatible-variance] - static clone(node: this): MarkNode; - constructor(ids: string[], key?: NodeKey): void; + constructor(ids?: string[], key?: NodeKey): void; hasID(id: string): boolean; getIDs(): string[]; diff --git a/packages/lexical-mark/src/MarkNode.ts b/packages/lexical-mark/src/MarkNode.ts index e2f34eb5889..509c0b220f5 100644 --- a/packages/lexical-mark/src/MarkNode.ts +++ b/packages/lexical-mark/src/MarkNode.ts @@ -39,12 +39,8 @@ export class MarkNode extends ElementNode { /** @internal */ __ids: readonly string[]; - static getType(): string { - return 'mark'; - } - - static clone(node: MarkNode): MarkNode { - return new MarkNode(node.__ids, node.__key); + $config() { + return this.config('mark', {extends: ElementNode}); } afterCloneFrom(prevNode: this): void { @@ -52,14 +48,6 @@ export class MarkNode extends ElementNode { this.__ids = prevNode.__ids; } - static importDOM(): null { - return null; - } - - static importJSON(serializedNode: SerializedMarkNode): MarkNode { - return $createMarkNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super.updateFromJSON(serializedNode).setIDs(serializedNode.ids); } diff --git a/packages/lexical-overflow/flow/LexicalOverflow.js.flow b/packages/lexical-overflow/flow/LexicalOverflow.js.flow index 4afe3faf4a3..b7a752d0aac 100644 --- a/packages/lexical-overflow/flow/LexicalOverflow.js.flow +++ b/packages/lexical-overflow/flow/LexicalOverflow.js.flow @@ -17,8 +17,6 @@ import type { } from 'lexical'; import {ElementNode} from 'lexical'; declare export class OverflowNode extends ElementNode { - static getType(): string; - static clone(node: OverflowNode): OverflowNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter(selection: RangeSelection): null | LexicalNode; diff --git a/packages/lexical-playground/src/nodes/EmojiNode.tsx b/packages/lexical-playground/src/nodes/EmojiNode.tsx index 5957632d371..04a5ab8c776 100644 --- a/packages/lexical-playground/src/nodes/EmojiNode.tsx +++ b/packages/lexical-playground/src/nodes/EmojiNode.tsx @@ -26,8 +26,8 @@ export type SerializedEmojiNode = Spread< export class EmojiNode extends TextNode { __className: string; - static getType(): string { - return 'emoji'; + $config() { + return this.config('emoji', {extends: TextNode}); } static clone(node: EmojiNode): EmojiNode { diff --git a/packages/lexical-playground/src/nodes/EquationNode.tsx b/packages/lexical-playground/src/nodes/EquationNode.tsx index d7245b86e07..e81de756ecf 100644 --- a/packages/lexical-playground/src/nodes/EquationNode.tsx +++ b/packages/lexical-playground/src/nodes/EquationNode.tsx @@ -33,12 +33,8 @@ export class EquationNode extends DecoratorNode { __equation: string; __inline: boolean; - static getType(): string { - return 'equation'; - } - - static clone(node: EquationNode): EquationNode { - return new EquationNode(node.__equation, node.__inline, node.__key); + $config() { + return this.config('equation', {extends: DecoratorNode}); } constructor(equation: string = '', inline?: boolean, key?: NodeKey) { diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx index fb2e66dd4b4..350b67bd322 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx @@ -38,17 +38,8 @@ export class ExcalidrawNode extends DecoratorNode { __width: Dimension; __height: Dimension; - static getType(): string { - return 'excalidraw'; - } - - static clone(node: ExcalidrawNode): ExcalidrawNode { - return new ExcalidrawNode( - node.__data, - node.__width, - node.__height, - node.__key, - ); + $config() { + return this.config('excalidraw', {extends: DecoratorNode}); } static importJSON(serializedNode: SerializedExcalidrawNode): ExcalidrawNode { diff --git a/packages/lexical-playground/src/nodes/FigmaNode.tsx b/packages/lexical-playground/src/nodes/FigmaNode.tsx index 4ee8bf9d4c3..2993addd4f1 100644 --- a/packages/lexical-playground/src/nodes/FigmaNode.tsx +++ b/packages/lexical-playground/src/nodes/FigmaNode.tsx @@ -65,8 +65,8 @@ export type SerializedFigmaNode = Spread< export class FigmaNode extends DecoratorBlockNode { __id: string; - static getType(): string { - return 'figma'; + $config() { + return this.config('figma', {extends: DecoratorBlockNode}); } static clone(node: FigmaNode): FigmaNode { diff --git a/packages/lexical-playground/src/nodes/ImageNode.tsx b/packages/lexical-playground/src/nodes/ImageNode.tsx index 14f5895abfc..1e5936fe441 100644 --- a/packages/lexical-playground/src/nodes/ImageNode.tsx +++ b/packages/lexical-playground/src/nodes/ImageNode.tsx @@ -137,8 +137,8 @@ export class ImageNode extends DecoratorNode { // Captions cannot yet be used within editor cells __captionsEnabled: boolean; - static getType(): string { - return 'image'; + $config() { + return this.config('image', {extends: DecoratorNode}); } static clone(node: ImageNode): ImageNode { diff --git a/packages/lexical-playground/src/nodes/KeywordNode.ts b/packages/lexical-playground/src/nodes/KeywordNode.ts index c10456fb912..79b36b1e342 100644 --- a/packages/lexical-playground/src/nodes/KeywordNode.ts +++ b/packages/lexical-playground/src/nodes/KeywordNode.ts @@ -19,16 +19,8 @@ import { export type SerializedKeywordNode = SerializedTextNode; export class KeywordNode extends TextNode { - static getType(): string { - return 'keyword'; - } - - static clone(node: KeywordNode): KeywordNode { - return new KeywordNode(node.__text, node.__key); - } - - static importJSON(serializedNode: SerializedKeywordNode): KeywordNode { - return $createKeywordNode().updateFromJSON(serializedNode); + $config() { + return this.config('keyword', {extends: TextNode}); } createDOM(config: EditorConfig): HTMLElement { diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts index 95a21f8633c..342f73dff67 100644 --- a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -33,8 +33,8 @@ export class LayoutContainerNode extends ElementNode { this.__templateColumns = templateColumns; } - static getType(): string { - return 'layout-container'; + $config() { + return this.config('layout-container', {extends: ElementNode}); } static clone(node: LayoutContainerNode): LayoutContainerNode { diff --git a/packages/lexical-playground/src/nodes/LayoutItemNode.ts b/packages/lexical-playground/src/nodes/LayoutItemNode.ts index 85e4643a761..aedfe73c706 100644 --- a/packages/lexical-playground/src/nodes/LayoutItemNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutItemNode.ts @@ -21,12 +21,8 @@ export function $isEmptyLayoutItemNode(node: LexicalNode): boolean { } export class LayoutItemNode extends ElementNode { - static getType(): string { - return 'layout-item'; - } - - static clone(node: LayoutItemNode): LayoutItemNode { - return new LayoutItemNode(node.__key); + $config() { + return this.config('layout-item', {extends: ElementNode}); } createDOM(config: EditorConfig): HTMLElement { @@ -54,10 +50,6 @@ export class LayoutItemNode extends ElementNode { return false; } - static importJSON(serializedNode: SerializedLayoutItemNode): LayoutItemNode { - return $createLayoutItemNode().updateFromJSON(serializedNode); - } - isShadowRoot(): boolean { return true; } diff --git a/packages/lexical-playground/src/nodes/MentionNode.ts b/packages/lexical-playground/src/nodes/MentionNode.ts index 61b4a3aeb6c..4c9dac6430a 100644 --- a/packages/lexical-playground/src/nodes/MentionNode.ts +++ b/packages/lexical-playground/src/nodes/MentionNode.ts @@ -28,8 +28,8 @@ const mentionBackgroundColor = 'rgba(24, 119, 232, 0.2)'; export class MentionNode extends TextNode { __mention: string; - static getType(): string { - return 'mention'; + $config() { + return this.config('mention', {extends: TextNode}); } static clone(node: MentionNode): MentionNode { diff --git a/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx b/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx index f8fca7ae9d4..3d0a93f2e7b 100644 --- a/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx +++ b/packages/lexical-playground/src/nodes/PageBreakNode/index.tsx @@ -64,16 +64,8 @@ function PageBreakComponent({nodeKey}: {nodeKey: NodeKey}) { } export class PageBreakNode extends DecoratorNode { - static getType(): string { - return 'page-break'; - } - - static clone(node: PageBreakNode): PageBreakNode { - return new PageBreakNode(node.__key); - } - - static importJSON(serializedNode: SerializedPageBreakNode): PageBreakNode { - return $createPageBreakNode().updateFromJSON(serializedNode); + $config() { + return this.config('page-break', {extends: DecoratorNode}); } createDOM(): HTMLElement { diff --git a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx index c1b632d18dc..f6d19128ede 100644 --- a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx +++ b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx @@ -6,18 +6,14 @@ * */ -import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical'; +import type {EditorConfig, LexicalNode} from 'lexical'; import {$applyNodeReplacement, addClassNamesToElement, TextNode} from 'lexical'; /** @noInheritDoc */ export class SpecialTextNode extends TextNode { - static getType(): string { - return 'specialText'; - } - - static clone(node: SpecialTextNode): SpecialTextNode { - return new SpecialTextNode(node.__text, node.__key); + $config() { + return this.config('specialText', {extends: TextNode}); } createDOM(config: EditorConfig): HTMLElement { @@ -38,10 +34,6 @@ export class SpecialTextNode extends TextNode { return false; } - static importJSON(serializedNode: SerializedTextNode): SpecialTextNode { - return $createSpecialTextNode().updateFromJSON(serializedNode); - } - isTextEntity(): true { return true; } diff --git a/packages/lexical-playground/src/nodes/StickyNode.tsx b/packages/lexical-playground/src/nodes/StickyNode.tsx index 1748bbbc146..074d80f4253 100644 --- a/packages/lexical-playground/src/nodes/StickyNode.tsx +++ b/packages/lexical-playground/src/nodes/StickyNode.tsx @@ -80,8 +80,8 @@ export class StickyNode extends DecoratorNode { __color: StickyNoteColor; __caption: LexicalEditorWithDispose; - static getType(): string { - return 'sticky'; + $config() { + return this.config('sticky', {extends: DecoratorNode}); } static clone(node: StickyNode): StickyNode { diff --git a/packages/lexical-playground/src/nodes/TweetNode.tsx b/packages/lexical-playground/src/nodes/TweetNode.tsx index cc132e76720..1530f0f0285 100644 --- a/packages/lexical-playground/src/nodes/TweetNode.tsx +++ b/packages/lexical-playground/src/nodes/TweetNode.tsx @@ -122,8 +122,8 @@ export type SerializedTweetNode = Spread< export class TweetNode extends DecoratorBlockNode { __id: string; - static getType(): string { - return 'tweet'; + $config() { + return this.config('tweet', {extends: DecoratorBlockNode}); } static clone(node: TweetNode): TweetNode { diff --git a/packages/lexical-playground/src/nodes/YouTubeNode.tsx b/packages/lexical-playground/src/nodes/YouTubeNode.tsx index 1f9dbd5a201..387c7a9aaf1 100644 --- a/packages/lexical-playground/src/nodes/YouTubeNode.tsx +++ b/packages/lexical-playground/src/nodes/YouTubeNode.tsx @@ -68,8 +68,8 @@ export type SerializedYouTubeNode = Spread< export class YouTubeNode extends DecoratorBlockNode { __id: string; - static getType(): string { - return 'youtube'; + $config() { + return this.config('youtube', {extends: DecoratorBlockNode}); } static clone(node: YouTubeNode): YouTubeNode { diff --git a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts index c6ef3c57930..a978c7ec8a1 100644 --- a/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts @@ -41,8 +41,8 @@ export class CollapsibleContainerNode extends ElementNode { this.__open = open; } - static getType(): string { - return 'collapsible-container'; + $config() { + return this.config('collapsible-container', {extends: ElementNode}); } static clone(node: CollapsibleContainerNode): CollapsibleContainerNode { diff --git a/packages/lexical-react/flow/LexicalHorizontalRuleNode.js.flow b/packages/lexical-react/flow/LexicalHorizontalRuleNode.js.flow index 7f396bec4e3..c21467f476f 100644 --- a/packages/lexical-react/flow/LexicalHorizontalRuleNode.js.flow +++ b/packages/lexical-react/flow/LexicalHorizontalRuleNode.js.flow @@ -11,8 +11,6 @@ import type {LexicalNode, LexicalCommand, SerializedLexicalNode} from 'lexical'; import {DecoratorNode} from 'lexical'; export type SerializedHorizontalRuleNode = SerializedLexicalNode; declare export class HorizontalRuleNode extends DecoratorNode { - static getType(): string; - static clone(node: HorizontalRuleNode): HorizontalRuleNode; createDOM(): HTMLElement; getTextContent(): '\n'; updateDOM(): false; diff --git a/packages/lexical-react/src/LexicalHorizontalRuleNode.tsx b/packages/lexical-react/src/LexicalHorizontalRuleNode.tsx index 3779ad1f6f7..7dd0b32afc1 100644 --- a/packages/lexical-react/src/LexicalHorizontalRuleNode.tsx +++ b/packages/lexical-react/src/LexicalHorizontalRuleNode.tsx @@ -6,7 +6,7 @@ * */ -import type {DOMConversionMap, DOMConversionOutput, NodeKey} from 'lexical'; +import type {DOMConversionOutput, NodeKey} from 'lexical'; import type {JSX} from 'react'; import { @@ -82,27 +82,19 @@ function HorizontalRuleComponent({nodeKey}: {nodeKey: NodeKey}) { * @deprecated A pure Lexical implementation is available in `@lexical/extension` as HorizontalRuleExtension */ export class HorizontalRuleNode extends BaseHorizontalRuleNode { - static getType(): string { - return 'horizontalrule'; - } - - static clone(node: HorizontalRuleNode): HorizontalRuleNode { - return new HorizontalRuleNode(node.__key); - } - - static importJSON( - serializedNode: SerializedHorizontalRuleNode, - ): HorizontalRuleNode { - return $createHorizontalRuleNode().updateFromJSON(serializedNode); - } - - static importDOM(): DOMConversionMap | null { - return { - hr: () => ({ - conversion: $convertHorizontalRuleElement, - priority: 0, - }), - }; + $config() { + // `extends` is left to the runtime default (the prototype parent, + // BaseHorizontalRuleNode) so this deprecated subclass infers a `$config()` + // shape compatible with the base node it reuses the 'horizontalrule' type + // from. + return this.config('horizontalrule', { + importDOM: { + hr: () => ({ + conversion: $convertHorizontalRuleElement, + priority: 0, + }), + }, + }); } decorate(): JSX.Element { diff --git a/packages/lexical-rich-text/flow/LexicalRichText.js.flow b/packages/lexical-rich-text/flow/LexicalRichText.js.flow index 003841776b6..e103551736d 100644 --- a/packages/lexical-rich-text/flow/LexicalRichText.js.flow +++ b/packages/lexical-rich-text/flow/LexicalRichText.js.flow @@ -25,8 +25,6 @@ import {ElementNode} from 'lexical'; declare export var DRAG_DROP_PASTE: LexicalCommand; declare export class QuoteNode extends ElementNode { - static getType(): string; - static clone(node: QuoteNode): QuoteNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter( @@ -42,12 +40,9 @@ declare export function $isQuoteNode( export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; declare export class HeadingNode extends ElementNode { __tag: HeadingTagType; - static getType(): string; - static clone(node: HeadingNode): HeadingNode; - constructor(tag: HeadingTagType, key?: NodeKey): void; + constructor(tag?: HeadingTagType, key?: NodeKey): void; getTag(): HeadingTagType; createDOM(config: EditorConfig): HTMLElement; - static importDOM(): DOMConversionMap | null; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index e719ae4b7ad..053c49df75e 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -9,7 +9,6 @@ import type { CaretDirection, CommandPayloadType, - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -151,12 +150,16 @@ export type SerializedQuoteNode = SerializedElementNode; /** @noInheritDoc */ export class QuoteNode extends ElementNode { - static getType(): string { - return 'quote'; - } - - static clone(node: QuoteNode): QuoteNode { - return new QuoteNode(node.__key); + $config() { + return this.config('quote', { + extends: ElementNode, + importDOM: { + blockquote: () => ({ + conversion: $convertBlockquoteElement, + priority: 0, + }), + }, + }); } // View @@ -170,15 +173,6 @@ export class QuoteNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - blockquote: (node: Node) => ({ - conversion: $convertBlockquoteElement, - priority: 0, - }), - }; - } - exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); @@ -203,10 +197,6 @@ export class QuoteNode extends ElementNode { }; } - static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { - return $createQuoteNode().updateFromJSON(serializedNode); - } - // Mutation insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode { @@ -247,12 +237,43 @@ export class HeadingNode extends ElementNode { /** @internal */ __tag: HeadingTagType; - static getType(): string { - return 'heading'; - } - - static clone(node: HeadingNode): HeadingNode { - return new HeadingNode(node.__tag, node.__key); + $config() { + return this.config('heading', { + extends: ElementNode, + importDOM: { + h1: () => ({conversion: $convertHeadingElement, priority: 0}), + h2: () => ({conversion: $convertHeadingElement, priority: 0}), + h3: () => ({conversion: $convertHeadingElement, priority: 0}), + h4: () => ({conversion: $convertHeadingElement, priority: 0}), + h5: () => ({conversion: $convertHeadingElement, priority: 0}), + h6: () => ({conversion: $convertHeadingElement, priority: 0}), + p: (node: Node) => { + // domNode is a

since we matched it by nodeName + const paragraph = node as HTMLParagraphElement; + const firstChild = paragraph.firstChild; + if (firstChild !== null && isGoogleDocsTitle(firstChild)) { + return { + conversion: () => ({node: null}), + priority: 3, + }; + } + return null; + }, + span: (node: Node) => { + if (isGoogleDocsTitle(node)) { + return { + conversion: () => { + return { + node: $createHeadingNode('h1'), + }; + }, + priority: 3, + }; + } + return null; + }, + }, + }); } afterCloneFrom(prevNode: this): void { @@ -293,60 +314,6 @@ export class HeadingNode extends ElementNode { return prevNode.__tag !== this.__tag; } - static importDOM(): DOMConversionMap | null { - return { - h1: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h2: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h3: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h4: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h5: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h6: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - p: (node: Node) => { - // domNode is a

since we matched it by nodeName - const paragraph = node as HTMLParagraphElement; - const firstChild = paragraph.firstChild; - if (firstChild !== null && isGoogleDocsTitle(firstChild)) { - return { - conversion: () => ({node: null}), - priority: 3, - }; - } - return null; - }, - span: (node: Node) => { - if (isGoogleDocsTitle(node)) { - return { - conversion: (domNode: Node) => { - return { - node: $createHeadingNode('h1'), - }; - }, - priority: 3, - }; - } - return null; - }, - }; - } - exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); @@ -371,12 +338,6 @@ export class HeadingNode extends ElementNode { }; } - static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { - return $createHeadingNode(serializedNode.tag).updateFromJSON( - serializedNode, - ); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 47d3ce6353c..19549795bea 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -47,8 +47,6 @@ declare export class TableCellNode extends ElementNode { __headerState: TableCellHeaderState; __width?: number; __backgroundColor: null | string; - static getType(): string; - static clone(node: TableCellNode): TableCellNode; constructor( headerState?: TableCellHeaderState, colSpan?: number, @@ -97,8 +95,6 @@ export type TableMapValueType = { export type TableMapType = TableMapValueType[][]; declare export class TableNode extends ElementNode { - static getType(): string; - static clone(node: TableNode): TableNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter(selection: RangeSelection): null | ParagraphNode | TableNode; @@ -139,8 +135,6 @@ declare export function $getTableAndElementByKey( */ declare export class TableRowNode extends ElementNode { - static getType(): string; - static clone(node: TableRowNode): TableRowNode; constructor(height?: ?number, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; setHeight(height: number): ?number; diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index bb825763e9d..55d90b0ae7c 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -7,7 +7,6 @@ */ import type { - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -70,17 +69,20 @@ export class TableCellNode extends ElementNode { /** @internal */ __verticalAlign?: undefined | string; - static getType(): string { - return 'tablecell'; - } - - static clone(node: TableCellNode): TableCellNode { - return new TableCellNode( - node.__headerState, - node.__colSpan, - node.__width, - node.__key, - ); + $config() { + return this.config('tablecell', { + extends: ElementNode, + importDOM: { + td: () => ({ + conversion: $convertTableCellNodeElement, + priority: 0, + }), + th: () => ({ + conversion: $convertTableCellNodeElement, + priority: 0, + }), + }, + }); } afterCloneFrom(node: this): void { @@ -93,23 +95,6 @@ export class TableCellNode extends ElementNode { this.__width = node.__width; } - static importDOM(): DOMConversionMap | null { - return { - td: (node: Node) => ({ - conversion: $convertTableCellNodeElement, - priority: 0, - }), - th: (node: Node) => ({ - conversion: $convertTableCellNodeElement, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedTableCellNode): TableCellNode { - return $createTableCellNode().updateFromJSON(serializedNode); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 282cfc091e3..c044afe41d8 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -14,7 +14,6 @@ import { $getNearestNodeFromDOMNode, addClassNamesToElement, BaseSelection, - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -174,13 +173,25 @@ export function setScrollableTablesActive( /** @noInheritDoc */ export class TableNode extends ElementNode { /** @internal */ - __rowStriping: boolean; - __frozenColumnCount: number; - __frozenRowCount: number; - __colWidths?: readonly number[]; - - static getType(): string { - return 'table'; + __rowStriping: boolean = false; + __frozenColumnCount: number = 0; + __frozenRowCount: number = 0; + // Initialized unconditionally (not `__colWidths?: ...`) so a freshly + // constructed instance always has the own property — `@lexical/yjs` derives a + // node's syncable properties from `Object.keys(new Klass())`, so an + // uninitialized optional field would silently drop out of collab sync. + __colWidths: readonly number[] | undefined = undefined; + + $config() { + return this.config('table', { + extends: ElementNode, + importDOM: { + table: () => ({ + conversion: $convertTableElement, + priority: 1, + }), + }, + }); } getColWidths(): readonly number[] | undefined { @@ -196,10 +207,6 @@ export class TableNode extends ElementNode { return self; } - static clone(node: TableNode): TableNode { - return new TableNode(node.__key); - } - afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); this.__colWidths = prevNode.__colWidths; @@ -208,19 +215,6 @@ export class TableNode extends ElementNode { this.__frozenRowCount = prevNode.__frozenRowCount; } - static importDOM(): DOMConversionMap | null { - return { - table: (_node: Node) => ({ - conversion: $convertTableElement, - priority: 1, - }), - }; - } - - static importJSON(serializedNode: SerializedTableNode): TableNode { - return $createTableNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super .updateFromJSON(serializedNode) @@ -230,14 +224,6 @@ export class TableNode extends ElementNode { .setColWidths(serializedNode.colWidths); } - constructor(key?: NodeKey) { - super(key); - this.__rowStriping = false; - this.__frozenColumnCount = 0; - this.__frozenRowCount = 0; - this.__colWidths = undefined; - } - exportJSON(): SerializedTableNode { return { ...super.exportJSON(), diff --git a/packages/lexical-table/src/LexicalTableRowNode.ts b/packages/lexical-table/src/LexicalTableRowNode.ts index 4dce4c9927b..0b1cc3cb0bc 100644 --- a/packages/lexical-table/src/LexicalTableRowNode.ts +++ b/packages/lexical-table/src/LexicalTableRowNode.ts @@ -12,7 +12,6 @@ import {$descendantsMatching} from '@lexical/utils'; import { $applyNodeReplacement, addClassNamesToElement, - DOMConversionMap, DOMConversionOutput, EditorConfig, ElementNode, @@ -36,12 +35,16 @@ export class TableRowNode extends ElementNode { /** @internal */ __height?: number; - static getType(): string { - return 'tablerow'; - } - - static clone(node: TableRowNode): TableRowNode { - return new TableRowNode(node.__height, node.__key); + $config() { + return this.config('tablerow', { + extends: ElementNode, + importDOM: { + tr: () => ({ + conversion: $convertTableRowElement, + priority: 0, + }), + }, + }); } afterCloneFrom(prevNode: this): void { @@ -49,19 +52,6 @@ export class TableRowNode extends ElementNode { this.__height = prevNode.__height; } - static importDOM(): DOMConversionMap | null { - return { - tr: (node: Node) => ({ - conversion: $convertTableRowElement, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedTableRowNode): TableRowNode { - return $createTableRowNode().updateFromJSON(serializedNode); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { @@ -70,7 +60,9 @@ export class TableRowNode extends ElementNode { .setHeight(serializedNode.height); } - constructor(height?: number, key?: NodeKey) { + // `height` carries an explicit `undefined` default so the constructor reports + // zero required arguments and `$config` can synthesize the static `clone`. + constructor(height: number | undefined = undefined, key?: NodeKey) { super(key); this.__height = height; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableCellNode.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableCellNode.test.ts index fd95f830a41..1f917cbe24d 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableCellNode.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableCellNode.test.ts @@ -11,10 +11,10 @@ import { $createTableCellNode, $isTableCellNode, TableCellHeaderStates, - TableCellNode, } from '@lexical/table'; import {$createTextNode, $getRoot, DOMConversionOutput} from 'lexical'; import { + $runDOMConversion, expectHtmlToBeEqual, html, initializeUnitTest, @@ -220,22 +220,14 @@ describe('LexicalTableCellNode tests', () => { }); }, 15000); - // Simulates the Lexical Paste Engine finding and running the converter - const convertHTMLTag = (element: HTMLElement) => { - const importDOMMap = TableCellNode.importDOM(); - - // look up tag name (e.g., 'th') in the import map - const handler = importDOMMap![element.tagName.toLowerCase()]; - if (!handler) { - throw new Error(`No handler found for tag ${element.tagName}`); - } - - const specs = handler(element); - if (!specs) { - throw new Error(`Handler returned null for tag ${element.tagName}`); - } - return specs.conversion(element); - }; + // Simulates the Lexical Paste Engine finding and running the converter by + // driving the real DOM-import machinery (the editor's registered conversion + // cache, the path the paste engine actually uses) rather than reaching into + // TableCellNode.importDOM() directly. The element is converted in place so + // the cell's row/table position is visible to the conversion. + const $convertHTMLTag = ( + element: HTMLElement, + ): DOMConversionOutput | null => $runDOMConversion(testEnv.editor, element); const expectTableCellNode = (result: DOMConversionOutput | null) => { const node = result?.node; @@ -259,7 +251,7 @@ describe('LexicalTableCellNode tests', () => { const th = document.createElement('th'); th.setAttribute('scope', 'col'); - const result = convertHTMLTag(th); + const result = $convertHTMLTag(th); const node = expectTableCellNode(result); @@ -274,7 +266,7 @@ describe('LexicalTableCellNode tests', () => { const th = document.createElement('th'); th.setAttribute('scope', 'row'); - const result = convertHTMLTag(th); + const result = $convertHTMLTag(th); const node = expectTableCellNode(result); @@ -292,7 +284,7 @@ describe('LexicalTableCellNode tests', () => { tr.appendChild(th); table.appendChild(tr); - const result = convertHTMLTag(th); + const result = $convertHTMLTag(th); const node = expectTableCellNode(result); @@ -313,7 +305,7 @@ describe('LexicalTableCellNode tests', () => { tr.appendChild(td); table.appendChild(tr); - const result = convertHTMLTag(th); + const result = $convertHTMLTag(th); const node = expectTableCellNode(result); // First row, first column → BOTH @@ -339,7 +331,7 @@ describe('LexicalTableCellNode tests', () => { table.appendChild(tr1); table.appendChild(tr2); - const result = convertHTMLTag(th2); + const result = $convertHTMLTag(th2); const node = expectTableCellNode(result); // Non-first row, first column → COLUMN @@ -362,7 +354,7 @@ describe('LexicalTableCellNode tests', () => { table.appendChild(thead); // Second th in thead → ROW (not first column, so only ROW from first row) - const result = convertHTMLTag(th2); + const result = $convertHTMLTag(th2); const node = expectTableCellNode(result); expect(node.getHeaderStyles()).toBe(TableCellHeaderStates.ROW); @@ -376,7 +368,7 @@ describe('LexicalTableCellNode tests', () => { const td = document.createElement('td'); td.style.backgroundColor = '#F4B084'; - const result = convertHTMLTag(td); + const result = $convertHTMLTag(td); const node = expectTableCellNode(result); // Browsers normalize hex to rgb when set via .style @@ -390,7 +382,7 @@ describe('LexicalTableCellNode tests', () => { await editor.update(() => { const td = document.createElement('td'); - const result = convertHTMLTag(td); + const result = $convertHTMLTag(td); const node = expectTableCellNode(result); expect(node.getBackgroundColor()).toBeNull(); @@ -404,7 +396,7 @@ describe('LexicalTableCellNode tests', () => { const td = document.createElement('td'); td.style.color = 'blue'; - const result = convertHTMLTag(td); + const result = $convertHTMLTag(td); expectTableCellNode(result); // The after callback propagates td color to child TextNodes @@ -421,7 +413,7 @@ describe('LexicalTableCellNode tests', () => { const td = document.createElement('td'); td.style.color = 'blue'; - const result = convertHTMLTag(td); + const result = $convertHTMLTag(td); expectTableCellNode(result); const textNode = $createTextNode('Hello'); diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts index 90665f8e322..2e63af4a60e 100644 --- a/packages/lexical-yjs/src/Utils.ts +++ b/packages/lexical-yjs/src/Utils.ts @@ -553,9 +553,12 @@ export function $syncPropertiesFromYjs( writableNode = lexicalNode.getWritable(); } - writableNode[property as string & keyof typeof writableNode] = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nextValue as any; + // Generic property writer. `property` is never a read-only node field + // here (e.g. `__type` is intrinsic and, being equal on both sides, is + // filtered out by the `prevValue !== nextValue` guard above), so cast + // through a mutable record to write it. + (writableNode as unknown as Record)[property] = + nextValue; } } } diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 5d2ac0072a2..db8c4f55440 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -486,6 +486,7 @@ declare export class LexicalNode { static getType(): string; static clone(data: $FlowFixMe): LexicalNode; static importDOM(): DOMConversionMap | null; + static importJSON(serializedNode: $FlowFixMe): LexicalNode; constructor(key?: NodeKey): void; exportDOM(editor: LexicalEditor): DOMExportOutput; exportJSON(): SerializedLexicalNode; @@ -763,9 +764,7 @@ declare export class TextNode extends LexicalNode { __style: string; __mode: 0 | 1 | 2 | 3; __detail: number; - static getType(): string; - static clone(node: $FlowFixMe): TextNode; - constructor(text: string, key?: NodeKey): void; + constructor(text?: string, key?: NodeKey): void; getTopLevelElement(): ElementNode | null; getTopLevelElementOrThrow(): ElementNode; getFormat(): number; @@ -838,10 +837,7 @@ declare export function $isTabNode( ): node is TabNode; declare export class TabNode extends TextNode { - static getType(): string; - static clone(node: TabNode): TabNode; constructor(key?: NodeKey): void; - static importDOM(): DOMConversionMap | null; static importJSON(serializedTabNode: SerializedTabNode): TabNode; exportJSON(): SerializedTabNode; } @@ -851,8 +847,6 @@ declare export class TabNode extends TextNode { */ declare export class LineBreakNode extends LexicalNode { - static getType(): string; - static clone(node: LineBreakNode): LineBreakNode; constructor(key?: NodeKey): void; getTextContent(): '\n'; createDOM(): HTMLElement; @@ -874,8 +868,7 @@ declare export function $isLineBreakNode( declare export class RootNode extends ElementNode { __cachedText: null | string; - static getType(): string; - static clone(): RootNode; + static importJSON(serializedNode: SerializedRootNode): RootNode; constructor(): void; getTextContent(): string; select(_anchorOffset?: number, _focusOffset?: number): RangeSelection; @@ -1105,8 +1098,6 @@ declare export function $isLexicalNode( * LexicalParagraphNode */ declare export class ParagraphNode extends ElementNode { - static getType(): string; - static clone(node: ParagraphNode): ParagraphNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; insertNewAfter( diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 8a7347204c7..0997ee8e913 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -714,7 +714,9 @@ export class LexicalNode { /** @internal Allow us to look up the type including static props */ declare ['constructor']: KlassConstructor; /** @internal */ - __type: string; + // `__type` is assigned once, in the constructor, and is never valid to + // mutate afterward. + readonly __type: string; /** @internal */ //@ts-ignore We set the key in the constructor. __key: string; diff --git a/packages/lexical/src/__tests__/utils/index.tsx b/packages/lexical/src/__tests__/utils/index.tsx index 1e4a4fcdd96..53c3138d383 100644 --- a/packages/lexical/src/__tests__/utils/index.tsx +++ b/packages/lexical/src/__tests__/utils/index.tsx @@ -32,6 +32,8 @@ import { createEditor, CreateEditorArgs, DecoratorNode, + DOMConversion, + DOMConversionOutput, EditorState, EditorThemeClasses, ElementNode, @@ -608,3 +610,36 @@ export function prettifyHtml(s: string): string { parser: 'html', }); } + +/** + * Locate and run the DOM importer for `element` through the editor's registered + * conversion cache — the same machinery `@lexical/html`'s paste path consults + * via `getConversionFunction` — and return its {@link DOMConversionOutput}. + * + * Prefer this over calling a node's static `importDOM()` directly: it exercises + * the real registration (priority resolution, dedup, and any `HTMLConfig` + * import overrides) instead of one class's raw map, and it runs the conversion + * on `element` in place so logic that inspects the element's DOM ancestors + * (e.g. a table cell reading its row/table position) sees the real context. + */ +export function $runDOMConversion( + editor: LexicalEditor, + element: HTMLElement, +): DOMConversionOutput | null { + let match: DOMConversion | null = null; + const conversions = editor._htmlConversions.get( + element.tagName.toLowerCase(), + ); + if (conversions !== undefined) { + for (const conversion of conversions) { + const candidate = conversion(element); + if ( + candidate !== null && + (match === null || (match.priority || 0) <= (candidate.priority || 0)) + ) { + match = candidate; + } + } + } + return match !== null ? match.conversion(element) : null; +} diff --git a/packages/lexical/src/nodes/ArtificialNode.ts b/packages/lexical/src/nodes/ArtificialNode.ts index 47339f3a785..b52f9bfec65 100644 --- a/packages/lexical/src/nodes/ArtificialNode.ts +++ b/packages/lexical/src/nodes/ArtificialNode.ts @@ -12,8 +12,8 @@ import {ElementNode} from './LexicalElementNode'; // TODO: Cleanup ArtificialNode__DO_NOT_USE #5966 /** @internal */ export class ArtificialNode__DO_NOT_USE extends ElementNode { - static getType(): string { - return 'artificial'; + $config() { + return this.config('artificial', {extends: ElementNode}); } createDOM(config: EditorConfig): HTMLElement { diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts index 376ae273ad7..f31cff97d38 100644 --- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts +++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts @@ -7,12 +7,7 @@ */ import type {KlassConstructor} from '../LexicalEditor'; -import type { - DOMConversionMap, - DOMConversionOutput, - NodeKey, - SerializedLexicalNode, -} from '../LexicalNode'; +import type {DOMConversionOutput, SerializedLexicalNode} from '../LexicalNode'; import {LexicalNode} from '../LexicalNode'; import { @@ -27,16 +22,21 @@ export type SerializedLineBreakNode = SerializedLexicalNode; export class LineBreakNode extends LexicalNode { /** @internal */ declare ['constructor']: KlassConstructor; - static getType(): string { - return 'linebreak'; - } - - static clone(node: LineBreakNode): LineBreakNode { - return new LineBreakNode(node.__key); - } - constructor(key?: NodeKey) { - super(key); + $config() { + return this.config('linebreak', { + importDOM: { + br: (node: Node) => { + if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) { + return null; + } + return { + conversion: $convertLineBreakElement, + priority: 0, + }; + }, + }, + }); } getTextContent(): '\n' { @@ -54,26 +54,6 @@ export class LineBreakNode extends LexicalNode { isInline(): true { return true; } - - static importDOM(): DOMConversionMap | null { - return { - br: (node: Node) => { - if (isOnlyChildInBlockNode(node) || isLastChildInBlockNode(node)) { - return null; - } - return { - conversion: $convertLineBreakElement, - priority: 0, - }; - }, - }; - } - - static importJSON( - serializedLineBreakNode: SerializedLineBreakNode, - ): LineBreakNode { - return $createLineBreakNode().updateFromJSON(serializedLineBreakNode); - } } function $convertLineBreakElement(node: Node): DOMConversionOutput { diff --git a/packages/lexical/src/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index 825852499ec..4b0fa37338f 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -13,7 +13,6 @@ import type { Spread, } from '../LexicalEditor'; import type { - DOMConversionMap, DOMConversionOutput, DOMExportOutput, LexicalNode, @@ -49,12 +48,16 @@ export class ParagraphNode extends ElementNode { /** @internal */ declare ['constructor']: KlassConstructor; - static getType(): string { - return 'paragraph'; - } - - static clone(node: ParagraphNode): ParagraphNode { - return new ParagraphNode(node.__key); + $config() { + return this.config('paragraph', { + extends: ElementNode, + importDOM: { + p: () => ({ + conversion: $convertParagraphElement, + priority: 0, + }), + }, + }); } // View @@ -76,15 +79,6 @@ export class ParagraphNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - p: (node: Node) => ({ - conversion: $convertParagraphElement, - priority: 0, - }), - }; - } - exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); @@ -104,10 +98,6 @@ export class ParagraphNode extends ElementNode { }; } - static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode { - return $createParagraphNode().updateFromJSON(serializedNode); - } - exportJSON(): SerializedParagraphNode { const json = super.exportJSON(); // Provide backwards compatible values, see #7971 diff --git a/packages/lexical/src/nodes/LexicalRootNode.ts b/packages/lexical/src/nodes/LexicalRootNode.ts index 6c17486a6d4..711902fb9c2 100644 --- a/packages/lexical/src/nodes/LexicalRootNode.ts +++ b/packages/lexical/src/nodes/LexicalRootNode.ts @@ -26,12 +26,8 @@ export class RootNode extends ElementNode { /** @internal */ __cachedText: null | string; - static getType(): string { - return 'root'; - } - - static clone(): RootNode { - return new RootNode(); + $config() { + return this.config('root', {extends: ElementNode}); } constructor() { diff --git a/packages/lexical/src/nodes/LexicalTabNode.ts b/packages/lexical/src/nodes/LexicalTabNode.ts index 5e7458ce24d..7d47a6f4c8f 100644 --- a/packages/lexical/src/nodes/LexicalTabNode.ts +++ b/packages/lexical/src/nodes/LexicalTabNode.ts @@ -6,7 +6,7 @@ * */ -import type {DOMConversionMap, NodeKey} from '../LexicalNode'; +import type {NodeKey} from '../LexicalNode'; import invariant from '@lexical/internal/invariant'; @@ -25,23 +25,18 @@ export type SerializedTabNode = SerializedTextNode; /** @noInheritDoc */ export class TabNode extends TextNode { - static getType(): string { - return 'tab'; + $config() { + return this.config('tab', {extends: TextNode}); } - static clone(node: TabNode): TabNode { - return new TabNode(node.__key); - } - - constructor(key?: NodeKey) { + // `key` carries an explicit `undefined` default (rather than the usual `?`) + // so the constructor reports zero required arguments, which lets `$config` + // synthesize the static `clone` by invoking the no-argument constructor. + constructor(key: NodeKey | undefined = undefined) { super('\t', key); this.__detail = IS_UNMERGEABLE; } - static importDOM(): DOMConversionMap | null { - return null; - } - createDOM(config: EditorConfig): HTMLElement { const dom = super.createDOM(config); const classNames = getCachedClassNameArray(config.theme, 'tab'); @@ -53,10 +48,6 @@ export class TabNode extends TextNode { return dom; } - static importJSON(serializedTabNode: SerializedTabNode): TabNode { - return $createTabNode().updateFromJSON(serializedTabNode); - } - /** * Always normalizes the stored content to `'\t'` regardless of input — see * comment below for the rationale. diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index f7afdae89c1..a490776a49c 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -14,7 +14,6 @@ import type { TextNodeThemeClasses, } from '../LexicalEditor'; import type { - DOMConversionMap, DOMConversionOutput, DOMExportOutput, LexicalUpdateJSON, @@ -341,12 +340,59 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { return true; } - static getType(): string { - return 'text'; - } - - static clone(node: TextNode): TextNode { - return new TextNode(node.__text, node.__key); + $config() { + return this.config('text', { + importDOM: { + '#text': () => ({ + conversion: $convertTextDOMNode, + priority: 0, + }), + b: () => ({ + conversion: convertBringAttentionToElement, + priority: 0, + }), + code: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + em: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + i: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + mark: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + s: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + span: () => ({ + conversion: convertSpanElement, + priority: 0, + }), + strong: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + sub: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + sup: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + u: () => ({ + conversion: convertTextFormatElement, + priority: 0, + }), + }, + }); } afterCloneFrom(prevNode: this): void { @@ -607,63 +653,6 @@ export class TextNode extends LexicalNode implements InlineFormattableNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - '#text': () => ({ - conversion: $convertTextDOMNode, - priority: 0, - }), - b: () => ({ - conversion: convertBringAttentionToElement, - priority: 0, - }), - code: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - em: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - i: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - mark: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - s: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - span: () => ({ - conversion: convertSpanElement, - priority: 0, - }), - strong: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - sub: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - sup: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - u: () => ({ - conversion: convertTextFormatElement, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedTextNode): TextNode { - return $createTextNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super .updateFromJSON(serializedNode) diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalParagraphNode.test.ts b/packages/lexical/src/nodes/__tests__/unit/LexicalParagraphNode.test.ts index 9fa13eaa5f9..4335dcba1aa 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalParagraphNode.test.ts +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalParagraphNode.test.ts @@ -15,7 +15,11 @@ import { } from 'lexical'; import {describe, expect, test} from 'vitest'; -import {initializeUnitTest, invariant} from '../../../__tests__/utils'; +import { + $runDOMConversion, + initializeUnitTest, + invariant, +} from '../../../__tests__/utils'; const editorConfig = Object.freeze({ namespace: '', @@ -151,18 +155,11 @@ describe('LexicalParagraphNode tests', () => { test('ParagraphNode.importDOM handles both CSS text-align and legacy align attribute', async () => { const {editor} = testEnv; - const convertParagraph = (element: HTMLElement) => { - const importDOMMap = ParagraphNode.importDOM(); - const handler = importDOMMap![element.tagName.toLowerCase()]; - if (!handler) { - throw new Error(`No handler found for tag: ${element.tagName}`); - } - const specs = handler(element); - if (!specs) { - return null; - } - return specs.conversion(element); - }; + // Drive the real DOM-import machinery (the editor's registered conversion + // cache) instead of reaching into ParagraphNode.importDOM() directly. + const $convertParagraph = ( + element: HTMLElement, + ): DOMConversionOutput | null => $runDOMConversion(editor, element); const expectParagraphNode = (result: DOMConversionOutput | null) => { const node = result ? result.node : null; @@ -183,14 +180,14 @@ describe('LexicalParagraphNode tests', () => { const pAlign = document.createElement('p'); pAlign.setAttribute('align', 'right'); - const nodeAlign = expectParagraphNode(convertParagraph(pAlign)); + const nodeAlign = expectParagraphNode($convertParagraph(pAlign)); expect(nodeAlign.getFormatType()).toBe('right'); // Case 2: Modern

const pStyle = document.createElement('p'); pStyle.style.textAlign = 'center'; - const nodeStyle = expectParagraphNode(convertParagraph(pStyle)); + const nodeStyle = expectParagraphNode($convertParagraph(pStyle)); expect(nodeStyle.getFormatType()).toBe('center'); // Case 3: CSS takes priority over Attribute @@ -199,7 +196,7 @@ describe('LexicalParagraphNode tests', () => { pConflict.setAttribute('align', 'right'); pConflict.style.textAlign = 'left'; - const nodeConflict = expectParagraphNode(convertParagraph(pConflict)); + const nodeConflict = expectParagraphNode($convertParagraph(pConflict)); expect(nodeConflict.getFormatType()).toBe('left'); // Case 4: Invalid align attribute is ignored @@ -207,7 +204,7 @@ describe('LexicalParagraphNode tests', () => { const pInvalid = document.createElement('p'); pInvalid.setAttribute('align', 'super-weird-stuff'); - const nodeInvalid = expectParagraphNode(convertParagraph(pInvalid)); + const nodeInvalid = expectParagraphNode($convertParagraph(pInvalid)); // Should NOT be 'super-weird-stuff' or undefined expect(nodeInvalid.getFormatType()).toBe(''); }); diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalRootNode.test.ts b/packages/lexical/src/nodes/__tests__/unit/LexicalRootNode.test.ts index 35118007aac..60070731b38 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalRootNode.test.ts +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalRootNode.test.ts @@ -6,6 +6,7 @@ * */ import { + $cloneWithProperties, $createParagraphNode, $createTextNode, $getRoot, @@ -91,7 +92,7 @@ describe('LexicalRootNode tests', () => { }); test('RootNode.clone()', async () => { - const rootNodeClone = (rootNode.constructor as typeof RootNode).clone(); + const rootNodeClone = $cloneWithProperties(rootNode); expect(rootNodeClone).not.toBe(rootNode); expect(rootNodeClone).toStrictEqual(rootNode);

since we matched it by nodeName - const td = node as HTMLTableCellElement; - const table: HTMLTableElement | null = td.closest('table'); - - if (isGitHubCodeCell(td) || (table && isGitHubCodeTable(table))) { - // Return a no-op if it's a table cell in a code table, but not a code line. - // Otherwise it'll fall back to the T - return { - conversion: convertCodeNoop, - priority: 3, - }; - } - - return null; - }, - tr: (node: Node) => { - // element is a