Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions packages/lexical-website/docs/concepts/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ 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<string> = createCommand();

editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');
const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand('HELLO_WORLD');

editor.registerCommand(
HELLO_WORLD_COMMAND,
(payload: string) => {
console.log(payload); // Hello World!
return false;
},
COMMAND_PRIORITY_LOW,
COMMAND_PRIORITY_EDITOR,
);

editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');
```

## `editor.dispatchCommand(...)`
Expand Down Expand Up @@ -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`
20 changes: 18 additions & 2 deletions packages/lexical/flow/Lexical.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,18 @@ type Listeners = {
update: Map<UpdateListener, void | (() => void)>,
};
export type CommandListener<P> = (payload: P, editor: LexicalEditor) => boolean;
declare class DequeSet<T> {
size: number;
addBack(v: T): this;
addFront(v: T): this;
delete(v: T): boolean;
toArray(): T[];
toReadonlyArray(): $ReadOnlyArray<T>;
@@iterator(): Iterator<T>;
}
type Tuple5<T> = $ReadOnly<[T, T, T, T, T]>;
// $FlowFixMe[unclear-type]
type Commands = Map<LexicalCommand<any>, Array<Set<CommandListener<any>>>>;
type Commands = Map<LexicalCommand<any>, Tuple5<DequeSet<CommandListener<any>>>>;
type RegisteredNodes = Map<string, RegisteredNode>;
type RegisteredNode = {
klass: Class<LexicalNode>,
Expand Down Expand Up @@ -247,7 +257,7 @@ declare export class LexicalEditor {
registerCommand<P>(
command: LexicalCommand<P>,
listener: CommandListener<P>,
priority: CommandListenerPriority,
priority: CommandListenerPriority | CommandListenerPriorityBefore,
): () => void;
registerEditableListener(listener: EditableListener): () => void;
registerMutationListener(
Expand Down Expand Up @@ -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<LexicalNode>,
Expand Down
47 changes: 47 additions & 0 deletions packages/lexical/src/LexicalDequeSet.ts
Original file line number Diff line number Diff line change
@@ -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<T> implements Iterable<T> {
_front: Set<T> = new Set();
_back: Set<T> = 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<T> {
return this.toReadonlyArray()[Symbol.iterator]();
}
}
79 changes: 69 additions & 10 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -422,12 +423,61 @@ export type CommandListener<P> = (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<T> = 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<TPayload> = {
Expand Down Expand Up @@ -457,9 +507,12 @@ export type LexicalCommand<TPayload> = {
export type CommandPayloadType<TCommand extends LexicalCommand<unknown>> =
TCommand extends LexicalCommand<infer TPayload> ? TPayload : never;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyCommandListener = CommandListener<any>;

type Commands = Map<
LexicalCommand<unknown>,
Array<Set<CommandListener<unknown>>>
Tuple5<DequeSet<AnyCommandListener>>
>;

export type ListenerMap<T> = Map<T, undefined | (() => void)>;
Expand Down Expand Up @@ -1048,7 +1101,7 @@ export class LexicalEditor {
registerCommand<P>(
command: LexicalCommand<P>,
listener: CommandListener<P>,
priority: CommandListenerPriority,
priority: CommandListenerPriority | CommandListenerPriorityBefore,
): () => void {
if (priority === undefined) {
invariant(false, 'Listener for type "command" requires a "priority".');
Expand All @@ -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(),
]);
}

Expand All @@ -1076,10 +1129,16 @@ export class LexicalEditor {
);
}

const listeners = listenersInPriorityOrder[priority];
listeners.add(listener as CommandListener<unknown>);
const normalizedPriority = normalizePriority(priority);

const listeners = listenersInPriorityOrder[normalizedPriority];
if (normalizedPriority !== priority) {
listeners.addFront(listener);
} else {
listeners.addBack(listener);
}
return () => {
listeners.delete(listener as CommandListener<unknown>);
listeners.delete(listener);

if (
listenersInPriorityOrder.every(
Expand Down
10 changes: 3 additions & 7 deletions packages/lexical/src/LexicalUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading