From 6c94c3348c3f9e61da3836c0caae9ddba6fdef4c Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 1 Dec 2024 14:40:30 -0800 Subject: [PATCH 01/11] New update tag: skip-dom-selection --- .../lexical-website/docs/concepts/commands.md | 6 +++ .../docs/concepts/selection.md | 52 ++++++++++++++++++- packages/lexical/src/LexicalEditor.ts | 27 +++++++--- packages/lexical/src/LexicalUpdates.ts | 3 +- 4 files changed, 79 insertions(+), 9 deletions(-) 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/selection.md b/packages/lexical-website/docs/concepts/selection.md index e1c1a152fd9..6b44f64fe17 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.21.0): + +```js +// Call this from an editor.update or command listener +$addTag('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(() => { + $addTag('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/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 02be308ba84..439ce6f8b1e 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -278,11 +278,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. * } * ``` @@ -774,14 +774,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

( @@ -988,7 +998,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. */ @@ -1288,7 +1301,7 @@ export class LexicalEditor { * You still must call JSON.stringify (or something else) to turn the * state into a string you can transfer over the wire and store in a database. * - * See {@link LexicalNode.exportJSON} + * See {@link LexicalNode#exportJSON} * * @returns A JSON-serializable javascript object */ diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 11296a2be5a..e79cf6d6078 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; From b08c5499bede32252b38992db3517977c8699432 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 6 Dec 2024 12:30:25 -0800 Subject: [PATCH 02/11] update version --- packages/lexical-website/docs/concepts/selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-website/docs/concepts/selection.md b/packages/lexical-website/docs/concepts/selection.md index 6b44f64fe17..429f41d7c6c 100644 --- a/packages/lexical-website/docs/concepts/selection.md +++ b/packages/lexical-website/docs/concepts/selection.md @@ -128,7 +128,7 @@ 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.21.0): +(added in v0.22.0): ```js // Call this from an editor.update or command listener From 8dca3531e4392b50de26a1c011146830e25d95e5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 6 Dec 2024 12:31:54 -0800 Subject: [PATCH 03/11] Clean up old grid selection errors/test names --- packages/lexical-playground/__tests__/e2e/Tables.spec.mjs | 4 ++-- packages/lexical-table/src/LexicalTableObserver.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 1cf4e25a7b1..41da9e170f0 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-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); From 774cb10b69005e825fd678496ac6593fcc21d5d4 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Fri, 6 Dec 2024 13:23:38 -0800 Subject: [PATCH 04/11] fix updateDOM types --- .../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 ++-- .../lexical-list/src/LexicalListItemNode.ts | 6 +---- 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 +- .../src/nodes/AutocompleteNode.tsx | 6 +---- .../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/LexicalTableRowNode.ts | 2 +- packages/lexical-utils/src/markSelection.ts | 22 ++++++++++--------- .../lexical-website/docs/concepts/nodes.md | 4 ++-- packages/lexical/flow/Lexical.js.flow | 10 +-------- .../lexical/src/nodes/LexicalParagraphNode.ts | 6 +---- packages/lexical/src/nodes/LexicalRootNode.ts | 2 +- packages/lexical/src/nodes/LexicalTextNode.ts | 6 +---- 31 files changed, 40 insertions(+), 112 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/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index f4fafcba71a..b47d237f4df 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -82,11 +82,7 @@ export class ListItemNode extends ElementNode { return element; } - updateDOM( - prevNode: ListItemNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const parent = this.getParent(); if ($isListNode(parent) && parent.getListType() === 'check') { updateListItemChecked(dom, this, prevNode, parent); 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/src/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx index 220add6396c..297f646e6a1 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; } 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/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/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/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/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index c1250aeae16..2bcdd8174b3 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -120,11 +120,7 @@ export class ParagraphNode extends ElementNode { } return dom; } - updateDOM( - prevNode: ParagraphNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { return false; } 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; From 2305da999bdee9f170c0d766562ca70c33b73162 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sat, 7 Dec 2024 17:25:36 -0800 Subject: [PATCH 05/11] call onUpdate / $onUpdate even when nothing will change --- packages/lexical/src/LexicalUpdates.ts | 1 + .../src/__tests__/unit/LexicalUtils.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index e79cf6d6078..8b129817a23 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -1006,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/__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[] = []; From 2428272e67c7126edab6ee58a5f406450b8e6b73 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 8 Dec 2024 16:27:19 -0800 Subject: [PATCH 06/11] unit test --- .../src/__tests__/unit/LexicalEditor.test.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 3986f27806f..475d24720ab 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(); + }); + }); }); From a3ccd8b1505c518e7c901fd8e0b70f112789f6c5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 8 Dec 2024 16:34:29 -0800 Subject: [PATCH 07/11] tsc --- packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 475d24720ab..af71d8f681c 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -2902,7 +2902,7 @@ describe('LexicalEditor tests', () => { onError: onError, }); const text = 'initial content'; - let textNode: TextNode; + let textNode!: TextNode; await newEditor.update( () => { textNode = $createTextNode(text); @@ -2944,7 +2944,7 @@ describe('LexicalEditor tests', () => { onError: onError, }); const text = 'initial content'; - let textNode: TextNode; + let textNode!: TextNode; await newEditor.update( () => { textNode = $createTextNode(text); From 31902e31f2f3e24d80ae0eb6c9f960c4390ddd3d Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 10 Dec 2024 10:19:45 -0800 Subject: [PATCH 08/11] use typedoc syntax --- packages/lexical/src/LexicalEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 9dfc5de5aa5..9990bff346b 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -1315,7 +1315,7 @@ export class LexicalEditor { * You still must call JSON.stringify (or something else) to turn the * state into a string you can transfer over the wire and store in a database. * - * See {@link LexicalNode#exportJSON} + * See {@link LexicalNode.exportJSON} * * @returns A JSON-serializable javascript object */ From 503894f247255f34aa514479340536f730d63849 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 10 Dec 2024 21:52:41 -0800 Subject: [PATCH 09/11] fix doc typo --- packages/lexical-website/docs/concepts/selection.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical-website/docs/concepts/selection.md b/packages/lexical-website/docs/concepts/selection.md index 429f41d7c6c..0923b3befa1 100644 --- a/packages/lexical-website/docs/concepts/selection.md +++ b/packages/lexical-website/docs/concepts/selection.md @@ -132,7 +132,7 @@ changing the selection, can use the `'skip-dom-selection'` update tag ```js // Call this from an editor.update or command listener -$addTag('skip-dom-selection'); +$addUpdateTag('skip-dom-selection'); ``` If you want to add this tag during processing of a `dispatchCommand`, @@ -144,7 +144,7 @@ you can wrap it in an `editor.update`: // confusing semantics (dispatchCommand will re-use the // current update without nesting) editor.update(() => { - $addTag('skip-dom-selection'); + $addUpdateTag('skip-dom-selection'); editor.dispatchCommand(/* … */); }); ``` From cd6cce1a5ed2b9cffca726a8c5a125eac2d08e36 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 12 Dec 2024 23:19:06 -0800 Subject: [PATCH 10/11] Avoid marking the whole state as dirty when registering a node transform --- .../src/nodes/AutocompleteNode.tsx | 12 ++-- packages/lexical/src/LexicalEditor.ts | 7 +- packages/lexical/src/LexicalUtils.ts | 67 ++++++++++++------- packages/lexical/src/index.ts | 1 + 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx index 297f646e6a1..777f0d69ab6 100644 --- a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx +++ b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx @@ -81,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; } } @@ -95,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/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 9990bff346b..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'; @@ -970,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), 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/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, From 5836044f7f3362431aaf73ff0318c7a29a7bb728 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Thu, 12 Dec 2024 23:24:15 -0800 Subject: [PATCH 11/11] fix test expectations --- .../__tests__/e2e/Autocomplete.spec.mjs | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) 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 - +

`, );