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