diff --git a/packages/lexical-website/docs/concepts/commands.md b/packages/lexical-website/docs/concepts/commands.md index 5a8447869f7..35685477f6a 100644 --- a/packages/lexical-website/docs/concepts/commands.md +++ b/packages/lexical-website/docs/concepts/commands.md @@ -13,9 +13,7 @@ When registering a `command` you supply a `priority` and can return `true` to ma You can view all of the existing commands in [`LexicalCommands.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalCommands.ts), but if you need a custom command for your own use case check out the typed `createCommand(...)` function. ```js -const HELLO_WORLD_COMMAND: LexicalCommand = createCommand(); - -editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!'); +const HELLO_WORLD_COMMAND: LexicalCommand = createCommand('HELLO_WORLD'); editor.registerCommand( HELLO_WORLD_COMMAND, @@ -23,8 +21,10 @@ editor.registerCommand( console.log(payload); // Hello World! return false; }, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_EDITOR, ); + +editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!'); ``` ## `editor.dispatchCommand(...)` @@ -121,3 +121,44 @@ editor.registerCommand( ``` Note that the same `KEY_TAB_COMMAND` command is registered by [`LexicalTableSelectionHelpers.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical-table/src/LexicalTableSelectionHelpers.ts), which handles moving focus to the next or previous cell within a `TableNode`, but the priority is high (`COMMAND_PRIORITY_HIGH`) because this behavior is very important. + +### Priorities and ordering + +Command listeners are called in the following order until a listener returns `true`: + +- From priority highest to lowest (critical, high, normal, low, editor) +- All `COMMAND_PRIORITY_BEFORE_${priority}` listeners, most recently registered first +- All `COMMAND_PRIORITY_${priority}` listeners, in registration order + +:::note + +As of v0.44.0 there are new `COMMAND_PRIORITY_BEFORE_*` priorities available +which make it much easier to override default behavior without escalating the priority. + +::: + +It is best practice to use the lowest priority possible, so most commands will +be registered with `COMMAND_PRIORITY_EDITOR` for the default behavior, then +commands to override that behavior can be registered with +`COMMAND_PRIORITY_BEFORE_EDITOR`. The higher priorities mostly serve purposes +such as being able to observe-but-not-handle events and to support legacy code +that predates the availability of `COMMAND_PRIORITY_BEFORE_*`. + +A modern lexical app will typically only need the +`COMMAND_PRIORITY_BEFORE_EDITOR` priority since the +last-registered-called-first ordering is suitable for almost all use cases. The +older priorities without BEFORE can be considered legacy and are primarily +offered for compatibility. + +Here is the full ordering of priorities, from lowest to highest: + +- `COMMAND_PRIORITY_EDITOR` +- `COMMAND_PRIORITY_BEFORE_EDITOR` +- `COMMAND_PRIORITY_LOW` +- `COMMAND_PRIORITY_BEFORE_LOW` +- `COMMAND_PRIORITY_NORMAL` +- `COMMAND_PRIORITY_BEFORE_NORMAL` +- `COMMAND_PRIORITY_HIGH` +- `COMMAND_PRIORITY_BEFORE_HIGH` +- `COMMAND_PRIORITY_CRITICAL` +- `COMMAND_PRIORITY_BEFORE_CRITICAL` diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index b81187ddab1..b58874db1d2 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -132,8 +132,18 @@ type Listeners = { update: Map void)>, }; export type CommandListener

= (payload: P, editor: LexicalEditor) => boolean; +declare class DequeSet { + size: number; + addBack(v: T): this; + addFront(v: T): this; + delete(v: T): boolean; + toArray(): T[]; + toReadonlyArray(): $ReadOnlyArray; + @@iterator(): Iterator; +} +type Tuple5 = $ReadOnly<[T, T, T, T, T]>; // $FlowFixMe[unclear-type] -type Commands = Map, Array>>>; +type Commands = Map, Tuple5>>>; type RegisteredNodes = Map; type RegisteredNode = { klass: Class, @@ -247,7 +257,7 @@ declare export class LexicalEditor { registerCommand

( command: LexicalCommand

, listener: CommandListener

, - priority: CommandListenerPriority, + priority: CommandListenerPriority | CommandListenerPriorityBefore, ): () => void; registerEditableListener(listener: EditableListener): () => void; registerMutationListener( @@ -367,11 +377,17 @@ export type EditorConfig = { disableEvents?: boolean, }; export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; +export type CommandListenerPriorityBefore = -8 | -7 | -6 | -5 | -4; export const COMMAND_PRIORITY_EDITOR = 0; export const COMMAND_PRIORITY_LOW = 1; export const COMMAND_PRIORITY_NORMAL = 2; export const COMMAND_PRIORITY_HIGH = 3; export const COMMAND_PRIORITY_CRITICAL = 4; +export const COMMAND_PRIORITY_BEFORE_EDITOR = -8; +export const COMMAND_PRIORITY_BEFORE_LOW = -7; +export const COMMAND_PRIORITY_BEFORE_NORMAL = -6; +export const COMMAND_PRIORITY_BEFORE_HIGH = -5; +export const COMMAND_PRIORITY_BEFORE_CRITICAL = -4; export type LexicalNodeReplacement = { replace: Class, diff --git a/packages/lexical/src/LexicalDequeSet.ts b/packages/lexical/src/LexicalDequeSet.ts new file mode 100644 index 00000000000..fb2a3b72dd5 --- /dev/null +++ b/packages/lexical/src/LexicalDequeSet.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +export class DequeSet implements Iterable { + _front: Set = new Set(); + _back: Set = new Set(); + _cache?: T[]; + get size(): number { + return this._front.size + this._back.size; + } + addBack(v: T): this { + delete this._cache; + if (!this._front.has(v)) { + this._back.add(v); + } + return this; + } + addFront(v: T): this { + delete this._cache; + if (!this._back.has(v)) { + this._front.add(v); + } + return this; + } + delete(v: T): boolean { + delete this._cache; + return this._front.delete(v) || this._back.delete(v); + } + toArray(): T[] { + const arr = Array.from(this._front).reverse(); + for (const v of this._back) { + arr.push(v); + } + return arr; + } + toReadonlyArray(): readonly T[] { + this._cache = this._cache || this.toArray(); + return this._cache; + } + [Symbol.iterator](): IterableIterator { + return this.toReadonlyArray()[Symbol.iterator](); + } +} diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index d84d404ce99..c2ae3db33db 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -27,6 +27,7 @@ import { TextNode, } from '.'; import {FULL_RECONCILE, NO_DIRTY_NODES} from './LexicalConstants'; +import {DequeSet} from './LexicalDequeSet'; import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import { addRootElementEvents, @@ -422,12 +423,61 @@ export type CommandListener

= (payload: P, editor: LexicalEditor) => boolean; export type EditableListener = (editable: boolean) => void | (() => void); export type CommandListenerPriority = 0 | 1 | 2 | 3 | 4; +export type CommandListenerPriorityBefore = + | typeof COMMAND_PRIORITY_BEFORE_CRITICAL + | typeof COMMAND_PRIORITY_BEFORE_EDITOR + | typeof COMMAND_PRIORITY_BEFORE_HIGH + | typeof COMMAND_PRIORITY_BEFORE_LOW + | typeof COMMAND_PRIORITY_BEFORE_NORMAL; +/** + * {@link LexicalEditor.registerCommand} listener added to the end of the editor priority queue (after critical, high, normal, low) + */ export const COMMAND_PRIORITY_EDITOR = 0; +/** + * {@link LexicalEditor.registerCommand} listener added to the end of the low priority queue (after critical, high, normal; before editor) + */ export const COMMAND_PRIORITY_LOW = 1; +/** + * {@link LexicalEditor.registerCommand} listener added to the end of the normal priority queue (after critical, high; before low, editor) + */ export const COMMAND_PRIORITY_NORMAL = 2; +/** + * {@link LexicalEditor.registerCommand} listener added to the end of the high priority queue (after critical; before normal, low, editor) + */ export const COMMAND_PRIORITY_HIGH = 3; +/** + * {@link LexicalEditor.registerCommand} listener added to the end of the critical priority queue (before high, normal, low, editor) + */ export const COMMAND_PRIORITY_CRITICAL = 4; +/** + * {@link LexicalEditor.registerCommand} listener added to the beginning of the editor priority queue (after critical, high, normal, low) + */ +export const COMMAND_PRIORITY_BEFORE_EDITOR = -8; +/** + * {@link LexicalEditor.registerCommand} listener added to the beginning of the low priority queue (after critical, high, normal; before editor) + */ +export const COMMAND_PRIORITY_BEFORE_LOW = -7; +/** + * {@link LexicalEditor.registerCommand} listener added to the beginning of the normal priority queue (after critical, high; before low, editor) + */ +export const COMMAND_PRIORITY_BEFORE_NORMAL = -6; +/** + * {@link LexicalEditor.registerCommand} listener added to the beginning of the high priority queue (after critical; before normal, low, editor) + */ +export const COMMAND_PRIORITY_BEFORE_HIGH = -5; +/** + * {@link LexicalEditor.registerCommand} listener added to the beginning of the critical priority queue (before high, normal, low, editor) + */ +export const COMMAND_PRIORITY_BEFORE_CRITICAL = -4; + +type Tuple5 = readonly [T, T, T, T, T]; + +function normalizePriority( + priority: CommandListenerPriority | CommandListenerPriorityBefore, +): CommandListenerPriority { + return (priority & 7) as CommandListenerPriority; +} // eslint-disable-next-line @typescript-eslint/no-unused-vars export type LexicalCommand = { @@ -457,9 +507,12 @@ export type LexicalCommand = { export type CommandPayloadType> = TCommand extends LexicalCommand ? TPayload : never; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyCommandListener = CommandListener; + type Commands = Map< LexicalCommand, - Array>> + Tuple5> >; export type ListenerMap = Map void)>; @@ -1048,7 +1101,7 @@ export class LexicalEditor { registerCommand

( command: LexicalCommand

, listener: CommandListener

, - priority: CommandListenerPriority, + priority: CommandListenerPriority | CommandListenerPriorityBefore, ): () => void { if (priority === undefined) { invariant(false, 'Listener for type "command" requires a "priority".'); @@ -1058,11 +1111,11 @@ export class LexicalEditor { if (!commandsMap.has(command)) { commandsMap.set(command, [ - new Set(), - new Set(), - new Set(), - new Set(), - new Set(), + new DequeSet(), + new DequeSet(), + new DequeSet(), + new DequeSet(), + new DequeSet(), ]); } @@ -1076,10 +1129,16 @@ export class LexicalEditor { ); } - const listeners = listenersInPriorityOrder[priority]; - listeners.add(listener as CommandListener); + const normalizedPriority = normalizePriority(priority); + + const listeners = listenersInPriorityOrder[normalizedPriority]; + if (normalizedPriority !== priority) { + listeners.addFront(listener); + } else { + listeners.addBack(listener); + } return () => { - listeners.delete(listener as CommandListener); + listeners.delete(listener); if ( listenersInPriorityOrder.every( diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 89def82db97..b20bd5dd486 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -813,15 +813,11 @@ export function triggerCommandListeners< if (listenerInPriorityOrder !== undefined) { const listenersSet = listenerInPriorityOrder[i]; - - if (listenersSet !== undefined) { - const listeners = Array.from(listenersSet); - const listenersLength = listeners.length; - + if (listenersSet.size > 0) { let returnVal = false; updateEditorSync(currentEditor, () => { - for (let j = 0; j < listenersLength; j++) { - if (listeners[j](payload, fromEditor)) { + for (const listener of listenersSet) { + if (listener(payload, fromEditor)) { returnVal = true; return; } diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index 20bae29ec35..d443f06a77a 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -27,6 +27,7 @@ import { TableRowNode, } from '@lexical/table'; import {JSDOM} from 'jsdom'; +import * as lexical from 'lexical'; import { $createLineBreakNode, $createNodeSelection, @@ -45,8 +46,12 @@ import { $parseSerializedNode, $setCompositionKey, $setSelection, + COMMAND_PRIORITY_BEFORE_EDITOR, + COMMAND_PRIORITY_BEFORE_LOW, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, + CommandListenerPriority, + CommandListenerPriorityBefore, createCommand, createEditor, EditorState, @@ -57,6 +62,7 @@ import { type LexicalEditor, type LexicalNode, type LexicalNodeReplacement, + mergeRegister, ParagraphNode, RootNode, SKIP_DOM_SELECTION_TAG, @@ -76,7 +82,7 @@ import {createPortal} from 'react-dom'; import {createRoot, Root} from 'react-dom/client'; import invariant from 'shared/invariant'; import * as ReactTestUtils from 'shared/react-test-utils'; -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {afterEach, assert, beforeEach, describe, expect, it, vi} from 'vitest'; import {emptyFunction} from '../../LexicalUtils'; import {SerializedParagraphNode} from '../../nodes/LexicalParagraphNode'; @@ -1936,22 +1942,22 @@ describe('LexicalEditor tests', () => { ); expect(editor._commands.has(command)).toEqual(true); - expect(editor._commands.get(command)).toEqual([ - new Set([commandListener, commandListenerTwo]), - new Set(), - new Set(), - new Set(), - new Set(), + expect(editor._commands.get(command)?.map((v) => [...v])).toEqual([ + [commandListener, commandListenerTwo], + [], + [], + [], + [], ]); removeCommandListener(); - expect(editor._commands.get(command)).toEqual([ - new Set([commandListenerTwo]), - new Set(), - new Set(), - new Set(), - new Set(), + expect(editor._commands.get(command)?.map((v) => [...v])).toEqual([ + [commandListenerTwo], + [], + [], + [], + [], ]); removeCommandListenerTwo(); @@ -2687,6 +2693,133 @@ describe('LexicalEditor tests', () => { expect(mutationListener).toHaveBeenCalledTimes(1); }); + it('calls command listeners in deque order', async () => { + const calls: string[] = []; + let regOrder = 0; + const names = { + [COMMAND_PRIORITY_BEFORE_EDITOR]: 'before:editor', + [COMMAND_PRIORITY_BEFORE_LOW]: 'before:low', + [COMMAND_PRIORITY_EDITOR]: 'after:editor', + [COMMAND_PRIORITY_LOW]: 'after:low', + } as const; + const listener = (priority: keyof typeof names) => { + const idx = regOrder++; + return editor.registerCommand( + TEST_COMMAND, + () => { + calls.push(`${names[priority]}:${idx}`); + return false; + }, + priority, + ); + }; + const TEST_COMMAND = createCommand('TEST_COMMAND'); + init(); + const unreg = mergeRegister( + listener(COMMAND_PRIORITY_EDITOR), + listener(COMMAND_PRIORITY_LOW), + listener(COMMAND_PRIORITY_EDITOR), + listener(COMMAND_PRIORITY_LOW), + listener(COMMAND_PRIORITY_BEFORE_EDITOR), + listener(COMMAND_PRIORITY_BEFORE_LOW), + listener(COMMAND_PRIORITY_BEFORE_EDITOR), + listener(COMMAND_PRIORITY_BEFORE_LOW), + ); + expect(calls).toHaveLength(0); + editor.dispatchCommand(TEST_COMMAND, undefined); + expect(calls).toEqual([ + 'before:low:7', + 'before:low:5', + 'after:low:1', + 'after:low:3', + 'before:editor:6', + 'before:editor:4', + 'after:editor:0', + 'after:editor:2', + ]); + unreg(); + calls.length = 0; + editor.dispatchCommand(TEST_COMMAND, undefined); + expect(calls).toEqual([]); + }); + it('maps priorities correctly', () => { + // this brute forces to make sure all of the names match exactly what we expect + const beforePriorities: [string, number][] = []; + const afterPriorities: [string, number][] = []; + for (const [k, v] of Object.entries(lexical)) { + if (k.startsWith('COMMAND_PRIORITY_')) { + assert( + typeof v === 'number' && Math.floor(v) === v, + 'priorities are integers', + ); + if (k.startsWith('COMMAND_PRIORITY_BEFORE')) { + expect(v < 0).toBe(true); + beforePriorities.push([k, v]); + } else { + expect(v >= 0).toBe(true); + afterPriorities.push([k, v]); + } + } + } + beforePriorities.sort((a, b) => a[1] - b[1]); + afterPriorities.sort((a, b) => a[1] - b[1]); + expect(beforePriorities).toHaveLength(5); + expect(afterPriorities).toHaveLength(5); + expect( + beforePriorities.map(([k]) => k.replace(/^COMMAND_PRIORITY_BEFORE_/, '')), + ).toEqual( + afterPriorities.map(([k]) => k.replace(/^COMMAND_PRIORITY_/, '')), + ); + init(); + const command = createCommand('TEST_COMMAND'); + const listeners: (() => void)[] = []; + const calls: string[] = []; + for (const count of [0, 1]) { + for (const arr of [afterPriorities, beforePriorities]) { + for (const [k, v] of arr) { + listeners.push( + editor.registerCommand( + command, + () => { + calls.push(`${k} ${count}`); + return false; + }, + v as CommandListenerPriority | CommandListenerPriorityBefore, + ), + ); + } + } + } + editor.dispatchCommand(command, undefined); + expect(calls).toEqual([ + 'COMMAND_PRIORITY_BEFORE_CRITICAL 1', + 'COMMAND_PRIORITY_BEFORE_CRITICAL 0', + 'COMMAND_PRIORITY_CRITICAL 0', + 'COMMAND_PRIORITY_CRITICAL 1', + 'COMMAND_PRIORITY_BEFORE_HIGH 1', + 'COMMAND_PRIORITY_BEFORE_HIGH 0', + 'COMMAND_PRIORITY_HIGH 0', + 'COMMAND_PRIORITY_HIGH 1', + 'COMMAND_PRIORITY_BEFORE_NORMAL 1', + 'COMMAND_PRIORITY_BEFORE_NORMAL 0', + 'COMMAND_PRIORITY_NORMAL 0', + 'COMMAND_PRIORITY_NORMAL 1', + 'COMMAND_PRIORITY_BEFORE_LOW 1', + 'COMMAND_PRIORITY_BEFORE_LOW 0', + 'COMMAND_PRIORITY_LOW 0', + 'COMMAND_PRIORITY_LOW 1', + 'COMMAND_PRIORITY_BEFORE_EDITOR 1', + 'COMMAND_PRIORITY_BEFORE_EDITOR 0', + 'COMMAND_PRIORITY_EDITOR 0', + 'COMMAND_PRIORITY_EDITOR 1', + ]); + // ensure unregistration works + mergeRegister(...listeners)(); + calls.length = 0; + editor.dispatchCommand(command, undefined); + expect(calls).toHaveLength(0); + }); + it('allows using the same listener for multiple node types', async () => { init(); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 4316b68cdf0..f858e3e82c8 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -135,6 +135,7 @@ export { export type { CommandListener, CommandListenerPriority, + CommandListenerPriorityBefore, CommandPayloadType, CreateEditorArgs, EditableListener, @@ -161,6 +162,11 @@ export type { UpdateListenerPayload, } from './LexicalEditor'; export { + COMMAND_PRIORITY_BEFORE_CRITICAL, + COMMAND_PRIORITY_BEFORE_EDITOR, + COMMAND_PRIORITY_BEFORE_HIGH, + COMMAND_PRIORITY_BEFORE_LOW, + COMMAND_PRIORITY_BEFORE_NORMAL, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH,