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
-
+
+ betical (TAB)
+
`,
);
@@ -118,7 +123,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);
@@ -204,7 +214,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);
@@ -241,7 +256,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials
+
`,
);
@@ -278,7 +298,12 @@ test.describe('Autocomplete', () => {
data-lexical-text="true">
Test
-
+
+ imonials (TAB)
+
`,
);