From 66f805c41a9c72a4b92f8a5056938335c1b3d7b1 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 13 Dec 2024 10:31:26 -0800 Subject: [PATCH] [Breaking Change][lexical] Feature: New update tag: skip-dom-selection, $onUpdate now always called (#6894) --- .../lexical-code/flow/LexicalCode.js.flow | 7 -- .../lexical-code/src/CodeHighlightNode.ts | 6 +- packages/lexical-code/src/CodeNode.ts | 6 +- .../lexical-link/flow/LexicalLink.js.flow | 5 - packages/lexical-link/src/index.ts | 4 +- packages/lexical-list/src/LexicalListNode.ts | 6 +- packages/lexical-mark/src/MarkNode.ts | 2 +- .../flow/LexicalOverflow.js.flow | 1 - packages/lexical-overflow/src/index.ts | 2 +- .../__tests__/e2e/Autocomplete.spec.mjs | 35 ++++++- .../__tests__/e2e/Tables.spec.mjs | 4 +- .../src/nodes/AutocompleteNode.tsx | 18 ++-- .../src/nodes/EmojiNode.tsx | 6 +- .../src/nodes/EquationNode.tsx | 2 +- .../nodes/InlineImageNode/InlineImageNode.tsx | 6 +- .../src/nodes/LayoutContainerNode.ts | 2 +- .../src/nodes/SpecialTextNode.tsx | 6 +- .../CollapsibleContainerNode.ts | 5 +- .../CollapsibleContentNode.ts | 2 +- .../CollapsiblePlugin/CollapsibleTitleNode.ts | 2 +- .../flow/LexicalRichText.js.flow | 2 - packages/lexical-rich-text/src/index.ts | 4 +- .../lexical-table/flow/LexicalTable.js.flow | 4 - .../lexical-table/src/LexicalTableCellNode.ts | 2 +- .../lexical-table/src/LexicalTableNode.ts | 6 +- .../lexical-table/src/LexicalTableObserver.ts | 4 +- .../lexical-table/src/LexicalTableRowNode.ts | 2 +- packages/lexical-utils/src/markSelection.ts | 22 +++-- .../lexical-website/docs/concepts/commands.md | 6 ++ .../lexical-website/docs/concepts/nodes.md | 4 +- .../docs/concepts/selection.md | 52 ++++++++++- packages/lexical/flow/Lexical.js.flow | 10 +- packages/lexical/src/LexicalEditor.ts | 32 +++++-- packages/lexical/src/LexicalUpdates.ts | 4 +- packages/lexical/src/LexicalUtils.ts | 67 +++++++++----- .../src/__tests__/unit/LexicalEditor.test.tsx | 91 +++++++++++++++++++ .../src/__tests__/unit/LexicalUtils.test.ts | 30 ++++++ packages/lexical/src/index.ts | 1 + packages/lexical/src/nodes/LexicalRootNode.ts | 2 +- packages/lexical/src/nodes/LexicalTextNode.ts | 6 +- 40 files changed, 327 insertions(+), 151 deletions(-) diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index 9c5cf449acd..a3a0bac98f5 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -77,12 +77,6 @@ declare export class CodeHighlightNode extends TextNode { // $FlowFixMe static clone(node: CodeHighlightNode): CodeHighlightNode; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - // $FlowFixMe - prevNode: CodeHighlightNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; setFormat(format: number): this; } @@ -125,7 +119,6 @@ declare export class CodeNode extends ElementNode { static clone(node: CodeNode): CodeNode; constructor(language: ?string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: CodeNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts index 15f08d207ab..c9b43e486d3 100644 --- a/packages/lexical-code/src/CodeHighlightNode.ts +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -136,11 +136,7 @@ export class CodeHighlightNode extends TextNode { return element; } - updateDOM( - prevNode: CodeHighlightNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const update = super.updateDOM(prevNode, dom, config); const prevClassName = getHighlightThemeClass( config.theme, diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index 728e23e64b1..f2ae407c189 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -107,11 +107,7 @@ export class CodeNode extends ElementNode { } return element; } - updateDOM( - prevNode: CodeNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const language = this.__language; const prevLanguage = prevNode.__language; diff --git a/packages/lexical-link/flow/LexicalLink.js.flow b/packages/lexical-link/flow/LexicalLink.js.flow index cab496485ac..5e755f8e8aa 100644 --- a/packages/lexical-link/flow/LexicalLink.js.flow +++ b/packages/lexical-link/flow/LexicalLink.js.flow @@ -39,11 +39,6 @@ declare export class LinkNode extends ElementNode { static clone(node: LinkNode): LinkNode; constructor(url: string, attributes?: LinkAttributes, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - prevNode: LinkNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; static importDOM(): DOMConversionMap | null; exportJSON(): SerializedLinkNode; getURL(): string; diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index b2cdaefc89c..47e8cde0fc8 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -113,7 +113,7 @@ export class LinkNode extends ElementNode { } updateDOM( - prevNode: LinkNode, + prevNode: this, anchor: LinkHTMLElementType, config: EditorConfig, ): boolean { @@ -393,7 +393,7 @@ export class AutoLinkNode extends LinkNode { } updateDOM( - prevNode: AutoLinkNode, + prevNode: this, anchor: LinkHTMLElementType, config: EditorConfig, ): boolean { diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index 2af911c7a8e..a82b342d28d 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -111,11 +111,7 @@ export class ListNode extends ElementNode { return dom; } - updateDOM( - prevNode: ListNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__tag !== this.__tag) { return true; } diff --git a/packages/lexical-mark/src/MarkNode.ts b/packages/lexical-mark/src/MarkNode.ts index a19bd626fc7..2bc9ab140f0 100644 --- a/packages/lexical-mark/src/MarkNode.ts +++ b/packages/lexical-mark/src/MarkNode.ts @@ -78,7 +78,7 @@ export class MarkNode extends ElementNode { } updateDOM( - prevNode: MarkNode, + prevNode: this, element: HTMLElement, config: EditorConfig, ): boolean { diff --git a/packages/lexical-overflow/flow/LexicalOverflow.js.flow b/packages/lexical-overflow/flow/LexicalOverflow.js.flow index 4ebd65ead94..d9e0a990aea 100644 --- a/packages/lexical-overflow/flow/LexicalOverflow.js.flow +++ b/packages/lexical-overflow/flow/LexicalOverflow.js.flow @@ -19,7 +19,6 @@ declare export class OverflowNode extends ElementNode { static clone(node: OverflowNode): OverflowNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: OverflowNode, dom: HTMLElement): boolean; insertNewAfter(selection: RangeSelection): null | LexicalNode; excludeFromCopy(): boolean; static importJSON(serializedNode: SerializedOverflowNode): OverflowNode; diff --git a/packages/lexical-overflow/src/index.ts b/packages/lexical-overflow/src/index.ts index 2b1986a5d6e..60e77d21ecb 100644 --- a/packages/lexical-overflow/src/index.ts +++ b/packages/lexical-overflow/src/index.ts @@ -58,7 +58,7 @@ export class OverflowNode extends ElementNode { return div; } - updateDOM(prevNode: OverflowNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs index 2a7c3156111..fe73b19f791 100644 --- a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs @@ -52,7 +52,12 @@ test.describe('Autocomplete', () => { class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"> Sort by alpha - +

`, ); @@ -118,7 +123,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -204,7 +214,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -241,7 +256,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -278,7 +298,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 1b1ba63cd8b..b6a683f022e 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -1657,7 +1657,7 @@ test.describe.parallel('Tables', () => { }); test( - 'Grid selection: can select multiple cells and insert an image', + 'Table selection: can select multiple cells and insert an image', { tag: '@flaky', }, @@ -1741,7 +1741,7 @@ test.describe.parallel('Tables', () => { }, ); - test('Grid selection: can backspace lines, backspacing empty cell does not destroy it #3278', async ({ + test('Table selection: can backspace lines, backspacing empty cell does not destroy it #3278', async ({ page, isPlainText, isCollab, diff --git a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx index 220add6396c..777f0d69ab6 100644 --- a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx +++ b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx @@ -73,11 +73,7 @@ export class AutocompleteNode extends TextNode { this.__uuid = uuid; } - updateDOM( - prevNode: unknown, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { return false; } @@ -85,12 +81,16 @@ export class AutocompleteNode extends TextNode { return {element: null}; } + excludeFromCopy() { + return true; + } + createDOM(config: EditorConfig): HTMLElement { - if (this.__uuid !== UUID) { - return document.createElement('span'); - } const dom = super.createDOM(config); dom.classList.add(config.theme.autocomplete); + if (this.__uuid !== UUID) { + dom.style.display = 'none'; + } return dom; } } @@ -99,5 +99,5 @@ export function $createAutocompleteNode( text: string, uuid: string, ): AutocompleteNode { - return new AutocompleteNode(text, uuid); + return new AutocompleteNode(text, uuid).setMode('token'); } diff --git a/packages/lexical-playground/src/nodes/EmojiNode.tsx b/packages/lexical-playground/src/nodes/EmojiNode.tsx index 3c1a56874b4..30b899666d1 100644 --- a/packages/lexical-playground/src/nodes/EmojiNode.tsx +++ b/packages/lexical-playground/src/nodes/EmojiNode.tsx @@ -48,11 +48,7 @@ export class EmojiNode extends TextNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const inner = dom.firstChild; if (inner === null) { return true; diff --git a/packages/lexical-playground/src/nodes/EquationNode.tsx b/packages/lexical-playground/src/nodes/EquationNode.tsx index 373b821f988..1ab7cce2128 100644 --- a/packages/lexical-playground/src/nodes/EquationNode.tsx +++ b/packages/lexical-playground/src/nodes/EquationNode.tsx @@ -128,7 +128,7 @@ export class EquationNode extends DecoratorNode { }; } - updateDOM(prevNode: EquationNode): boolean { + updateDOM(prevNode: this): boolean { // If the inline property changes, replace the element return this.__inline !== prevNode.__inline; } diff --git a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx index 3ed9eca084b..1a759e8cd0e 100644 --- a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx +++ b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx @@ -230,11 +230,7 @@ export class InlineImageNode extends DecoratorNode { return span; } - updateDOM( - prevNode: InlineImageNode, - dom: HTMLElement, - config: EditorConfig, - ): false { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): false { const position = this.__position; if (position !== prevNode.__position) { const className = `${config.theme.inlineImage} position-${position}`; diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts index b89eed53b89..8bb7cddf47a 100644 --- a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -73,7 +73,7 @@ export class LayoutContainerNode extends ElementNode { return {element}; } - updateDOM(prevNode: LayoutContainerNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { if (prevNode.__templateColumns !== this.__templateColumns) { dom.style.gridTemplateColumns = this.__templateColumns; } diff --git a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx index 474241d15d4..8c89106f7cf 100644 --- a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx +++ b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx @@ -37,11 +37,7 @@ export class SpecialTextNode extends TextNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__text.startsWith('[') && prevNode.__text.endsWith(']')) { const strippedText = this.__text.substring(1, this.__text.length - 1); // Strip brackets again dom.textContent = strippedText; // Update the text content diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts index 1ade6a71cbc..6d4387e0a59 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts @@ -79,10 +79,7 @@ export class CollapsibleContainerNode extends ElementNode { return dom; } - updateDOM( - prevNode: CollapsibleContainerNode, - dom: HTMLDetailsElement, - ): boolean { + updateDOM(prevNode: this, dom: HTMLDetailsElement): boolean { const currentOpen = this.__open; if (prevNode.__open !== currentOpen) { // details is not well supported in Chrome #5582 diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts index 427d22bfd72..f6f4ce07ddd 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts @@ -72,7 +72,7 @@ export class CollapsibleContentNode extends ElementNode { return dom; } - updateDOM(prevNode: CollapsibleContentNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts index d2e0488e09e..3b6a39061b3 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts @@ -62,7 +62,7 @@ export class CollapsibleTitleNode extends ElementNode { return dom; } - updateDOM(prevNode: CollapsibleTitleNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-rich-text/flow/LexicalRichText.js.flow b/packages/lexical-rich-text/flow/LexicalRichText.js.flow index 0751c17fc5f..9b7bbb0c803 100644 --- a/packages/lexical-rich-text/flow/LexicalRichText.js.flow +++ b/packages/lexical-rich-text/flow/LexicalRichText.js.flow @@ -25,7 +25,6 @@ declare export class QuoteNode extends ElementNode { static clone(node: QuoteNode): QuoteNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, @@ -44,7 +43,6 @@ declare export class HeadingNode extends ElementNode { constructor(tag: HeadingTagType, key?: NodeKey): void; getTag(): HeadingTagType; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean; static importDOM(): DOMConversionMap | null; insertNewAfter( selection: RangeSelection, diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index cec5da17fa7..639cab8ffa5 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -136,7 +136,7 @@ export class QuoteNode extends ElementNode { addClassNamesToElement(element, config.theme.quote); return element; } - updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } @@ -257,7 +257,7 @@ export class HeadingNode extends ElementNode { return element; } - updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 0d3af559ed3..075314baf93 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -52,7 +52,6 @@ declare export class TableCellNode extends ElementNode { key?: NodeKey, ): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableCellNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, ): null | ParagraphNode | TableCellNode; @@ -70,7 +69,6 @@ declare export class TableCellNode extends ElementNode { setBackgroundColor(newBackgroundColor: null | string): TableCellNode; toggleHeaderStyle(headerState: TableCellHeaderState): TableCellNode; hasHeader(): boolean; - updateDOM(prevNode: TableCellNode): boolean; collapseAtStart(): true; canBeEmpty(): false; } @@ -99,7 +97,6 @@ declare export class TableNode extends ElementNode { static clone(node: TableNode): TableNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableNode, dom: HTMLElement): boolean; insertNewAfter(selection: RangeSelection): null | ParagraphNode | TableNode; collapseAtStart(): true; getCordsFromCellNode( @@ -126,7 +123,6 @@ declare export class TableRowNode extends ElementNode { static clone(node: TableRowNode): TableRowNode; constructor(height?: ?number, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableRowNode, dom: HTMLElement): boolean; setHeight(height: number): ?number; getHeight(): ?number; insertNewAfter( diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index 795779c4990..92b52bcf1f0 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -265,7 +265,7 @@ export class TableCellNode extends ElementNode { return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS; } - updateDOM(prevNode: TableCellNode): boolean { + updateDOM(prevNode: this): boolean { return ( prevNode.__headerState !== this.__headerState || prevNode.__width !== this.__width || diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 636613346b3..b31104a7cdb 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -225,11 +225,7 @@ export class TableNode extends ElementNode { return tableElement; } - updateDOM( - prevNode: TableNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__rowStriping !== this.__rowStriping) { setRowStriping(dom, config, this.__rowStriping); } diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 059c471b96d..2f76ce0712a 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -462,9 +462,7 @@ export class TableObserver { const selection = $getSelection(); - if (!$isTableSelection(selection)) { - invariant(false, 'Expected grid selection'); - } + invariant($isTableSelection(selection), 'Expected TableSelection'); const selectedNodes = selection.getNodes().filter($isTableCellNode); diff --git a/packages/lexical-table/src/LexicalTableRowNode.ts b/packages/lexical-table/src/LexicalTableRowNode.ts index 9a7d5c99c88..4e216b865df 100644 --- a/packages/lexical-table/src/LexicalTableRowNode.ts +++ b/packages/lexical-table/src/LexicalTableRowNode.ts @@ -104,7 +104,7 @@ export class TableRowNode extends ElementNode { return this.getLatest().__height; } - updateDOM(prevNode: TableRowNode): boolean { + updateDOM(prevNode: this): boolean { return prevNode.__height !== this.__height; } diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index 382b57b4bb7..1c5a07911dc 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -67,11 +67,12 @@ export default function markSelection( currentAnchorNodeKey !== previousAnchorNode.getKey() || (currentAnchorNode !== previousAnchorNode && (!$isTextNode(previousAnchorNode) || - currentAnchorNode.updateDOM( - previousAnchorNode, - currentAnchorNodeDOM, - editor._config, - ))); + ($isTextNode(currentAnchorNode) && + currentAnchorNode.updateDOM( + previousAnchorNode, + currentAnchorNodeDOM, + editor._config, + )))); const differentFocusDOM = previousFocusNode === null || currentFocusNodeDOM === null || @@ -79,11 +80,12 @@ export default function markSelection( currentFocusNodeKey !== previousFocusNode.getKey() || (currentFocusNode !== previousFocusNode && (!$isTextNode(previousFocusNode) || - currentFocusNode.updateDOM( - previousFocusNode, - currentFocusNodeDOM, - editor._config, - ))); + ($isTextNode(currentFocusNode) && + currentFocusNode.updateDOM( + previousFocusNode, + currentFocusNodeDOM, + editor._config, + )))); if (differentAnchorDOM || differentFocusDOM) { const anchorHTMLElement = editor.getElementByKey( anchor.getNode().getKey(), diff --git a/packages/lexical-website/docs/concepts/commands.md b/packages/lexical-website/docs/concepts/commands.md index a773b640186..5456058c3cf 100644 --- a/packages/lexical-website/docs/concepts/commands.md +++ b/packages/lexical-website/docs/concepts/commands.md @@ -31,6 +31,8 @@ editor.registerCommand( Commands can be dispatched from anywhere you have access to the `editor` such as a Toolbar Button, an event listener, or a Plugin, but most of the core commands are dispatched from [`LexicalEvents.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalEvents.ts). +Calling `dispatchCommand` will implicitly call `editor.update` to trigger its command listeners if it was not called from inside `editor.update`. + ```js editor.dispatchCommand(command, payload); ``` @@ -70,6 +72,10 @@ editor.registerCommand( You can register a command from anywhere you have access to the `editor` object, but it's important that you remember to clean up the listener with its remove listener callback when it's no longer needed. +The command listener will always be called from an `editor.update`, so you may use dollar functions. You should not use +`editor.update` (and *never* call `editor.read`) synchronously from within a command listener. It is safe to call +`editor.getEditorState().read` if you need to read the previous state after updates have already been made. + ```js const removeListener = editor.registerCommand( COMMAND, diff --git a/packages/lexical-website/docs/concepts/nodes.md b/packages/lexical-website/docs/concepts/nodes.md index baa60eb92c5..99526c6d082 100644 --- a/packages/lexical-website/docs/concepts/nodes.md +++ b/packages/lexical-website/docs/concepts/nodes.md @@ -183,7 +183,7 @@ export class CustomParagraph extends ElementNode { return dom; } - updateDOM(prevNode: CustomParagraph, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { // Returning false tells Lexical that this node does not need its // DOM element replacing with a new copy from createDOM. return false; @@ -231,7 +231,7 @@ export class ColoredNode extends TextNode { } updateDOM( - prevNode: ColoredNode, + prevNode: this, dom: HTMLElement, config: EditorConfig, ): boolean { diff --git a/packages/lexical-website/docs/concepts/selection.md b/packages/lexical-website/docs/concepts/selection.md index e1c1a152fd9..0923b3befa1 100644 --- a/packages/lexical-website/docs/concepts/selection.md +++ b/packages/lexical-website/docs/concepts/selection.md @@ -116,4 +116,54 @@ editor.update(() => { // You can also clear selection by setting it to `null`. $setSelection(null); }); -``` \ No newline at end of file +``` + +## Focus + +You may notice that when you issue an `editor.update` or +`editor.dispatchCommand` then the editor can "steal focus" if there is +a selection and the editor is editable. This is because the Lexical +selection is reconciled to the DOM selection during reconciliation, +and the browser's focus follows its DOM selection. + +If you want to make updates or dispatch commands to the editor without +changing the selection, can use the `'skip-dom-selection'` update tag +(added in v0.22.0): + +```js +// Call this from an editor.update or command listener +$addUpdateTag('skip-dom-selection'); +``` + +If you want to add this tag during processing of a `dispatchCommand`, +you can wrap it in an `editor.update`: + +```js +// NOTE: If you are already in a command listener or editor.update, +// do *not* nest a second editor.update! Nested updates have +// confusing semantics (dispatchCommand will re-use the +// current update without nesting) +editor.update(() => { + $addUpdateTag('skip-dom-selection'); + editor.dispatchCommand(/* … */); +}); +``` + +If you have to support older versions of Lexical, you can mark the editor +as not editable during the update or dispatch. + +```js +// NOTE: This code should be *outside* of your update or command listener, e.g. +// directly in the DOM event listener +const prevEditable = editor.isEditable(); +editor.setEditable(false); +editor.update( + () => { + // run your update code or editor.dispatchCommand in here + }, { + onUpdate: () => { + editor.setEditable(prevEditable); + }, + }, +); +``` diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index dccc5987079..a632cd35407 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -415,8 +415,7 @@ declare export class LexicalNode { getTextContentSize(includeDirectionless?: boolean): number; createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement; updateDOM( - // $FlowFixMe - prevNode: any, + prevNode: this, dom: HTMLElement, config: EditorConfig, ): boolean; @@ -611,11 +610,6 @@ declare export class TextNode extends LexicalNode { getTextContent(): string; getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; selectionTransform( prevSelection: null | BaseSelection, nextSelection: RangeSelection, @@ -707,7 +701,6 @@ declare export class RootNode extends ElementNode { replace(node: N): N; insertBefore(nodeToInsert: T): T; insertAfter(nodeToInsert: T): T; - updateDOM(prevNode: RootNode, dom: HTMLElement): false; append(...nodesToAppend: Array): this; canBeEmpty(): false; } @@ -850,7 +843,6 @@ declare export class ParagraphNode extends ElementNode { static clone(node: ParagraphNode): ParagraphNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: ParagraphNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index dcc91e85658..7cc1280854b 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -37,7 +37,7 @@ import { getCachedTypeToNodeMap, getDefaultView, getDOMSelection, - markAllNodesAsDirty, + markNodesWithTypesAsDirty, } from './LexicalUtils'; import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; import {DecoratorNode} from './nodes/LexicalDecoratorNode'; @@ -279,11 +279,11 @@ export type LexicalCommand = { * * editor.registerCommand(MY_COMMAND, payload => { * // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to - * handleMyCommand(editor, payload); + * $handleMyCommand(editor, payload); * return true; * }); * - * function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) { + * function $handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) { * // `payload` is of type `SomeType`, extracted from the command. * } * ``` @@ -775,14 +775,24 @@ export class LexicalEditor { } /** * Registers a listener that will trigger anytime the provided command - * is dispatched, subject to priority. Listeners that run at a higher priority can "intercept" - * commands and prevent them from propagating to other handlers by returning true. + * is dispatched with {@link LexicalEditor.dispatch}, subject to priority. + * Listeners that run at a higher priority can "intercept" commands and + * prevent them from propagating to other handlers by returning true. * - * Listeners registered at the same priority level will run deterministically in the order of registration. + * Listeners are always invoked in an {@link LexicalEditor.update} and can + * call dollar functions. + * + * Listeners registered at the same priority level will run + * deterministically in the order of registration. * * @param command - the command that will trigger the callback. * @param listener - the function that will execute when the command is dispatched. * @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4 + * (or {@link COMMAND_PRIORITY_EDITOR} | + * {@link COMMAND_PRIORITY_LOW} | + * {@link COMMAND_PRIORITY_NORMAL} | + * {@link COMMAND_PRIORITY_HIGH} | + * {@link COMMAND_PRIORITY_CRITICAL}) * @returns a teardown function that can be used to cleanup the listener. */ registerCommand

( @@ -960,7 +970,10 @@ export class LexicalEditor { registeredNodes.push(registeredReplaceWithNode); } - markAllNodesAsDirty(this, klass.getType()); + markNodesWithTypesAsDirty( + this, + registeredNodes.map((node) => node.klass.getType()), + ); return () => { registeredNodes.forEach((node) => node.transforms.delete(listener as Transform), @@ -989,7 +1002,10 @@ export class LexicalEditor { /** * Dispatches a command of the specified type with the specified payload. * This triggers all command listeners (set by {@link LexicalEditor.registerCommand}) - * for this type, passing them the provided payload. + * for this type, passing them the provided payload. The command listeners + * will be triggered in an implicit {@link LexicalEditor.update}, unless + * this was invoked from inside an update in which case that update context + * will be re-used (as if this was a dollar function itself). * @param type - the type of command listeners to trigger. * @param payload - the data to pass as an argument to the command listeners. */ diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 11296a2be5a..8b129817a23 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -607,7 +607,8 @@ export function $commitPendingUpdates( editor._editable && // domSelection will be null in headless domSelection !== null && - (needsUpdate || pendingSelection === null || pendingSelection.dirty) + (needsUpdate || pendingSelection === null || pendingSelection.dirty) && + !tags.has('skip-dom-selection') ) { activeEditor = editor; activeEditorState = pendingEditorState; @@ -1005,6 +1006,7 @@ function $beginUpdate( const shouldUpdate = editor._dirtyType !== NO_DIRTY_NODES || + editor._deferred.length > 0 || editorStateHasDirtySelection(pendingEditorState, editor); if (shouldUpdate) { diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index d4bc2d1ea46..3a90936ba86 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -72,7 +72,6 @@ import { internalGetActiveEditorState, isCurrentlyReadOnlyMode, triggerCommandListeners, - updateEditor, } from './LexicalUpdates'; export const emptyFunction = () => { @@ -498,22 +497,31 @@ export function getEditorStateTextContent(editorState: EditorState): string { return editorState.read(() => $getRoot().getTextContent()); } -export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void { - // Mark all existing text nodes as dirty - updateEditor( - editor, +export function markNodesWithTypesAsDirty( + editor: LexicalEditor, + types: string[], +): void { + // We only need to mark nodes dirty if they were in the previous state. + // If they aren't, then they are by definition dirty already. + const cachedMap = getCachedTypeToNodeMap(editor.getEditorState()); + const dirtyNodeMaps: NodeMap[] = []; + for (const type of types) { + const nodeMap = cachedMap.get(type); + if (nodeMap) { + // By construction these are non-empty + dirtyNodeMaps.push(nodeMap); + } + } + // Nothing to mark dirty, no update necessary + if (dirtyNodeMaps.length === 0) { + return; + } + editor.update( () => { - const editorState = getActiveEditorState(); - if (editorState.isEmpty()) { - return; - } - if (type === 'root') { - $getRoot().markDirty(); - return; - } - const nodeMap = editorState._nodeMap; - for (const [, node] of nodeMap) { - node.markDirty(); + for (const nodeMap of dirtyNodeMaps) { + for (const node of nodeMap.values()) { + node.markDirty(); + } } }, editor._pendingEditorState === null @@ -1825,17 +1833,26 @@ export function getCachedTypeToNodeMap( ); let typeToNodeMap = cachedNodeMaps.get(editorState); if (!typeToNodeMap) { - typeToNodeMap = new Map(); + typeToNodeMap = computeTypeToNodeMap(editorState); cachedNodeMaps.set(editorState, typeToNodeMap); - for (const [nodeKey, node] of editorState._nodeMap) { - const nodeType = node.__type; - let nodeMap = typeToNodeMap.get(nodeType); - if (!nodeMap) { - nodeMap = new Map(); - typeToNodeMap.set(nodeType, nodeMap); - } - nodeMap.set(nodeKey, node); + } + return typeToNodeMap; +} + +/** + * @internal + * Compute a Map of node type to nodes for an EditorState + */ +function computeTypeToNodeMap(editorState: EditorState): TypeToNodeMap { + const typeToNodeMap = new Map(); + for (const [nodeKey, node] of editorState._nodeMap) { + const nodeType = node.__type; + let nodeMap = typeToNodeMap.get(nodeType); + if (!nodeMap) { + nodeMap = new Map(); + typeToNodeMap.set(nodeType, nodeMap); } + nodeMap.set(nodeKey, node); } return typeToNodeMap; } diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 3986f27806f..af71d8f681c 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -39,6 +39,7 @@ import { createEditor, EditorState, ElementNode, + getDOMSelection, type Klass, type LexicalEditor, type LexicalNode, @@ -2893,4 +2894,94 @@ describe('LexicalEditor tests', () => { expect(onError).not.toHaveBeenCalled(); }); }); + + describe('selection', () => { + it('updates the DOM selection', async () => { + const onError = jest.fn(); + const newEditor = createTestEditor({ + onError: onError, + }); + const text = 'initial content'; + let textNode!: TextNode; + await newEditor.update( + () => { + textNode = $createTextNode(text); + $getRoot().append($createParagraphNode().append(textNode)); + textNode.select(); + }, + {tag: 'history-merge'}, + ); + await newEditor.setRootElement(container); + const domText = newEditor.getElementByKey(textNode.getKey()) + ?.firstChild as Text; + expect(domText).not.toBe(null); + let selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + let range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + await newEditor.update(() => { + textNode.select(0); + }); + selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + range = selection.getRangeAt(0); + expect(range.collapsed).toBe(false); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(0); + expect(range.endOffset).toBe(text.length); + expect(onError).not.toHaveBeenCalled(); + }); + it('does not update the Lexical->DOM selection with skip-dom-selection', async () => { + const onError = jest.fn(); + const newEditor = createTestEditor({ + onError: onError, + }); + const text = 'initial content'; + let textNode!: TextNode; + await newEditor.update( + () => { + textNode = $createTextNode(text); + $getRoot().append($createParagraphNode().append(textNode)); + textNode.select(); + }, + {tag: 'history-merge'}, + ); + await newEditor.setRootElement(container); + const domText = newEditor.getElementByKey(textNode.getKey()) + ?.firstChild as Text; + expect(domText).not.toBe(null); + let selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + let range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + await newEditor.update( + () => { + textNode.select(0); + }, + {tag: 'skip-dom-selection'}, + ); + selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + expect(onError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts index e360eac2486..6b7e913c1ba 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -244,6 +244,36 @@ describe('LexicalUtils tests', () => { }); describe('$onUpdate', () => { + test('deferred even when there are no dirty nodes', () => { + const {editor} = testEnv; + const runs: string[] = []; + + editor.update( + () => { + $onUpdate(() => { + runs.push('second'); + }); + }, + { + onUpdate: () => { + runs.push('first'); + }, + }, + ); + expect(runs).toEqual([]); + editor.update(() => { + $onUpdate(() => { + runs.push('third'); + }); + }); + expect(runs).toEqual([]); + + // Flush pending updates + editor.read(() => {}); + + expect(runs).toEqual(['first', 'second', 'third']); + }); + test('added fn runs after update, original onUpdate, and prior calls to $onUpdate', () => { const {editor} = testEnv; const runs: string[] = []; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 562c7c4e387..3f6d1746ae8 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -29,6 +29,7 @@ export type { SerializedEditor, Spread, Transform, + UpdateListener, } from './LexicalEditor'; export type { EditorState, diff --git a/packages/lexical/src/nodes/LexicalRootNode.ts b/packages/lexical/src/nodes/LexicalRootNode.ts index b99576b8ea4..7e4782061f1 100644 --- a/packages/lexical/src/nodes/LexicalRootNode.ts +++ b/packages/lexical/src/nodes/LexicalRootNode.ts @@ -77,7 +77,7 @@ export class RootNode extends ElementNode { // View - updateDOM(prevNode: RootNode, dom: HTMLElement): false { + updateDOM(prevNode: this, dom: HTMLElement): false { return false; } diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index fad639a1c72..694bff21a1b 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -490,11 +490,7 @@ export class TextNode extends LexicalNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const nextText = this.__text; const prevFormat = prevNode.__format; const nextFormat = this.__format;