diff --git a/AGENTS.md b/AGENTS.md
index c20e3824746..ee1453ae614 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,6 +40,10 @@ For E2E testing workflow:
- `pnpm run tsc` - Run TypeScript compiler
- `pnpm run ci-check` - Run all checks (TypeScript, Flow, Prettier, ESLint)
+**Never commit changes to `scripts/error-codes/codes.json`.**
+That edit is not yours to make — revert it to the state of
+`main` before staging, and never `git add` the file.
+
### Searching and refactoring
Prefer **ast-grep** over line-oriented regex (`grep`/`sed`) for anything
diff --git a/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts b/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts
index c80a4c13e58..ede30c4ec87 100644
--- a/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts
+++ b/dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts
@@ -64,6 +64,7 @@ import {
COMMAND_PRIORITY_BEFORE_EDITOR,
COMMAND_PRIORITY_EDITOR,
configExtension,
+ CONTROL_OR_META,
createCommand,
createState,
defineExtension,
@@ -72,7 +73,6 @@ import {
type ElementDOMSlot,
ElementNode,
HISTORIC_TAG,
- IS_APPLE,
isExactShortcutMatch,
isHTMLElement,
KEY_DOWN_COMMAND,
@@ -1159,9 +1159,8 @@ export const MdastFootnoteExtension = defineExtension({
if (
editor.isEditable() &&
isExactShortcutMatch(event, 'f', {
+ ...CONTROL_OR_META,
altKey: true,
- ctrlKey: !IS_APPLE,
- metaKey: IS_APPLE,
})
) {
event.preventDefault();
diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
index de10cecd6dc..ad2dc71dabe 100644
--- a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
+++ b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
@@ -143,6 +143,7 @@ describe('CodeImportExtension', () => {
//
rule out-prioritizes TableExtension's generic one.
dependencies: [TableExtension, CodeExtension],
name: 'table-code-host',
+ theme: {tableScrollableWrapper: ''},
}),
);
importInto(
diff --git a/packages/lexical-extension/src/KeyboardShortcutsExtension.ts b/packages/lexical-extension/src/KeyboardShortcutsExtension.ts
new file mode 100644
index 00000000000..71f8f6748b4
--- /dev/null
+++ b/packages/lexical-extension/src/KeyboardShortcutsExtension.ts
@@ -0,0 +1,289 @@
+/**
+ * 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.
+ *
+ */
+
+import {
+ $getSelection,
+ type BaseSelection,
+ COMMAND_PRIORITY_NORMAL,
+ type CommandListenerPriority,
+ type CommandListenerPriorityBefore,
+ compileKeyboardShortcuts,
+ defineExtension,
+ IS_APPLE,
+ KEY_DOWN_COMMAND,
+ keyboardEventMaskForPlatform,
+ type KeyboardShortcut,
+ type KeyboardShortcutMatch,
+ type LexicalEditor,
+ safeCast,
+ shallowMergeConfig,
+} from 'lexical';
+
+import {namedSignals} from './namedSignals';
+import {effect} from './signals';
+
+export interface FormatKeyboardShortcutOptions {
+ /** Override the platform convention (defaults to the runtime platform) */
+ isApple?: boolean;
+ /** The separator between segments (default `'+'`) */
+ separator?: string;
+}
+
+const MODIFIERS = [
+ ['ctrlKey', 'Ctrl'],
+ ['altKey', 'Alt'],
+ ['shiftKey', 'Shift'],
+ ['metaKey', 'Meta'],
+] as const;
+
+const UNIVERSAL_KEYS: Record = {
+ ' ': 'Space',
+};
+
+const APPLE_KEYS: Record = {
+ ...UNIVERSAL_KEYS,
+ Alt: '\u2325',
+ ArrowDown: '\u2193',
+ ArrowLeft: '\u2190',
+ ArrowRight: '\u2192',
+ ArrowUp: '\u2191',
+ Backspace: '\u232B',
+ CapsLock: '\u21EA',
+ Ctrl: '\u2303',
+ Delete: '\u2326',
+ End: '\u2198',
+ Enter: '\u21A9',
+ Escape: '\u238B',
+ Home: '\u2196',
+ Meta: '\u2318',
+ PageDown: '\u21DF',
+ PageUp: '\u21DE',
+ Shift: '\u21E7',
+ Tab: '\u21E5',
+};
+const SHIFT_APPLE_KEYS: Record = {
+ ...APPLE_KEYS,
+ Tab: '\u21E4',
+};
+
+/**
+ * Format the key binding of a shortcut as a human readable string for
+ * menus, tooltips, and help dialogs (e.g. `'⌘+Shift+K'` on Apple platforms
+ * and `'Ctrl+Shift+K'` elsewhere). Modifiers with an `'any'` mask are not
+ * displayed.
+ */
+export function formatKeyboardShortcut(
+ shortcut: KeyboardShortcutMatch,
+ options: FormatKeyboardShortcutOptions = {},
+): string[] {
+ const {isApple = IS_APPLE} = options;
+ const {unshiftedKey, key} = shortcut;
+ const modifiers = keyboardEventMaskForPlatform(
+ shortcut.modifiers || {},
+ isApple,
+ );
+ const segments: string[] = [];
+ const keyNames = isApple
+ ? modifiers.shiftKey === true
+ ? SHIFT_APPLE_KEYS
+ : APPLE_KEYS
+ : UNIVERSAL_KEYS;
+ for (const [k, name] of MODIFIERS) {
+ if (modifiers[k] === true) {
+ // Apple omits the shift modifier in cases where unshifted key
+ // differs from the key, e.g. 'shift+/', is displayed as '?'
+ if (isApple && k === 'shiftKey' && unshiftedKey && key.length === 1) {
+ continue;
+ }
+ segments.push(keyNames[name] || name);
+ }
+ }
+ segments.push(
+ keyNames[key] ||
+ (!isApple && modifiers.shiftKey === true && unshiftedKey) ||
+ (key.length === 1 && key.toUpperCase()) ||
+ key,
+ );
+ return segments;
+}
+
+/**
+ * Keyboard shortcuts by name. The names exist so that other extensions and
+ * applications can overlay the table: configuring an existing name remaps
+ * that shortcut, configuring it to null disables it, and new names add new
+ * shortcuts.
+ * @experimental
+ */
+export type NamedKeyboardShortcuts = Record<
+ string,
+ KeyboardShortcut | readonly KeyboardShortcut[] | null
+>;
+
+/**
+ * Configuration for {@link KeyboardShortcutsExtension}.
+ * @experiemental
+ */
+export interface KeyboardShortcutsConfig {
+ /** When `true`, the shortcut listener is not registered */
+ disabled: boolean;
+ /**
+ * The `KEY_DOWN_COMMAND` priority (default {@link COMMAND_PRIORITY_NORMAL}).
+ *
+ * This must be a priority *above* {@link COMMAND_PRIORITY_EDITOR}. Every
+ * editor registers the core `$handleKeyDown` at
+ * {@link COMMAND_PRIORITY_EDITOR} and it unconditionally reports the event
+ * as handled, so a shortcut listener at that priority or later is never
+ * reached. That also rules out
+ * {@link COMMAND_PRIORITY_BEFORE_EDITOR}: command dispatch walks priorities
+ * from {@link COMMAND_PRIORITY_CRITICAL} down to
+ * {@link COMMAND_PRIORITY_EDITOR} on the *outside* and the nested editor
+ * chain on the inside, so a nested editor's own `$handleKeyDown` ends the
+ * dispatch before any listener the parent has in the editor-priority queue —
+ * which would make {@link KeyboardShortcut.bubbleFromNestedEditors}
+ * impossible to satisfy.
+ */
+ priority: CommandListenerPriority | CommandListenerPriorityBefore;
+ /** The named shortcut table, merged by name across the extension graph */
+ shortcuts: NamedKeyboardShortcuts;
+}
+
+/**
+ * @experimental @internal
+ *
+ * Compile the given shortcuts and register a single
+ * {@link KEY_DOWN_COMMAND} listener that dispatches each matched shortcut's
+ * command with the KeyboardEvent as its payload (unless its `$disabled`
+ * predicate returns true for the current selection). When several
+ * shortcuts match the same event they are tried in the given order until
+ * one command dispatch is handled.
+ *
+ * @returns A cleanup function that unregisters the listener.
+ */
+function registerKeyboardShortcuts(
+ editor: LexicalEditor,
+ shortcuts: Iterable,
+ priority: CommandListenerPriority | CommandListenerPriorityBefore,
+): () => void {
+ const compiled = compileKeyboardShortcuts(shortcuts);
+ return editor.registerCommand(
+ KEY_DOWN_COMMAND,
+ (event, fromEditor) => {
+ let selection: undefined | null | BaseSelection;
+ for (const shortcut of compiled.matches(event)) {
+ if (editor !== fromEditor && !shortcut.bubbleFromNestedEditors) {
+ continue;
+ }
+ if (shortcut.$disabled) {
+ if (selection === undefined) {
+ selection = $getSelection();
+ }
+ if (shortcut.$disabled(selection, fromEditor)) {
+ continue;
+ }
+ }
+ const $next = fromEditor.dispatchCommand.bind(
+ fromEditor,
+ shortcut.command,
+ event,
+ );
+ if (
+ shortcut.$dispatch
+ ? shortcut.$dispatch(shortcut.command, event, $next, fromEditor)
+ : $next()
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+ priority,
+ );
+}
+
+function isReadonlyArray(x: unknown): x is readonly T[] {
+ return Array.isArray(x);
+}
+
+function flattenKeyboardShortcuts(
+ shortcuts: readonly KeyboardShortcut[] | KeyboardShortcut | null,
+): readonly KeyboardShortcut[] {
+ return isReadonlyArray(shortcuts) ? shortcuts : shortcuts ? [shortcuts] : [];
+}
+
+/**
+ * Merge by name, as {@link shallowMergeConfig} would, except that the
+ * overriding names come *first* in object entry iteration so that they are
+ * also the first to be offered a matching keypress.
+ */
+function mergeNamedShortcuts(
+ config: NamedKeyboardShortcuts,
+ overrides: undefined | NamedKeyboardShortcuts,
+) {
+ if (!overrides) {
+ return config;
+ }
+ const dest = {...overrides};
+ for (const [k, v0] of Object.entries(config)) {
+ if (dest[k] === undefined) {
+ dest[k] = v0;
+ }
+ }
+ return dest;
+}
+
+/**
+ * @experimental
+ *
+ * Dispatches a table of keyboard shortcuts from a single compiled
+ * `KEY_DOWN_COMMAND` listener, in O(1) per keypress.
+ *
+ * The table is merged across the whole extension graph by name: any
+ * extension or app config can add shortcuts under new names, remap an
+ * existing name to a different key or handler, or disable one by
+ * configuring it to null. The output exposes the config as signals, so the
+ * table can also be remapped at runtime through the `shortcuts` signal
+ * (the listener is recompiled on change).
+ *
+ * Configuring an existing name always replaces its mapping outright, and a
+ * name may be mapped to an array to give it several bindings at once. The
+ * overriding names are also matched first, ahead of the names they did not
+ * override, when more than one shortcut matches the same keypress.
+ */
+export const KeyboardShortcutsExtension = /* @__PURE__ */ defineExtension({
+ build(editor, config, state) {
+ return namedSignals(config);
+ },
+ config: /* @__PURE__ */ safeCast({
+ disabled: false,
+ priority: COMMAND_PRIORITY_NORMAL,
+ shortcuts: {},
+ }),
+ mergeConfig(config, overrides) {
+ const merged = shallowMergeConfig(config, overrides);
+ merged.shortcuts = mergeNamedShortcuts(
+ config.shortcuts,
+ overrides.shortcuts,
+ );
+ return merged;
+ },
+ name: '@lexical/extension/KeyboardShortcuts',
+ register(editor, config, state) {
+ const {disabled, priority, shortcuts} = state.getOutput();
+ return effect(() => {
+ if (!disabled.value) {
+ const allShortcuts: KeyboardShortcut[] = [];
+ for (const shortcutConfig of Object.values(shortcuts.value)) {
+ for (const v of flattenKeyboardShortcuts(shortcutConfig)) {
+ allShortcuts.push(v);
+ }
+ }
+ return registerKeyboardShortcuts(editor, allShortcuts, priority.value);
+ }
+ });
+ },
+});
diff --git a/packages/lexical-extension/src/LexicalBuilder.ts b/packages/lexical-extension/src/LexicalBuilder.ts
index a04123b2538..28e39371cfe 100644
--- a/packages/lexical-extension/src/LexicalBuilder.ts
+++ b/packages/lexical-extension/src/LexicalBuilder.ts
@@ -244,6 +244,10 @@ export class LexicalBuilder {
}
}
+ /**
+ * @param configs - Ownership passes to the builder, which retains the array
+ * and may append to it. Callers must pass an array nobody else holds.
+ */
addEdge(
fromExtensionName: string,
toExtensionName: string,
@@ -251,7 +255,17 @@ export class LexicalBuilder {
) {
const outgoing = this.outgoingConfigEdges.get(fromExtensionName);
if (outgoing) {
- outgoing.set(toExtensionName, configs);
+ // An extension may reach the same dependency more than once (e.g. two
+ // configExtension entries for it, or both a direct and a peer
+ // dependency). Every config has to be kept in the order it was seen,
+ // otherwise all but the last would be silently discarded instead of
+ // merged.
+ const existing = outgoing.get(toExtensionName);
+ if (existing) {
+ existing.push(...configs);
+ } else {
+ outgoing.set(toExtensionName, configs);
+ }
} else {
this.outgoingConfigEdges.set(
fromExtensionName,
diff --git a/packages/lexical-extension/src/PreventSelectAllExtension.ts b/packages/lexical-extension/src/PreventSelectAllExtension.ts
index c593bbf5a94..b809634d7c4 100644
--- a/packages/lexical-extension/src/PreventSelectAllExtension.ts
+++ b/packages/lexical-extension/src/PreventSelectAllExtension.ts
@@ -7,8 +7,8 @@
*/
import {
+ CONTROL_OR_META,
defineExtension,
- IS_APPLE,
isExactShortcutMatch,
isHTMLElement,
registerEventListener,
@@ -22,7 +22,7 @@ import {effect} from './signals';
function captureKeydown(e: KeyboardEvent) {
const target = e.target;
if (
- isExactShortcutMatch(e, 'a', {ctrlKey: !IS_APPLE, metaKey: IS_APPLE}) &&
+ isExactShortcutMatch(e, 'a', CONTROL_OR_META) &&
isHTMLElement(target) &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
) {
diff --git a/packages/lexical-extension/src/__tests__/unit/KeyboardShortcutsExtension.test.ts b/packages/lexical-extension/src/__tests__/unit/KeyboardShortcutsExtension.test.ts
new file mode 100644
index 00000000000..ea1aeef19c8
--- /dev/null
+++ b/packages/lexical-extension/src/__tests__/unit/KeyboardShortcutsExtension.test.ts
@@ -0,0 +1,993 @@
+/**
+ * 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.
+ *
+ */
+
+import type {
+ KeyboardShortcut,
+ KeyboardShortcutMatch,
+ KeyboardShortcutsConfig,
+ NamedKeyboardShortcuts,
+} from '@lexical/extension';
+
+import {
+ buildEditorFromExtensions,
+ compileKeyboardShortcuts,
+ formatKeyboardShortcut,
+ getExtensionDependencyFromEditor,
+ KeyboardShortcutsExtension,
+ NestedEditorExtension,
+} from '@lexical/extension';
+import {
+ COMMAND_PRIORITY_BEFORE_EDITOR,
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ COMMAND_PRIORITY_NORMAL,
+ configExtension,
+ CONTROL_OR_ALT,
+ CONTROL_OR_META,
+ createCommand,
+ defineExtension,
+ isExactShortcutMatch,
+ KEY_DOWN_COMMAND,
+ type KeyboardEventModifierMask,
+ type KeyboardEventModifiers,
+ type LexicalCommand,
+ type LexicalEditor,
+ mergeRegister,
+ safeCast,
+} from 'lexical';
+import {describe, expect, test, vi} from 'vitest';
+
+function makeEvent(
+ key: string,
+ code: string,
+ bits: number,
+): KeyboardEventModifiers {
+ return {
+ altKey: Boolean(bits & 1),
+ code,
+ ctrlKey: Boolean(bits & 2),
+ key,
+ metaKey: Boolean(bits & 4),
+ shiftKey: Boolean(bits & 8),
+ };
+}
+
+function keyboardEvent(init: KeyboardEventInit): KeyboardEvent {
+ return new KeyboardEvent('keydown', {cancelable: true, ...init});
+}
+
+describe('compileKeyboardShortcuts', () => {
+ // Every (key, code) pair crossed with all 16 modifier states, covering
+ // exact key matches, case-insensitivity, and the event.code fallback for
+ // non-Latin layouts (Cyrillic letter, Arabic-Indic digit)
+ const EVENT_KEYS: [string, string][] = [
+ ['b', 'KeyB'],
+ ['B', 'KeyB'],
+ ['б', 'KeyB'],
+ ['1', 'Digit1'],
+ ['!', 'Digit1'],
+ ['١', 'Digit1'],
+ ['Enter', 'Enter'],
+ [',', 'Comma'],
+ ['[', 'BracketLeft'],
+ ['z', 'KeyZ'],
+ ];
+ const SHORTCUTS: [string, KeyboardEventModifierMask][] = [
+ ['b', {ctrlKey: true}],
+ ['B', {metaKey: true}],
+ ['1', {altKey: true, ctrlKey: true}],
+ ['Enter', {shiftKey: 'any'}],
+ [',', {ctrlKey: true}],
+ ['[', {}],
+ ['z', {ctrlKey: true, shiftKey: 'any'}],
+ ];
+
+ test('matches exactly the events that isExactShortcutMatch matches', () => {
+ for (const [key, modifiers] of SHORTCUTS) {
+ const compiled = compileKeyboardShortcuts([{key, modifiers}]);
+ for (const [eventKey, eventCode] of EVENT_KEYS) {
+ for (let bits = 0; bits < 16; bits++) {
+ const event = makeEvent(eventKey, eventCode, bits);
+ expect(
+ compiled.match(event) !== undefined,
+ `key=${key} modifiers=${JSON.stringify(
+ modifiers,
+ )} event=${JSON.stringify(event)}`,
+ ).toBe(isExactShortcutMatch(event, key, modifiers));
+ }
+ }
+ }
+ });
+
+ test('matches returns all matching shortcuts in insertion order', () => {
+ const first = {key: 'k', modifiers: {ctrlKey: true}, name: 'first'};
+ const second = {
+ key: 'K',
+ modifiers: {ctrlKey: true, shiftKey: 'any'},
+ name: 'second',
+ } as const;
+ const other = {key: 'j', modifiers: {ctrlKey: true}, name: 'other'};
+ const compiled = compileKeyboardShortcuts([first, second, other]);
+ expect(compiled.matches(makeEvent('k', 'KeyK', 2))).toEqual([
+ first,
+ second,
+ ]);
+ expect(compiled.matches(makeEvent('K', 'KeyK', 2 | 8))).toEqual([second]);
+ expect(compiled.matches(makeEvent('k', 'KeyK', 0))).toEqual([]);
+ });
+});
+
+describe('formatKeyboardShortcut', () => {
+ test('CONTROL_OR_META', () => {
+ expect(
+ formatKeyboardShortcut(
+ {key: ' ', modifiers: CONTROL_OR_META},
+ {isApple: true},
+ ),
+ ).toEqual(['\u2318', 'Space']);
+ expect(
+ formatKeyboardShortcut(
+ {key: ' ', modifiers: CONTROL_OR_META},
+ {isApple: false},
+ ),
+ ).toEqual(['Ctrl', 'Space']);
+ });
+ test('CONTROL_OR_ALT', () => {
+ expect(
+ formatKeyboardShortcut(
+ {key: ' ', modifiers: CONTROL_OR_ALT},
+ {isApple: true},
+ ),
+ ).toEqual(['\u2325', 'Space']);
+ expect(
+ formatKeyboardShortcut(
+ {key: ' ', modifiers: CONTROL_OR_ALT},
+ {isApple: false},
+ ),
+ ).toEqual(['Ctrl', 'Space']);
+ });
+ test.for(
+ safeCast<[KeyboardShortcutMatch, string, string][]>([
+ [
+ {key: 'k', modifiers: {metaKey: true, shiftKey: true}},
+ '⇧+⌘+K',
+ 'Shift+Meta+K',
+ ],
+ [
+ {key: 'q', modifiers: {ctrlKey: true, shiftKey: true}},
+ '\u2303+⇧+Q',
+ 'Ctrl+Shift+Q',
+ ],
+ [
+ {key: 'q', modifiers: {...CONTROL_OR_META, shiftKey: true}},
+ '⇧+⌘+Q',
+ 'Ctrl+Shift+Q',
+ ],
+ [
+ {key: 'Backspace', modifiers: {...CONTROL_OR_ALT}},
+ '\u2325+\u232B',
+ 'Ctrl+Backspace',
+ ],
+ [{key: ' '}, 'Space', 'Space'],
+ [{key: 'ArrowLeft', modifiers: {shiftKey: 'any'}}, '\u2190', 'ArrowLeft'],
+ ]),
+ )(
+ 'formatKeyboardShortcut(%o) -> apple: %s other: %s',
+ ([shortcut, apple, other]) => {
+ expect(
+ [true, false].map(isApple =>
+ formatKeyboardShortcut(shortcut, {isApple}).join('+'),
+ ),
+ ).toEqual([apple, other]);
+ },
+ );
+ test('formats platform conventions', () => {
+ const shortcut = {key: 'k', modifiers: {metaKey: true, shiftKey: true}};
+ expect(formatKeyboardShortcut(shortcut, {isApple: true}).join('+')).toBe(
+ '⇧+⌘+K',
+ );
+ expect(formatKeyboardShortcut(shortcut, {isApple: false}).join('+')).toBe(
+ 'Shift+Meta+K',
+ );
+ expect(
+ formatKeyboardShortcut(
+ {key: 'q', modifiers: {ctrlKey: true, shiftKey: true}},
+ {isApple: true},
+ ).join('+'),
+ ).toBe('⌃+⇧+Q');
+ expect(
+ formatKeyboardShortcut(
+ {key: '0', modifiers: {altKey: true, ctrlKey: true}},
+ {isApple: false},
+ ).join('+'),
+ ).toBe('Ctrl+Alt+0');
+ expect(formatKeyboardShortcut({key: ' '}, {isApple: false}).join('+')).toBe(
+ 'Space',
+ );
+ expect(
+ formatKeyboardShortcut(
+ {key: 'ArrowLeft', modifiers: {shiftKey: 'any'}},
+ {isApple: false},
+ ).join('+'),
+ ).toBe('ArrowLeft');
+ });
+});
+
+/**
+ * Build an editor with the given shortcuts registered plus a recording
+ * listener for each distinct command (returning `handled` for it).
+ */
+function buildTestEditor(
+ shortcuts: NamedKeyboardShortcuts,
+ listeners: [
+ LexicalCommand,
+ (event: KeyboardEvent) => boolean,
+ ][],
+) {
+ return buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [configExtension(KeyboardShortcutsExtension, {shortcuts})],
+ name: 'keyboard-shortcuts-test',
+ register: editor =>
+ mergeRegister(
+ ...listeners.map(([command, listener]) =>
+ editor.registerCommand(command, listener, COMMAND_PRIORITY_EDITOR),
+ ),
+ ),
+ }),
+ );
+}
+
+/**
+ * Build an editor whose KeyboardShortcutsExtension is configured by each of
+ * `layers` in order, so that later layers are merged over earlier ones the
+ * way an app config is merged over the extensions it depends on.
+ */
+function buildLayeredEditor(
+ layers: Partial[],
+ listeners: [
+ LexicalCommand,
+ (event: KeyboardEvent) => boolean,
+ ][],
+) {
+ return buildEditorFromExtensions(
+ defineExtension({
+ dependencies: layers.map(layer =>
+ configExtension(KeyboardShortcutsExtension, layer),
+ ),
+ name: 'layered-test',
+ register: editor =>
+ mergeRegister(
+ ...listeners.map(([command, listener]) =>
+ editor.registerCommand(command, listener, COMMAND_PRIORITY_EDITOR),
+ ),
+ ),
+ }),
+ );
+}
+
+/**
+ * A set of distinct commands whose listeners append their name to `calls` as
+ * they are dispatched, for asserting the order in which a keypress is offered
+ * to the shortcuts that match it.
+ */
+function commandRecorder() {
+ const calls: string[] = [];
+ const listeners: [
+ LexicalCommand,
+ (event: KeyboardEvent) => boolean,
+ ][] = [];
+ /** A Ctrl+K shortcut for a fresh command that records `name` when handled */
+ function ctrlKShortcut(name: string, handled = true): KeyboardShortcut {
+ const command = createCommand(`recorder/${name}`);
+ listeners.push([
+ command,
+ () => {
+ calls.push(name);
+ return handled;
+ },
+ ]);
+ return {command, key: 'k', modifiers: {ctrlKey: true}};
+ }
+ return {calls, ctrlKShortcut, listeners};
+}
+
+const ctrlK = () => keyboardEvent({ctrlKey: true, key: 'k'});
+
+describe('registerKeyboardShortcuts', () => {
+ test('dispatches the matched shortcut command with the event as payload', () => {
+ const BOLD_COMMAND = createCommand('test/BOLD');
+ const ITALIC_COMMAND = createCommand('test/ITALIC');
+ const bold = vi.fn().mockReturnValue(true);
+ const italic = vi.fn().mockReturnValue(true);
+ const editor = buildTestEditor(
+ {
+ BOLD: {command: BOLD_COMMAND, key: 'b', modifiers: {ctrlKey: true}},
+ ITALIC: {command: ITALIC_COMMAND, key: 'i', modifiers: {ctrlKey: true}},
+ },
+ [
+ [BOLD_COMMAND, bold],
+ [ITALIC_COMMAND, italic],
+ ],
+ );
+ const event = keyboardEvent({ctrlKey: true, key: 'b'});
+ expect(editor.dispatchCommand(KEY_DOWN_COMMAND, event)).toBe(true);
+ expect(bold).toHaveBeenCalledTimes(1);
+ expect(bold.mock.calls[0][0]).toBe(event);
+ expect(italic).not.toHaveBeenCalled();
+ // No modifier match -> no dispatch (the event may still be handled by
+ // the core $handleKeyDown listener at COMMAND_PRIORITY_EDITOR)
+ editor.dispatchCommand(KEY_DOWN_COMMAND, keyboardEvent({key: 'b'}));
+ expect(bold).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('falls through to the next matching shortcut when a dispatch is unhandled', () => {
+ const SKIPPED_COMMAND = createCommand('test/SKIPPED');
+ const HANDLED_COMMAND = createCommand('test/HANDLED');
+ const skipped = vi.fn().mockReturnValue(false);
+ const handled = vi.fn().mockReturnValue(true);
+ const editor = buildTestEditor(
+ {
+ SKIPPED: {
+ command: SKIPPED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ // eslint-disable-next-line sort-keys-fix/sort-keys-fix -- intentionally after SKIPPED
+ HANDLED: {
+ command: HANDLED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ },
+ [
+ [SKIPPED_COMMAND, skipped],
+ [HANDLED_COMMAND, handled],
+ ],
+ );
+ const event = keyboardEvent({ctrlKey: true, key: 'k'});
+ expect(editor.dispatchCommand(KEY_DOWN_COMMAND, event)).toBe(true);
+ expect(skipped).toHaveBeenCalledTimes(1);
+ expect(handled).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('skips shortcuts whose $disabled predicate returns true', () => {
+ const DISABLED_COMMAND = createCommand('test/DISABLED');
+ const ENABLED_COMMAND = createCommand('test/ENABLED');
+ const disabled = vi.fn().mockReturnValue(true);
+ const enabled = vi.fn().mockReturnValue(true);
+ const $disabled = vi.fn().mockReturnValue(true);
+ const editor = buildTestEditor(
+ {
+ DISABLED: {
+ $disabled,
+ command: DISABLED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ ENABLED: {
+ command: ENABLED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ },
+ [
+ [DISABLED_COMMAND, disabled],
+ [ENABLED_COMMAND, enabled],
+ ],
+ );
+ const event = keyboardEvent({ctrlKey: true, key: 'k'});
+ expect(editor.dispatchCommand(KEY_DOWN_COMMAND, event)).toBe(true);
+ expect($disabled).toHaveBeenCalledTimes(1);
+ expect(disabled).not.toHaveBeenCalled();
+ expect(enabled).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('falls through when $dispatch returns false', () => {
+ const SKIPPED_COMMAND = createCommand('test/DISPATCH_SKIP');
+ const HANDLED_COMMAND = createCommand(
+ 'test/DISPATCH_HANDLED',
+ );
+ const handled = vi.fn().mockReturnValue(true);
+ const editor = buildTestEditor(
+ {
+ SKIPPED: {
+ $dispatch: (_command, _event, _$next) => false,
+ command: SKIPPED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ // eslint-disable-next-line sort-keys-fix/sort-keys-fix -- intentionally after SKIPPED
+ HANDLED: {
+ command: HANDLED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ },
+ [[HANDLED_COMMAND, handled]],
+ );
+ const event = keyboardEvent({ctrlKey: true, key: 'k'});
+ expect(editor.dispatchCommand(KEY_DOWN_COMMAND, event)).toBe(true);
+ expect(handled).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('$dispatch middleware wraps the command dispatch', () => {
+ const WRAPPED_COMMAND = createCommand('test/WRAPPED');
+ const listener = vi.fn().mockReturnValue(true);
+ const order: string[] = [];
+ const editor = buildTestEditor(
+ {
+ WRAPPED: {
+ $dispatch: (command, event, $next, editor2) => {
+ expect(command).toBe(WRAPPED_COMMAND);
+ expect(editor2).toBe(editor);
+ order.push('before');
+ const handled = $next();
+ order.push('after');
+ return handled;
+ },
+ command: WRAPPED_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ },
+ },
+ [
+ [
+ WRAPPED_COMMAND,
+ event => {
+ order.push('listener');
+ return listener(event);
+ },
+ ],
+ ],
+ );
+ const event = keyboardEvent({ctrlKey: true, key: 'k'});
+ expect(editor.dispatchCommand(KEY_DOWN_COMMAND, event)).toBe(true);
+ expect(order).toEqual(['before', 'listener', 'after']);
+ editor.dispose();
+ });
+});
+
+describe('KeyboardShortcutsExtension', () => {
+ const shortcutWith = (
+ command: LexicalCommand,
+ key: string,
+ modifiers: KeyboardEventModifierMask,
+ ): KeyboardShortcut => ({command, key, modifiers});
+
+ function buildExtensionEditor(
+ shortcuts: NamedKeyboardShortcuts,
+ listeners: [
+ LexicalCommand,
+ (event: KeyboardEvent) => boolean,
+ ][],
+ overlay?: NamedKeyboardShortcuts,
+ ) {
+ return buildLayeredEditor(
+ overlay ? [{shortcuts}, {shortcuts: overlay}] : [{shortcuts}],
+ listeners,
+ );
+ }
+
+ test('dispatches configured shortcuts', () => {
+ const BOLD_COMMAND = createCommand('ext/BOLD');
+ const bold = vi.fn().mockReturnValue(true);
+ const editor = buildExtensionEditor(
+ {bold: shortcutWith(BOLD_COMMAND, 'b', {ctrlKey: true})},
+ [[BOLD_COMMAND, bold]],
+ );
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({ctrlKey: true, key: 'b'}),
+ );
+ expect(bold).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('overlays merge by name: add, remap, and disable', () => {
+ const BOLD_COMMAND = createCommand('overlay/BOLD');
+ const ITALIC_COMMAND = createCommand('overlay/ITALIC');
+ const CUSTOM_COMMAND = createCommand('overlay/CUSTOM');
+ const bold = vi.fn().mockReturnValue(true);
+ const italic = vi.fn().mockReturnValue(true);
+ const custom = vi.fn().mockReturnValue(true);
+ const editor = buildExtensionEditor(
+ {
+ bold: shortcutWith(BOLD_COMMAND, 'b', {ctrlKey: true}),
+ italic: shortcutWith(ITALIC_COMMAND, 'i', {ctrlKey: true}),
+ },
+ [
+ [BOLD_COMMAND, bold],
+ [ITALIC_COMMAND, italic],
+ [CUSTOM_COMMAND, custom],
+ ],
+ {
+ // remap bold to a different key
+ bold: shortcutWith(BOLD_COMMAND, 'b', {ctrlKey: true, shiftKey: true}),
+ // add a new shortcut
+ custom: shortcutWith(CUSTOM_COMMAND, 'm', {altKey: true}),
+ // disable italic
+ italic: null,
+ },
+ );
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({ctrlKey: true, key: 'b'}),
+ );
+ expect(bold).not.toHaveBeenCalled();
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({ctrlKey: true, key: 'b', shiftKey: true}),
+ );
+ expect(bold).toHaveBeenCalledTimes(1);
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({ctrlKey: true, key: 'i'}),
+ );
+ expect(italic).not.toHaveBeenCalled();
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({altKey: true, key: 'm'}),
+ );
+ expect(custom).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+
+ test('shortcuts can be remapped and disabled at runtime through the output signals', () => {
+ const BOLD_COMMAND = createCommand('runtime/BOLD');
+ const bold = vi.fn().mockReturnValue(true);
+ const editor = buildExtensionEditor(
+ {bold: shortcutWith(BOLD_COMMAND, 'b', {ctrlKey: true})},
+ [[BOLD_COMMAND, bold]],
+ );
+ const {output} = getExtensionDependencyFromEditor(
+ editor,
+ KeyboardShortcutsExtension,
+ );
+ output.shortcuts.value = {
+ ...output.shortcuts.value,
+ bold: shortcutWith(BOLD_COMMAND, 'b', {metaKey: true}),
+ };
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({ctrlKey: true, key: 'b'}),
+ );
+ expect(bold).not.toHaveBeenCalled();
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({key: 'b', metaKey: true}),
+ );
+ expect(bold).toHaveBeenCalledTimes(1);
+
+ output.disabled.value = true;
+ editor.dispatchCommand(
+ KEY_DOWN_COMMAND,
+ keyboardEvent({key: 'b', metaKey: true}),
+ );
+ expect(bold).toHaveBeenCalledTimes(1);
+ editor.dispose();
+ });
+});
+
+describe('KeyboardShortcutsExtension shortcut table merge', () => {
+ test('a later layer replaces the mapping it overrides', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('base', false)}},
+ {shortcuts: {bold: rec.ctrlKShortcut('override', false)}},
+ ],
+ rec.listeners,
+ );
+ // The override left the event unhandled, and the mapping it replaced is
+ // gone rather than being a fallback for it
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['override']);
+ editor.dispose();
+ });
+
+ test('only the last layer to configure a name survives', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('first', false)}},
+ {shortcuts: {bold: rec.ctrlKShortcut('second', false)}},
+ {shortcuts: {bold: rec.ctrlKShortcut('third', false)}},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['third']);
+ editor.dispose();
+ });
+
+ test('null disables the name', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('base', false)}},
+ {shortcuts: {bold: null}},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual([]);
+ editor.dispose();
+ });
+
+ test('an array replaces the mapping with several bindings', () => {
+ const rec = commandRecorder();
+ const replaced = rec.ctrlKShortcut('replaced', false);
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('base', false)}},
+ {shortcuts: {bold: [replaced]}},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['replaced']);
+ editor.dispose();
+ });
+
+ test('an empty array disables the name like null does', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('base', false)}},
+ {shortcuts: {bold: []}},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual([]);
+ editor.dispose();
+ });
+
+ test('a name mapped to an array registers every entry in order', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {
+ shortcuts: {
+ bold: [
+ rec.ctrlKShortcut('first', false),
+ rec.ctrlKShortcut('second', false),
+ ],
+ },
+ },
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['first', 'second']);
+ editor.dispose();
+ });
+
+ test('names added by a later layer are matched before existing names', () => {
+ const rec = commandRecorder();
+ // 'zzz' sorts after 'aaa', so only the layer order can put it first
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {aaa: rec.ctrlKShortcut('aaa', false)}},
+ {shortcuts: {zzz: rec.ctrlKShortcut('zzz', false)}},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['zzz', 'aaa']);
+ editor.dispose();
+ });
+
+ test('a layer that configures no shortcuts leaves the table alone', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {shortcuts: {bold: rec.ctrlKShortcut('base')}},
+ {priority: COMMAND_PRIORITY_CRITICAL},
+ ],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['base']);
+ editor.dispose();
+ });
+
+ test('arrays assigned to the runtime signal are flattened too', () => {
+ const rec = commandRecorder();
+ // All commands must exist before the editor is built so their listeners
+ // are registered, even the ones only used after the signal is assigned
+ const base = rec.ctrlKShortcut('base', false);
+ const runtimeFirst = rec.ctrlKShortcut('runtimeFirst', false);
+ const runtimeSecond = rec.ctrlKShortcut('runtimeSecond', false);
+ const editor = buildLayeredEditor(
+ [{shortcuts: {bold: base}}],
+ rec.listeners,
+ );
+ const {output} = getExtensionDependencyFromEditor(
+ editor,
+ KeyboardShortcutsExtension,
+ );
+ output.shortcuts.value = {bold: [runtimeFirst, runtimeSecond]};
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['runtimeFirst', 'runtimeSecond']);
+ editor.dispose();
+ });
+});
+
+describe('KeyboardShortcutsExtension priority', () => {
+ test('defaults to COMMAND_PRIORITY_NORMAL, ahead of $handleKeyDown', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [{shortcuts: {bold: rec.ctrlKShortcut('bold')}}],
+ rec.listeners,
+ );
+ const {output} = getExtensionDependencyFromEditor(
+ editor,
+ KeyboardShortcutsExtension,
+ );
+ expect(output.priority.value).toBe(COMMAND_PRIORITY_NORMAL);
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['bold']);
+
+ // Every editor registers the core $handleKeyDown at
+ // COMMAND_PRIORITY_EDITOR and it always reports the event as handled, so
+ // a shortcut listener at that priority is never reached. (BEFORE_EDITOR
+ // is early enough for a lone editor, but not for nested ones — see
+ // 'bubbling requires a priority above COMMAND_PRIORITY_EDITOR'.)
+ rec.calls.length = 0;
+ output.priority.value = COMMAND_PRIORITY_EDITOR;
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual([]);
+ editor.dispose();
+ });
+
+ test('a configured priority is used for the KEY_DOWN_COMMAND listener', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [
+ {
+ priority: COMMAND_PRIORITY_LOW,
+ shortcuts: {bold: rec.ctrlKShortcut('bold')},
+ },
+ ],
+ rec.listeners,
+ );
+ const keyDown = editor.registerCommand(
+ KEY_DOWN_COMMAND,
+ () => {
+ rec.calls.push('high');
+ return true;
+ },
+ COMMAND_PRIORITY_HIGH,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['high']);
+
+ const {output} = getExtensionDependencyFromEditor(
+ editor,
+ KeyboardShortcutsExtension,
+ );
+ rec.calls.length = 0;
+ output.priority.value = COMMAND_PRIORITY_CRITICAL;
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual(['bold']);
+
+ keyDown();
+ editor.dispose();
+ });
+
+ test('the last layer to configure a priority wins', () => {
+ const editor = buildLayeredEditor(
+ [{priority: COMMAND_PRIORITY_LOW}, {priority: COMMAND_PRIORITY_CRITICAL}],
+ [],
+ );
+ const {output} = getExtensionDependencyFromEditor(
+ editor,
+ KeyboardShortcutsExtension,
+ );
+ expect(output.priority.value).toBe(COMMAND_PRIORITY_CRITICAL);
+ editor.dispose();
+ });
+
+ test('disabled: true never registers the listener', () => {
+ const rec = commandRecorder();
+ const editor = buildLayeredEditor(
+ [{disabled: true, shortcuts: {bold: rec.ctrlKShortcut('bold')}}],
+ rec.listeners,
+ );
+ editor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(rec.calls).toEqual([]);
+ editor.dispose();
+ });
+});
+
+describe('KeyboardShortcutsExtension nested editors', () => {
+ const BOLD_COMMAND = createCommand('nested/BOLD');
+
+ /**
+ * A parent editor holding the shortcut table, and a nested editor with no
+ * shortcuts of its own so that its unhandled KEY_DOWN_COMMAND delegates to
+ * the parent. Both record BOLD_COMMAND dispatches, tagged with the editor
+ * they were dispatched on.
+ */
+ function buildNestedEditors(
+ shortcuts: NamedKeyboardShortcuts,
+ priority?: KeyboardShortcutsConfig['priority'],
+ ) {
+ const calls: string[] = [];
+ const registerRecorder = (editorName: string) => (editor: LexicalEditor) =>
+ editor.registerCommand(
+ BOLD_COMMAND,
+ () => {
+ calls.push(`bold@${editorName}`);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ );
+ const parentEditor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [
+ configExtension(KeyboardShortcutsExtension, {
+ ...(priority === undefined ? undefined : {priority}),
+ shortcuts,
+ }),
+ ],
+ name: 'parent',
+ register: registerRecorder('parent'),
+ }),
+ );
+ const childEditor = parentEditor.read(() =>
+ buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [NestedEditorExtension],
+ name: 'child',
+ register: registerRecorder('child'),
+ }),
+ ),
+ );
+ const dispose = () => {
+ childEditor.dispose();
+ parentEditor.dispose();
+ };
+ return {calls, childEditor, dispose, parentEditor};
+ }
+
+ const boldShortcut = (
+ overrides: Partial = {},
+ ): KeyboardShortcut => ({
+ command: BOLD_COMMAND,
+ key: 'k',
+ modifiers: {ctrlKey: true},
+ ...overrides,
+ });
+
+ test('events bubbled up from a nested editor are ignored by default', () => {
+ const {calls, childEditor, dispose} = buildNestedEditors({
+ bold: boldShortcut(),
+ });
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls).toEqual([]);
+ dispose();
+ });
+
+ test('events from the registering editor are dispatched without the flag', () => {
+ const {calls, dispose, parentEditor} = buildNestedEditors({
+ bold: boldShortcut(),
+ });
+ parentEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls).toEqual(['bold@parent']);
+ dispose();
+ });
+
+ test('bubbleFromNestedEditors dispatches on the originating editor', () => {
+ const {calls, childEditor, dispose} = buildNestedEditors({
+ bold: boldShortcut({bubbleFromNestedEditors: true}),
+ });
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ // Dispatched on the child, not the editor the shortcut is registered on
+ expect(calls).toEqual(['bold@child']);
+ dispose();
+ });
+
+ test('a non-bubbling shortcut does not shadow a bubbling one on the same key', () => {
+ const IGNORED_COMMAND = createCommand('nested/IGNORED');
+ const ignored = vi.fn().mockReturnValue(true);
+ const {calls, childEditor, dispose, parentEditor} = buildNestedEditors({
+ ignored: {command: IGNORED_COMMAND, key: 'k', modifiers: {ctrlKey: true}},
+ // eslint-disable-next-line sort-keys-fix/sort-keys-fix -- intentionally after ignored
+ bold: boldShortcut({bubbleFromNestedEditors: true}),
+ });
+ const cleanup = parentEditor.registerCommand(
+ IGNORED_COMMAND,
+ ignored,
+ COMMAND_PRIORITY_EDITOR,
+ );
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(ignored).not.toHaveBeenCalled();
+ expect(calls).toEqual(['bold@child']);
+ cleanup();
+ dispose();
+ });
+
+ test('$disabled and $dispatch receive the originating editor', () => {
+ const $disabled = vi.fn().mockReturnValue(false);
+ const $dispatch = vi.fn(
+ (
+ _command: LexicalCommand,
+ _event: KeyboardEvent,
+ $next: () => boolean,
+ _editor: LexicalEditor,
+ ) => $next(),
+ );
+ const {calls, childEditor, dispose} = buildNestedEditors({
+ bold: boldShortcut({$disabled, $dispatch, bubbleFromNestedEditors: true}),
+ });
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls).toEqual(['bold@child']);
+ expect($disabled).toHaveBeenCalledTimes(1);
+ expect($disabled.mock.calls[0][1]).toBe(childEditor);
+ expect($dispatch).toHaveBeenCalledTimes(1);
+ expect($dispatch.mock.calls[0][3]).toBe(childEditor);
+ dispose();
+ });
+
+ test('$disabled is not consulted for events that will not bubble', () => {
+ const $disabled = vi.fn().mockReturnValue(false);
+ const {calls, childEditor, dispose} = buildNestedEditors({
+ bold: boldShortcut({$disabled}),
+ });
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls).toEqual([]);
+ expect($disabled).not.toHaveBeenCalled();
+ dispose();
+ });
+
+ test('bubbling requires a priority above COMMAND_PRIORITY_EDITOR', () => {
+ // Why the default is COMMAND_PRIORITY_NORMAL rather than
+ // COMMAND_PRIORITY_BEFORE_EDITOR: command dispatch walks priorities from
+ // CRITICAL down to EDITOR on the outside and the nested editor chain on
+ // the inside, and every editor registers the core $handleKeyDown at
+ // COMMAND_PRIORITY_EDITOR, which always reports the event as handled. So
+ // a nested editor's own $handleKeyDown ends the dispatch before anything
+ // the parent has in the editor-priority queue, and BEFORE_EDITOR is the
+ // front of exactly that queue.
+ const bubbling = {bold: boldShortcut({bubbleFromNestedEditors: true})};
+ for (const priority of [
+ COMMAND_PRIORITY_LOW,
+ COMMAND_PRIORITY_NORMAL,
+ COMMAND_PRIORITY_CRITICAL,
+ ] as const) {
+ const {calls, childEditor, dispose} = buildNestedEditors(
+ bubbling,
+ priority,
+ );
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls, `priority ${priority}`).toEqual(['bold@child']);
+ dispose();
+ }
+ for (const priority of [
+ COMMAND_PRIORITY_BEFORE_EDITOR,
+ COMMAND_PRIORITY_EDITOR,
+ ] as const) {
+ const {calls, childEditor, dispose} = buildNestedEditors(
+ bubbling,
+ priority,
+ );
+ childEditor.dispatchCommand(KEY_DOWN_COMMAND, ctrlK());
+ expect(calls, `priority ${priority}`).toEqual([]);
+ dispose();
+ }
+ });
+});
diff --git a/packages/lexical-extension/src/__tests__/unit/LexicalBuilder.test.ts b/packages/lexical-extension/src/__tests__/unit/LexicalBuilder.test.ts
index 5876beececd..c0399f6cb1e 100644
--- a/packages/lexical-extension/src/__tests__/unit/LexicalBuilder.test.ts
+++ b/packages/lexical-extension/src/__tests__/unit/LexicalBuilder.test.ts
@@ -6,6 +6,8 @@
*
*/
+import type {AnyLexicalExtensionArgument} from '@lexical/extension';
+
import {
buildEditorFromExtensions,
configExtension,
@@ -91,6 +93,61 @@ describe('LexicalBuilder', () => {
expect(rep.extension).toBe(ConfigExtension);
expect(rep.getState().config).toEqual({a: 1, b: null});
});
+ describe('a dependency configured more than once by one extension', () => {
+ const PairExtension = defineExtension({
+ config: safeCast<{first: string; second: string}>({
+ first: 'default',
+ second: 'default',
+ }),
+ name: 'Pair',
+ });
+ const configOf = (...dependencies: AnyLexicalExtensionArgument[]) => {
+ const builder = LexicalBuilder.fromEditor(
+ buildEditorFromExtensions(
+ defineExtension({dependencies, name: 'root'}),
+ ),
+ );
+ const rep = builder
+ .sortedExtensionReps()
+ .find(r => r.extension === PairExtension);
+ assert(rep, 'PairExtension not found');
+ return rep.getState().config;
+ };
+
+ it('applies every config rather than only the last', () => {
+ expect(
+ configOf(
+ configExtension(PairExtension, {first: 'one'}),
+ configExtension(PairExtension, {second: 'two'}),
+ ),
+ ).toEqual({first: 'one', second: 'two'});
+ });
+
+ it('applies them in order, so the last one wins a conflict', () => {
+ expect(
+ configOf(
+ configExtension(PairExtension, {first: 'one'}),
+ configExtension(PairExtension, {first: 'two'}),
+ ),
+ ).toEqual({first: 'two', second: 'default'});
+ });
+
+ it('keeps a peer dependency config alongside a direct one', () => {
+ const PeerExtension = defineExtension({
+ name: 'Peer',
+ peerDependencies: [
+ declarePeerDependency('Pair', {second: 'peer'}),
+ ],
+ });
+ expect(
+ configOf(
+ configExtension(PairExtension, {first: 'direct'}),
+ PeerExtension,
+ ),
+ ).toEqual({first: 'direct', second: 'peer'});
+ });
+ });
+
it('handles circular dependencies', () => {
const ExtensionA = defineExtension({dependencies: [], name: 'A'});
const ExtensionB = defineExtension({dependencies: [ExtensionA], name: 'B'});
diff --git a/packages/lexical-extension/src/index.ts b/packages/lexical-extension/src/index.ts
index 1725a258ad5..8a6e7b2ecb2 100644
--- a/packages/lexical-extension/src/index.ts
+++ b/packages/lexical-extension/src/index.ts
@@ -52,6 +52,13 @@ export {
type InitialStateConfig,
InitialStateExtension,
} from './InitialStateExtension';
+export {
+ formatKeyboardShortcut,
+ type FormatKeyboardShortcutOptions,
+ type KeyboardShortcutsConfig,
+ KeyboardShortcutsExtension,
+ type NamedKeyboardShortcuts,
+} from './KeyboardShortcutsExtension';
export {buildEditorFromExtensions, LexicalBuilder} from './LexicalBuilder';
export {
namedSignals,
@@ -104,12 +111,18 @@ export {watchedSignal} from './watchedSignal';
export {
type AnyLexicalExtension,
type AnyLexicalExtensionArgument,
+ type CompiledKeyboardShortcuts,
+ compileKeyboardShortcuts,
configExtension,
+ CONTROL_OR_ALT,
+ CONTROL_OR_META,
declarePeerDependency,
defineExtension,
type ExtensionConfigBase,
type ExtensionRegisterState,
type InitialEditorStateType,
+ type KeyboardShortcut,
+ type KeyboardShortcutMatch,
type LexicalEditorWithDispose,
type LexicalExtension,
type LexicalExtensionArgument,
diff --git a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
index 1025bb113af..5ee1363540d 100644
--- a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
+++ b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs
@@ -307,9 +307,7 @@ export async function moveToStart(page) {
await page.keyboard.press('ArrowLeft');
await page.keyboard.up('Meta');
} else {
- await page.keyboard.down('Control');
- await page.keyboard.press('ArrowLeft');
- await page.keyboard.up('Control');
+ await page.keyboard.press('Home');
}
}
@@ -319,9 +317,7 @@ export async function moveToEnd(page) {
await page.keyboard.press('ArrowRight');
await page.keyboard.up('Meta');
} else {
- await page.keyboard.down('Control');
- await page.keyboard.press('ArrowRight');
- await page.keyboard.up('Control');
+ await page.keyboard.press('End');
}
}
diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx
index 8a904dd42f0..1dd20e4d2ea 100644
--- a/packages/lexical-playground/src/App.tsx
+++ b/packages/lexical-playground/src/App.tsx
@@ -103,6 +103,7 @@ import {PollExtension} from './plugins/PollExtension';
import {PullQuoteExtension} from './plugins/PullQuoteExtension';
import {ReactReviewExtension} from './plugins/ReviewExtension';
import {RubyExtension} from './plugins/RubyExtension';
+import {ShortcutsExtension} from './plugins/ShortcutsExtension';
import {SpecialTextExtension} from './plugins/SpecialTextExtension';
import {TabFocusExtension} from './plugins/TabFocusExtension';
import {TerseExportExtension} from './plugins/TerseExportExtension';
@@ -249,6 +250,7 @@ const PlaygroundRichTextExtension = /* @__PURE__ */ defineExtension({
ReactFindReplaceExtension,
PullQuoteExtension,
RubyExtension,
+ ShortcutsExtension,
/* @__PURE__ */ configExtension(TabIndentationExtension, {maxIndent: 7}),
],
name: '@lexical/playground/RichText',
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index d3faa8f8a05..867a7c9e26d 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -6,10 +6,12 @@
*
*/
+import {getExtensionDependencyFromEditor, signal} from '@lexical/extension';
import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {useSignalValue} from '@lexical/react/useExtensionSignalValue';
import {CAN_USE_DOM, registerEventListener} from 'lexical';
-import {type JSX, useEffect, useState} from 'react';
+import {type JSX, useCallback, useEffect, useMemo, useState} from 'react';
import {createWebsocketProvider} from './collaboration';
import {useSettings} from './context/SettingsContext';
@@ -27,7 +29,7 @@ import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin';
import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbarPlugin';
import {MentionsPlugin} from './plugins/MentionsExtension';
import FloatingRubyEditorPlugin from './plugins/RubyExtension/FloatingRubyEditor';
-import ShortcutsPlugin from './plugins/ShortcutsPlugin';
+import {ShortcutsExtension} from './plugins/ShortcutsExtension';
import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import TableCellResizer from './plugins/TableCellResizer';
@@ -69,7 +71,29 @@ export default function Editor(): JSX.Element {
useState(false);
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
- const [isLinkEditMode, setIsLinkEditMode] = useState(false);
+ // The link edit mode state lives on the ShortcutsExtension output so the
+ // insert-link keyboard shortcut can set it without any React coupling
+ // (the extension is only present in rich text configurations)
+ const isLinkEditModeSignal = useMemo(
+ () =>
+ isRichText
+ ? getExtensionDependencyFromEditor(editor, ShortcutsExtension).output
+ .isLinkEditMode
+ : signal(false),
+ [editor, isRichText],
+ );
+ const isLinkEditMode = useSignalValue(isLinkEditModeSignal);
+ const setIsLinkEditMode = useCallback(
+ (nextIsLinkEditMode: boolean) => {
+ if (isRichText) {
+ getExtensionDependencyFromEditor(
+ editor,
+ ShortcutsExtension,
+ ).output.isLinkEditMode.value = nextIsLinkEditMode;
+ }
+ },
+ [editor, isRichText],
+ );
const [isRubyEditMode, setIsRubyEditMode] = useState(false);
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
@@ -102,12 +126,6 @@ export default function Editor(): JSX.Element {
setIsRubyEditMode={setIsRubyEditMode}
/>
)}
- {isRichText && (
-
- )}
diff --git a/packages/lexical-playground/src/images/icons/keyboard.svg b/packages/lexical-playground/src/images/icons/keyboard.svg
new file mode 100644
index 00000000000..d43a12bb599
--- /dev/null
+++ b/packages/lexical-playground/src/images/icons/keyboard.svg
@@ -0,0 +1 @@
+
diff --git a/packages/lexical-playground/src/images/icons/windows.svg b/packages/lexical-playground/src/images/icons/windows.svg
new file mode 100644
index 00000000000..fe9cfdbf5c7
--- /dev/null
+++ b/packages/lexical-playground/src/images/icons/windows.svg
@@ -0,0 +1 @@
+
diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css
index c5ce5cb3a72..cbd42c336dd 100644
--- a/packages/lexical-playground/src/index.css
+++ b/packages/lexical-playground/src/index.css
@@ -682,6 +682,11 @@ i.star {
mask-image: url(images/icons/star.svg);
}
+i.keyboard-shortcuts {
+ -webkit-mask-image: url(images/icons/keyboard.svg);
+ mask-image: url(images/icons/keyboard.svg);
+}
+
i.mic {
-webkit-mask-image: url(images/icons/mic.svg);
mask-image: url(images/icons/mic.svg);
diff --git a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx
index 7878348d580..d49872a47b6 100644
--- a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx
@@ -58,6 +58,7 @@ import {docFromHash, docToHash} from '../../utils/docSerialization';
import {formatCodeWithPrettier} from '../CodeActionMenuPlugin/formatCodeWithPrettier';
import {PLAYGROUND_TRANSFORMERS} from '../MarkdownTransformers';
import {PagesExtension} from '../PagesExtension';
+import ShortcutsHelpDialog from '../ShortcutsExtension/ShortcutsHelpDialog';
import {
SPEECH_TO_TEXT_COMMAND,
SUPPORT_SPEECH_RECOGNITION,
@@ -285,6 +286,16 @@ export default function ActionsPlugin({
return (
+
+
{SUPPORT_SPEECH_RECOGNITION && (
@@ -186,7 +185,7 @@ export default function FontSize({
}}
className="toolbar-item font-increment"
aria-label="Increase font size"
- title={`Increase font size (${SHORTCUTS.INCREASE_FONT_SIZE})`}>
+ title={`Increase font size (${shortcut('INCREASE_FONT_SIZE')})`}>
>
diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
index fd53820d5c6..5d8d3584506 100644
--- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx
@@ -96,8 +96,7 @@ import {INSERT_PAGE_BREAK} from '../PageBreakExtension';
import {PagesReactExtension} from '../PagesReactExtension';
import {InsertPollDialog} from '../PollExtension';
import {$isRubyNode, $toggleRuby} from '../RubyExtension/RubyNode';
-import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts';
-import ShortcutsHelpDialog from '../ShortcutsPlugin/ShortcutsHelpDialog';
+import {shortcut} from '../ShortcutsExtension/shortcuts';
import {InsertTableDialog} from '../TablePlugin';
import FontSize, {parseFontSizeForToolbar} from './fontSize';
import {
@@ -288,7 +287,7 @@ function BlockFormatDropDown({
Normal
-
{SHORTCUTS.NORMAL}
+
{shortcut('NORMAL')}
Heading 1
- {SHORTCUTS.HEADING1}
+ {shortcut('HEADING1')}
Heading 2
- {SHORTCUTS.HEADING2}
+ {shortcut('HEADING2')}
Heading 3
- {SHORTCUTS.HEADING3}
+ {shortcut('HEADING3')}
Numbered List
- {SHORTCUTS.NUMBERED_LIST}
+ {shortcut('NUMBERED_LIST')}
Bullet List
- {SHORTCUTS.BULLET_LIST}
+ {shortcut('BULLET_LIST')}
Check List
- {SHORTCUTS.CHECK_LIST}
+ {shortcut('CHECK_LIST')}
Quote
- {SHORTCUTS.QUOTE}
+ {shortcut('QUOTE')}
Code Block
- {SHORTCUTS.CODE_BLOCK}
+ {shortcut('CODE_BLOCK')}
);
@@ -457,7 +456,7 @@ function ElementFormatDropdown({
Left Align
- {SHORTCUTS.LEFT_ALIGN}
+ {shortcut('LEFT_ALIGN')}
{
@@ -468,7 +467,7 @@ function ElementFormatDropdown({
Center Align
- {SHORTCUTS.CENTER_ALIGN}
+ {shortcut('CENTER_ALIGN')}
{
@@ -479,7 +478,7 @@ function ElementFormatDropdown({
Right Align
- {SHORTCUTS.RIGHT_ALIGN}
+ {shortcut('RIGHT_ALIGN')}
{
@@ -490,7 +489,7 @@ function ElementFormatDropdown({
Justify Align
- {SHORTCUTS.JUSTIFY_ALIGN}
+ {shortcut('JUSTIFY_ALIGN')}
{
@@ -530,7 +529,7 @@ function ElementFormatDropdown({
Outdent
- {SHORTCUTS.OUTDENT}
+ {shortcut('OUTDENT')}
{
@@ -541,7 +540,7 @@ function ElementFormatDropdown({
Indent
- {SHORTCUTS.INDENT}
+ {shortcut('INDENT')}
);
@@ -685,10 +684,7 @@ export default function ToolbarPlugin({
if (elementDOM !== null) {
setSelectedElementKey(elementKey);
if ($isListNode(element)) {
- const parentList = $getNearestNodeOfType(
- anchorNode,
- ListNode,
- );
+ const parentList = $getNearestNodeOfType(anchorNode, ListNode);
const type = parentList
? parentList.getListType()
: element.getListType();
@@ -764,10 +760,7 @@ export default function ToolbarPlugin({
if ($isNodeSelection(selection)) {
const nodes = selection.getNodes();
for (const selectedNode of nodes) {
- const parentList = $getNearestNodeOfType(
- selectedNode,
- ListNode,
- );
+ const parentList = $getNearestNodeOfType(selectedNode, ListNode);
if (parentList) {
const type = parentList.getListType();
updateToolbarState('blockType', type);
@@ -1113,9 +1106,9 @@ export default function ToolbarPlugin({
className={
'toolbar-item spaced ' + (toolbarState.isBold ? 'active' : '')
}
- title={`Bold (${SHORTCUTS.BOLD})`}
+ title={`Bold (${shortcut('BOLD')})`}
type="button"
- aria-label={`Format text as bold. Shortcut: ${SHORTCUTS.BOLD}`}>
+ aria-label={`Format text as bold. Shortcut: ${shortcut('BOLD')}`}>
+ aria-label={`Format text as italics. Shortcut: ${shortcut('ITALIC')}`}>
+ aria-label={`Format text to underlined. Shortcut: ${shortcut('UNDERLINE')}`}>
{canViewerSeeInsertCodeButton && (
@@ -1154,7 +1147,7 @@ export default function ToolbarPlugin({
className={
'toolbar-item spaced ' + (toolbarState.isCode ? 'active' : '')
}
- title={`Insert code block (${SHORTCUTS.INSERT_CODE_BLOCK})`}
+ title={`Insert code block (${shortcut('INSERT_CODE_BLOCK')})`}
type="button"
aria-label="Insert code block">
@@ -1167,7 +1160,7 @@ export default function ToolbarPlugin({
'toolbar-item spaced ' + (toolbarState.isLink ? 'active' : '')
}
aria-label="Insert link"
- title={`Insert link (${SHORTCUTS.INSERT_LINK})`}
+ title={`Insert link (${shortcut('INSERT_LINK')})`}
type="button">
@@ -1217,7 +1210,7 @@ export default function ToolbarPlugin({
Lowercase
- {SHORTCUTS.LOWERCASE}
+ {shortcut('LOWERCASE')}
@@ -1232,7 +1225,7 @@ export default function ToolbarPlugin({
Uppercase
- {SHORTCUTS.UPPERCASE}
+ {shortcut('UPPERCASE')}
@@ -1247,7 +1240,7 @@ export default function ToolbarPlugin({
Capitalize
- {SHORTCUTS.CAPITALIZE}
+ {shortcut('CAPITALIZE')}
@@ -1262,7 +1255,7 @@ export default function ToolbarPlugin({
Strikethrough
- {SHORTCUTS.STRIKETHROUGH}
+ {shortcut('STRIKETHROUGH')}
@@ -1277,7 +1270,7 @@ export default function ToolbarPlugin({
Subscript
- {SHORTCUTS.SUBSCRIPT}
+ {shortcut('SUBSCRIPT')}
@@ -1292,7 +1285,7 @@ export default function ToolbarPlugin({
Superscript
- {SHORTCUTS.SUPERSCRIPT}
+ {shortcut('SUPERSCRIPT')}
@@ -1317,7 +1310,7 @@ export default function ToolbarPlugin({
Clear Formatting
- {SHORTCUTS.CLEAR_FORMATTING}
+ {shortcut('CLEAR_FORMATTING')}
@@ -1486,17 +1479,6 @@ export default function ToolbarPlugin({
editor={activeEditor}
isRTL={toolbarState.isRTL}
/>
-
-
- showModal('Keyboard shortcuts', () => )
- }>
- ?
-
{modal}
diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts
index fa8b84d8030..237daacc982 100644
--- a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts
+++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts
@@ -30,6 +30,7 @@ import {
$createParagraphNode,
$createRangeSelection,
$findMatchingParent,
+ $getEditor,
$getSelection,
$isBlockElementNode,
$isLineBreakNode,
@@ -128,8 +129,7 @@ export const calculateNextFontSize = (
/**
* Patches the selection with the updated font size.
*/
-export const updateFontSizeInSelection = (
- editor: LexicalEditor,
+export const $updateFontSizeInSelection = (
newFontSize: string | null,
updateType: UpdateFontSizeType | null,
skipRefocus: boolean,
@@ -146,46 +146,76 @@ export const updateFontSizeInSelection = (
return `${nextFontSize}px`;
};
- editor.update(() => {
- if (skipRefocus) {
- $addUpdateTag(SKIP_DOM_SELECTION_TAG);
- }
- if (editor.isEditable()) {
- const selection = $getSelection();
- if (selection !== null) {
- $patchStyleText(selection, {
- 'font-size': newFontSize || getNextFontSize,
- });
- }
+ if (skipRefocus) {
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
+ }
+ const editor = $getEditor();
+ if (editor.isEditable()) {
+ const selection = $getSelection();
+ if (selection !== null) {
+ $patchStyleText(selection, {
+ 'font-size': newFontSize || getNextFontSize,
+ });
}
- });
+ }
};
-export const updateFontSize = (
+export const updateFontSizeInSelection = (
editor: LexicalEditor,
+ newFontSize: string | null,
+ updateType: UpdateFontSizeType | null,
+ skipRefocus: boolean,
+) => {
+ editor.update(() =>
+ $updateFontSizeInSelection(newFontSize, updateType, skipRefocus),
+ );
+};
+
+export const $updateFontSize = (
updateType: UpdateFontSizeType,
inputValue: string,
skipRefocus: boolean = false,
) => {
if (inputValue !== '') {
const nextFontSize = calculateNextFontSize(Number(inputValue), updateType);
- updateFontSizeInSelection(
- editor,
- String(nextFontSize) + 'px',
- null,
- skipRefocus,
- );
+ $updateFontSizeInSelection(String(nextFontSize) + 'px', null, skipRefocus);
} else {
- updateFontSizeInSelection(editor, null, updateType, skipRefocus);
+ $updateFontSizeInSelection(null, updateType, skipRefocus);
}
};
+export const updateFontSize = (
+ editor: LexicalEditor,
+ updateType: UpdateFontSizeType,
+ inputValue: string,
+ skipRefocus: boolean = false,
+) => {
+ editor.update(() => $updateFontSize(updateType, inputValue, skipRefocus));
+};
+
+export const $formatParagraph = () => {
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ const selection = $getSelection();
+ $setBlocksType(selection, () => $createParagraphNode());
+};
+
export const formatParagraph = (editor: LexicalEditor) => {
- editor.update(() => {
+ editor.update(() => $formatParagraph());
+};
+
+export const $formatHeading = (
+ blockType: string,
+ headingSize: HeadingTagType,
+) => {
+ if (blockType !== headingSize) {
$addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
const selection = $getSelection();
- $setBlocksType(selection, () => $createParagraphNode());
- });
+ $setBlocksType(selection, () => $createHeadingNode(headingSize));
+ const updatedSelection = $getSelection();
+ if (updatedSelection) {
+ $patchStyleText(updatedSelection, {'font-size': null});
+ }
+ }
};
export const formatHeading = (
@@ -193,38 +223,41 @@ export const formatHeading = (
blockType: string,
headingSize: HeadingTagType,
) => {
- if (blockType !== headingSize) {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- const selection = $getSelection();
- $setBlocksType(selection, () => $createHeadingNode(headingSize));
- const updatedSelection = $getSelection();
- if (updatedSelection) {
- $patchStyleText(updatedSelection, {'font-size': null});
- }
- });
+ editor.update(() => $formatHeading(blockType, headingSize));
+};
+
+export const $formatBulletList = (blockType: string) => {
+ if (blockType !== 'bullet') {
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ $getEditor().dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
+ } else {
+ $formatParagraph();
}
};
export const formatBulletList = (editor: LexicalEditor, blockType: string) => {
- if (blockType !== 'bullet') {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND);
- });
+ editor.update(() => $formatBulletList(blockType));
+};
+
+export const $formatCheckList = (blockType: string) => {
+ if (blockType !== 'check') {
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ $getEditor().dispatchCommand(INSERT_CHECK_LIST_COMMAND);
} else {
- formatParagraph(editor);
+ $formatParagraph();
}
};
export const formatCheckList = (editor: LexicalEditor, blockType: string) => {
- if (blockType !== 'check') {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND);
- });
+ editor.update(() => $formatCheckList(blockType));
+};
+
+export const $formatNumberedList = (blockType: string) => {
+ if (blockType !== 'number') {
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ $getEditor().dispatchCommand(INSERT_ORDERED_LIST_COMMAND);
} else {
- formatParagraph(editor);
+ $formatParagraph();
}
};
@@ -232,26 +265,21 @@ export const formatNumberedList = (
editor: LexicalEditor,
blockType: string,
) => {
- if (blockType !== 'number') {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND);
- });
- } else {
- formatParagraph(editor);
- }
+ editor.update(() => $formatNumberedList(blockType));
};
-export const formatQuote = (editor: LexicalEditor, blockType: string) => {
+export const $formatQuote = (blockType: string) => {
if (blockType !== 'quote') {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- const selection = $getSelection();
- $setBlocksType(selection, () => $createQuoteNode());
- });
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ const selection = $getSelection();
+ $setBlocksType(selection, () => $createQuoteNode());
}
};
+export const formatQuote = (editor: LexicalEditor, blockType: string) => {
+ editor.update(() => $formatQuote(blockType));
+};
+
function $splitBlocksByLineBreaks(selection: RangeSelection): void {
const blocks: Set = new Set();
for (const node of selection.getNodes()) {
@@ -304,43 +332,45 @@ function $findBlockAncestor(node: LexicalNode): ElementNode | null {
return $findMatchingParent(node, $isBlockElementNode);
}
-export const formatCode = (editor: LexicalEditor, blockType: string) => {
+export const $formatCode = (blockType: string) => {
if (blockType !== 'code') {
- editor.update(() => {
- $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
- let selection = $getSelection();
- if (!selection) {
+ $addUpdateTag(SKIP_SELECTION_FOCUS_TAG);
+ let selection = $getSelection();
+ if (!selection) {
+ return;
+ }
+ if (!$isRangeSelection(selection) || selection.isCollapsed()) {
+ $setBlocksType(selection, () => $createCodeNode());
+ } else {
+ // Each LineBreakNode becomes a block boundary so insertNodes below
+ // can place the code block on its own row, not between
s.
+ $splitBlocksByLineBreaks(selection);
+ selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
return;
}
- if (!$isRangeSelection(selection) || selection.isCollapsed()) {
- $setBlocksType(selection, () => $createCodeNode());
- } else {
- // Each LineBreakNode becomes a block boundary so insertNodes below
- // can place the code block on its own row, not between
s.
- $splitBlocksByLineBreaks(selection);
- selection = $getSelection();
- if (!$isRangeSelection(selection)) {
- return;
- }
- const textContent = selection.getTextContent();
- // The trailing paragraph absorbs the post-insertion merge that
- // would otherwise fold the original block's trailing content into
- // the code node.
- const codeNode = $createCodeNode();
- const trailingParagraph = $createParagraphNode();
- selection.insertNodes([codeNode, trailingParagraph]);
- // insertNodes leaves the cursor in the trailing paragraph.
- selection = codeNode.select();
- selection.insertRawText(textContent);
- // Drop the trailing paragraph if the merge step didn't fill it.
- if (trailingParagraph.isAttached() && trailingParagraph.isEmpty()) {
- trailingParagraph.remove();
- }
+ const textContent = selection.getTextContent();
+ // The trailing paragraph absorbs the post-insertion merge that
+ // would otherwise fold the original block's trailing content into
+ // the code node.
+ const codeNode = $createCodeNode();
+ const trailingParagraph = $createParagraphNode();
+ selection.insertNodes([codeNode, trailingParagraph]);
+ // insertNodes leaves the cursor in the trailing paragraph.
+ selection = codeNode.select();
+ selection.insertRawText(textContent);
+ // Drop the trailing paragraph if the merge step didn't fill it.
+ if (trailingParagraph.isAttached() && trailingParagraph.isEmpty()) {
+ trailingParagraph.remove();
}
- });
+ }
}
};
+export const formatCode = (editor: LexicalEditor, blockType: string) => {
+ editor.update(() => $formatCode(blockType));
+};
+
function $clearBlockFormat(block: ElementNode): void {
if (block.getFormat() !== 0) {
block.setFormat('');
@@ -350,79 +380,81 @@ function $clearBlockFormat(block: ElementNode): void {
}
}
-export const clearFormatting = (
- editor: LexicalEditor,
- skipRefocus: boolean = false,
-) => {
- editor.update(() => {
- if (skipRefocus) {
- $addUpdateTag(SKIP_DOM_SELECTION_TAG);
- }
- const selection = $getSelection();
- if ($isRangeSelection(selection) || $isTableSelection(selection)) {
- const anchor = selection.anchor;
- const focus = selection.focus;
- const extractedNodes = selection.extract();
-
- if (anchor.key === focus.key && anchor.offset === focus.offset) {
- $clearBlockFormat(
- $getNearestBlockElementAncestorOrThrow(anchor.getNode()),
- );
- return;
- }
+export const $clearFormatting = (skipRefocus: boolean = false) => {
+ if (skipRefocus) {
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
+ }
+ const selection = $getSelection();
+ if ($isRangeSelection(selection) || $isTableSelection(selection)) {
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const extractedNodes = selection.extract();
- // Determine which blocks are fully selected before making any
- // changes, since the mutations below (such as replacing a
- // HeadingNode with a ParagraphNode) would detach nodes that the
- // selection's carets may refer to
- const postExtractSelection = $getSelection();
- let fullySelectedBlocks: null | Set = null;
- if ($isRangeSelection(postExtractSelection)) {
- fullySelectedBlocks = new Set();
- for (const node of extractedNodes) {
- if ($isTextNode(node)) {
- const block = $getNearestBlockElementAncestorOrThrow(node);
- if (
- !fullySelectedBlocks.has(block.getKey()) &&
- $isBlockFullySelected(block, postExtractSelection)
- ) {
- fullySelectedBlocks.add(block.getKey());
- }
- }
- }
- }
+ if (anchor.key === focus.key && anchor.offset === focus.offset) {
+ $clearBlockFormat(
+ $getNearestBlockElementAncestorOrThrow(anchor.getNode()),
+ );
+ return;
+ }
- extractedNodes.forEach(node => {
+ // Determine which blocks are fully selected before making any
+ // changes, since the mutations below (such as replacing a
+ // HeadingNode with a ParagraphNode) would detach nodes that the
+ // selection's carets may refer to
+ const postExtractSelection = $getSelection();
+ let fullySelectedBlocks: null | Set = null;
+ if ($isRangeSelection(postExtractSelection)) {
+ fullySelectedBlocks = new Set();
+ for (const node of extractedNodes) {
if ($isTextNode(node)) {
- if (node.getStyle() !== '') {
- node.setStyle('');
- }
- if (node.getFormat() !== 0) {
- node.setFormat(0);
- }
- const nearestBlockElement =
- $getNearestBlockElementAncestorOrThrow(node);
+ const block = $getNearestBlockElementAncestorOrThrow(node);
if (
- fullySelectedBlocks === null ||
- fullySelectedBlocks.has(nearestBlockElement.getKey())
+ !fullySelectedBlocks.has(block.getKey()) &&
+ $isBlockFullySelected(block, postExtractSelection)
) {
- $clearBlockFormat(nearestBlockElement);
+ fullySelectedBlocks.add(block.getKey());
}
- } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
- node.replace($createParagraphNode(), true);
- } else if ($isDecoratorBlockNode(node)) {
- node.setFormat('');
}
- });
+ }
+ }
- // hasFormat() reads the format cached on the RangeSelection rather than
- // the nodes, so the toolbars would keep showing the cleared formats as
- // active (#8881)
- const clearedSelection = $getSelection();
- if ($isRangeSelection(clearedSelection)) {
- clearedSelection.setFormat(0);
- clearedSelection.setStyle('');
+ extractedNodes.forEach(node => {
+ if ($isTextNode(node)) {
+ if (node.getStyle() !== '') {
+ node.setStyle('');
+ }
+ if (node.getFormat() !== 0) {
+ node.setFormat(0);
+ }
+ const nearestBlockElement =
+ $getNearestBlockElementAncestorOrThrow(node);
+ if (
+ fullySelectedBlocks === null ||
+ fullySelectedBlocks.has(nearestBlockElement.getKey())
+ ) {
+ $clearBlockFormat(nearestBlockElement);
+ }
+ } else if ($isHeadingNode(node) || $isQuoteNode(node)) {
+ node.replace($createParagraphNode(), true);
+ } else if ($isDecoratorBlockNode(node)) {
+ node.setFormat('');
}
+ });
+
+ // hasFormat() reads the format cached on the RangeSelection rather than
+ // the nodes, so the toolbars would keep showing the cleared formats as
+ // active (#8881)
+ const clearedSelection = $getSelection();
+ if ($isRangeSelection(clearedSelection)) {
+ clearedSelection.setFormat(0);
+ clearedSelection.setStyle('');
}
- });
+ }
+};
+
+export const clearFormatting = (
+ editor: LexicalEditor,
+ skipRefocus: boolean = false,
+) => {
+ editor.update(() => $clearFormatting(skipRefocus));
};
diff --git a/packages/lexical-playground/src/ui/Modal.css b/packages/lexical-playground/src/ui/Modal.css
index 8148fc9137a..cca2158da07 100644
--- a/packages/lexical-playground/src/ui/Modal.css
+++ b/packages/lexical-playground/src/ui/Modal.css
@@ -59,6 +59,7 @@
}
.Modal__content {
padding-top: 20px;
+ overflow-y: auto;
}
.Modal__modal:focus {
outline: none;
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts
index 8528a0fe54a..90f1978dbbe 100644
--- a/packages/lexical-table/src/LexicalTableNode.ts
+++ b/packages/lexical-table/src/LexicalTableNode.ts
@@ -357,7 +357,7 @@ export function setScrollableTablesActive(
active: boolean,
): void {
if (active) {
- if (__DEV__ && !editor._config.theme.tableScrollableWrapper) {
+ if (__DEV__ && editor._config.theme.tableScrollableWrapper === undefined) {
console.warn(
'TableNode: hasHorizontalScroll is active but theme.tableScrollableWrapper is not defined.',
);
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableSelectionHelpers.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableSelectionHelpers.test.ts
index 32099ab9213..6962ff4fe35 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableSelectionHelpers.test.ts
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableSelectionHelpers.test.ts
@@ -54,6 +54,7 @@ describe('LexicalTableSelectionHelpers', () => {
defineExtension({
dependencies: [TableExtension],
name: 'regression-8670-test',
+ theme: {tableScrollableWrapper: ''},
}),
);
editor.setRootElement(container);
@@ -137,6 +138,7 @@ describe('LexicalTableSelectionHelpers', () => {
defineExtension({
dependencies: [TableExtension],
name: 'regression-8832-test',
+ theme: {tableScrollableWrapper: ''},
}),
);
editor.setRootElement(container);
@@ -314,6 +316,7 @@ describe('LexicalTableSelectionHelpers', () => {
defineExtension({
dependencies: [TableExtension],
name: 'delete-line-test',
+ theme: {tableScrollableWrapper: ''},
}),
);
editor.setRootElement(container);
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
index e43cb35250d..413d6b51a96 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
@@ -76,6 +76,7 @@ beforeEach(() => {
defineExtension({
dependencies: [TableExtension],
name: 'LexicalTableUtils-test',
+ theme: {tableScrollableWrapper: ''},
}),
);
editor.update(
diff --git a/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts b/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts
index 7b5abec382b..2216a63f3d2 100644
--- a/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts
+++ b/packages/lexical-table/src/__tests__/unit/TableImportExtension.test.ts
@@ -39,6 +39,7 @@ function buildEditor() {
// required.
dependencies: [TableExtension],
name: 'table-host',
+ theme: {tableScrollableWrapper: ''},
}),
);
}
diff --git a/packages/lexical/src/LexicalConstants.ts b/packages/lexical/src/LexicalConstants.ts
index b7f874e0883..77d5dde05c6 100644
--- a/packages/lexical/src/LexicalConstants.ts
+++ b/packages/lexical/src/LexicalConstants.ts
@@ -161,4 +161,12 @@ export const TEXT_TYPE_TO_MODE: Record = {
/** The property key used to store node state on serialized node JSON. */
export const NODE_STATE_KEY = '$';
+
+/**
+ * @internal
+ *
+ * The property key on a {@link KeyboardEventModifierMask} that names the
+ * Apple-platform counterpart of ctrlKey.
+ */
+export const CONTROL_OR_OTHER_KEY = Symbol.for('@lexical/ctrlOrOtherKey');
export const PROTOTYPE_CONFIG_METHOD = '$config';
diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts
index 9b57218c762..c256f8192ab 100644
--- a/packages/lexical/src/LexicalEditor.ts
+++ b/packages/lexical/src/LexicalEditor.ts
@@ -7,6 +7,8 @@
*/
import type {DOMSlot, ElementDOMSlot} from './LexicalDOMSlot';
+import type {KeyDownShortcut} from './LexicalEvents';
+import type {CompiledKeyboardShortcuts} from './LexicalKeyboardShortcuts';
import type {ElementNode} from './nodes/LexicalElementNode';
import invariant from '@lexical/internal/invariant';
@@ -1194,6 +1196,8 @@ export class LexicalEditor {
*/
_slotsUsed: boolean;
/** @internal */
+ _keyDownShortcuts: null | CompiledKeyboardShortcuts;
+ /** @internal */
_inputState: InputState;
/** @internal */
_createEditorArgs?: undefined | CreateEditorArgs;
@@ -1264,6 +1268,7 @@ export class LexicalEditor {
this._window = null;
this._blockCursorElement = null;
this._slotsUsed = false;
+ this._keyDownShortcuts = null;
this._inputState = createInputState();
}
diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts
index 1c8c737c6a0..c79fa76b69e 100644
--- a/packages/lexical/src/LexicalEvents.ts
+++ b/packages/lexical/src/LexicalEvents.ts
@@ -6,7 +6,8 @@
*
*/
-import type {InputState, LexicalEditor} from './LexicalEditor';
+import type {InputState, LexicalCommand, LexicalEditor} from './LexicalEditor';
+import type {KeyboardShortcutMatch} from './LexicalKeyboardShortcuts';
import type {NodeKey} from './LexicalNode';
import type {ElementNode} from './nodes/LexicalElementNode';
import type {TextNode} from './nodes/LexicalTextNode';
@@ -71,6 +72,7 @@ import {
import {
CAN_USE_BEFORE_INPUT,
IS_ANDROID_CHROME,
+ IS_APPLE,
IS_APPLE_WEBKIT,
IS_FIREFOX,
IS_IOS,
@@ -89,6 +91,11 @@ import {
DOUBLE_LINE_BREAK,
IS_ALL_FORMATTING,
} from './LexicalConstants';
+import {
+ compileKeyboardShortcuts,
+ CONTROL_OR_ALT,
+ CONTROL_OR_META,
+} from './LexicalKeyboardShortcuts';
import {createRefCountedRegistry} from './LexicalRefCountedRegistry';
import {
$internalCreateRangeSelection,
@@ -123,42 +130,16 @@ import {
getNearestEditorFromDOMNode,
getWindow,
isBackspace,
- isBold,
- isCopy,
- isCut,
- isDelete,
- isDeleteBackward,
- isDeleteForward,
- isDeleteLineBackward,
- isDeleteLineForward,
- isDeleteWordBackward,
- isDeleteWordForward,
isDOMCapturingSelection,
isDOMNode,
isDOMShadowRoot,
isDOMTextNode,
- isEscape,
isFirefoxClipboardEvents,
isHTMLElement,
- isItalic,
isLexicalEditor,
- isLineBreak,
isModifier,
- isMoveBackward,
- isMoveDown,
- isMoveForward,
- isMoveToEnd,
- isMoveToStart,
- isMoveUp,
- isOpenLineBreak,
- isParagraph,
- isRedo,
- isSelectAll,
isSelectionWithinEditor,
- isSpace,
- isTab,
- isUnderline,
- isUndo,
+ type KeyboardEventModifierMask,
} from './LexicalUtils';
import {registerEventListener} from './utils/registerEventListener';
@@ -1589,6 +1570,167 @@ function onKeyDown(event: KeyboardEvent, editor: LexicalEditor): void {
dispatchCommand(editor, KEY_DOWN_COMMAND, event);
}
+/** @internal */
+export interface KeyDownShortcut extends KeyboardShortcutMatch {
+ onMatch: (event: KeyboardEvent, editor: LexicalEditor) => void;
+}
+
+const ANY_MODIFIERS = {
+ altKey: 'any',
+ ctrlKey: 'any',
+ metaKey: 'any',
+ shiftKey: 'any',
+} as const;
+const CTRL_KEY = {ctrlKey: true} as const;
+const META_KEY = {metaKey: true} as const;
+const SHIFT_KEY_ANY = {shiftKey: 'any'} as const;
+const ALT_SHIFT_KEY_ANY = {...SHIFT_KEY_ANY, altKey: 'any'} as const;
+
+/**
+ * The keydown shortcuts that the editor handles natively, compiled to
+ * dispatch by the pressed key and modifiers in O(1). Each shortcut's mask
+ * is exclusive of every other mask on the same key, so at most one entry
+ * matches any given event.
+ */
+function buildKeyDownShortcuts(): KeyDownShortcut[] {
+ /** Dispatch the command with the KeyboardEvent as its payload */
+ const dispatch = (
+ key: string,
+ modifiers: KeyboardEventModifierMask,
+ command: LexicalCommand,
+ ): KeyDownShortcut => ({
+ key,
+ modifiers,
+ onMatch: (event, editor) => {
+ dispatchCommand(editor, command, event);
+ },
+ });
+ /** preventDefault() and dispatch the command with a fixed payload */
+ const prevent = (
+ key: string,
+ modifiers: KeyboardEventModifierMask,
+ command: LexicalCommand,
+ payload: T,
+ ): KeyDownShortcut => ({
+ key,
+ modifiers,
+ onMatch: (event, editor) => {
+ event.preventDefault();
+ dispatchCommand(editor, command, payload);
+ },
+ });
+ const enter = (
+ modifiers: KeyboardEventModifierMask,
+ isInsertLineBreak: boolean,
+ ): KeyDownShortcut => ({
+ key: 'Enter',
+ modifiers,
+ onMatch: (event, editor) => {
+ editor._inputState.isInsertLineBreak = isInsertLineBreak;
+ dispatchCommand(editor, KEY_ENTER_COMMAND, event);
+ },
+ });
+ // Only RangeSelection can use the native cut/copy
+ const copyOrCut = (
+ key: string,
+ command: LexicalCommand,
+ ): KeyDownShortcut => ({
+ key,
+ modifiers: CONTROL_OR_META,
+ onMatch: (event, editor) => {
+ const prevSelection = editor._editorState._selection;
+ if (prevSelection !== null && !$isRangeSelection(prevSelection)) {
+ event.preventDefault();
+ dispatchCommand(editor, command, event);
+ }
+ },
+ });
+ return [
+ // moveForward / moveToEnd / moveBackward / moveToStart / moveUp / moveDown
+ dispatch('ArrowRight', SHIFT_KEY_ANY, KEY_ARROW_RIGHT_COMMAND),
+ dispatch('ArrowLeft', SHIFT_KEY_ANY, KEY_ARROW_LEFT_COMMAND),
+ dispatch('ArrowUp', ALT_SHIFT_KEY_ANY, KEY_ARROW_UP_COMMAND),
+ dispatch('ArrowDown', ALT_SHIFT_KEY_ANY, KEY_ARROW_DOWN_COMMAND),
+ // lineBreak / paragraph
+ enter({...ANY_MODIFIERS, shiftKey: true}, true),
+ enter({...ANY_MODIFIERS, shiftKey: false}, false),
+ dispatch(' ', ANY_MODIFIERS, KEY_SPACE_COMMAND),
+ // deleteBackward
+ {
+ key: 'Backspace',
+ modifiers: SHIFT_KEY_ANY,
+ onMatch: (event, editor) => {
+ if (dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event)) {
+ markHandledSelectionCommandInsertText(editor._inputState);
+ }
+ },
+ },
+ dispatch('Escape', ANY_MODIFIERS, KEY_ESCAPE_COMMAND),
+ // deleteForward
+ dispatch('Delete', {}, KEY_DELETE_COMMAND),
+ // deleteWordBackward / deleteWordForward
+ prevent('Backspace', CONTROL_OR_ALT, DELETE_WORD_COMMAND, true),
+ prevent('Delete', CONTROL_OR_ALT, DELETE_WORD_COMMAND, false),
+ prevent('b', CONTROL_OR_META, FORMAT_TEXT_COMMAND, 'bold'),
+ prevent('u', CONTROL_OR_META, FORMAT_TEXT_COMMAND, 'underline'),
+ prevent('i', CONTROL_OR_META, FORMAT_TEXT_COMMAND, 'italic'),
+ dispatch('Tab', SHIFT_KEY_ANY, KEY_TAB_COMMAND),
+ // undo / redo
+ prevent('z', CONTROL_OR_META, UNDO_COMMAND, undefined),
+ prevent('z', {...CONTROL_OR_META, shiftKey: true}, REDO_COMMAND, undefined),
+ ...(IS_APPLE
+ ? [
+ // openLineBreak
+ {
+ key: 'o',
+ modifiers: CTRL_KEY,
+ onMatch: (event: KeyboardEvent, editor: LexicalEditor) => {
+ event.preventDefault();
+ editor._inputState.isInsertLineBreak = true;
+ dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
+ },
+ },
+ // moveToStart / moveToEnd mac
+ dispatch(
+ 'ArrowLeft',
+ {metaKey: true, ...SHIFT_KEY_ANY},
+ MOVE_TO_START,
+ ),
+ dispatch(
+ 'ArrowRight',
+ {metaKey: true, ...SHIFT_KEY_ANY},
+ MOVE_TO_END,
+ ),
+
+ // deleteBackward / deleteForward
+ prevent('h', CTRL_KEY, DELETE_CHARACTER_COMMAND, true),
+ prevent('d', CTRL_KEY, DELETE_CHARACTER_COMMAND, false),
+ // deleteLineBackward / deleteLineForward
+ prevent('Backspace', META_KEY, DELETE_LINE_COMMAND, true),
+ prevent('Delete', META_KEY, DELETE_LINE_COMMAND, false),
+ prevent('k', CTRL_KEY, DELETE_LINE_COMMAND, false),
+ ]
+ : [
+ dispatch('Home', SHIFT_KEY_ANY, MOVE_TO_START),
+ dispatch('End', SHIFT_KEY_ANY, MOVE_TO_END),
+ prevent('y', CTRL_KEY, REDO_COMMAND, undefined),
+ ]),
+ // selectAll
+ {
+ key: 'a',
+ modifiers: CONTROL_OR_META,
+ onMatch: (event, editor) => {
+ event.preventDefault();
+ if (dispatchCommand(editor, SELECT_ALL_COMMAND, event)) {
+ markHandledSelectionCommandInsertText(editor._inputState);
+ }
+ },
+ },
+ copyOrCut('c', COPY_COMMAND),
+ copyOrCut('x', CUT_COMMAND),
+ ];
+}
+
function $handleKeyDown(event: KeyboardEvent): boolean {
const editor = getActiveEditor();
const inputState = editor._inputState;
@@ -1609,94 +1751,14 @@ function $handleKeyDown(event: KeyboardEvent): boolean {
}
}
- if (isMoveForward(event)) {
- dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
- } else if (isMoveToEnd(event)) {
- dispatchCommand(editor, MOVE_TO_END, event);
- } else if (isMoveBackward(event)) {
- dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
- } else if (isMoveToStart(event)) {
- dispatchCommand(editor, MOVE_TO_START, event);
- } else if (isMoveUp(event)) {
- dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
- } else if (isMoveDown(event)) {
- dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
- } else if (isLineBreak(event)) {
- inputState.isInsertLineBreak = true;
- dispatchCommand(editor, KEY_ENTER_COMMAND, event);
- } else if (isSpace(event)) {
- dispatchCommand(editor, KEY_SPACE_COMMAND, event);
- } else if (isOpenLineBreak(event)) {
- event.preventDefault();
- inputState.isInsertLineBreak = true;
- dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
- } else if (isParagraph(event)) {
- inputState.isInsertLineBreak = false;
- dispatchCommand(editor, KEY_ENTER_COMMAND, event);
- } else if (isDeleteBackward(event)) {
- if (isBackspace(event)) {
- if (dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event)) {
- markHandledSelectionCommandInsertText(inputState);
- }
- } else {
- event.preventDefault();
- dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
- }
- } else if (isEscape(event)) {
- dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
- } else if (isDeleteForward(event)) {
- if (isDelete(event)) {
- dispatchCommand(editor, KEY_DELETE_COMMAND, event);
- } else {
- event.preventDefault();
- dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
- }
- } else if (isDeleteWordBackward(event)) {
- event.preventDefault();
- dispatchCommand(editor, DELETE_WORD_COMMAND, true);
- } else if (isDeleteWordForward(event)) {
- event.preventDefault();
- dispatchCommand(editor, DELETE_WORD_COMMAND, false);
- } else if (isDeleteLineBackward(event)) {
- event.preventDefault();
- dispatchCommand(editor, DELETE_LINE_COMMAND, true);
- } else if (isDeleteLineForward(event)) {
- event.preventDefault();
- dispatchCommand(editor, DELETE_LINE_COMMAND, false);
- } else if (isBold(event)) {
- event.preventDefault();
- dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
- } else if (isUnderline(event)) {
- event.preventDefault();
- dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
- } else if (isItalic(event)) {
- event.preventDefault();
- dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
- } else if (isTab(event)) {
- dispatchCommand(editor, KEY_TAB_COMMAND, event);
- } else if (isUndo(event)) {
- event.preventDefault();
- dispatchCommand(editor, UNDO_COMMAND);
- } else if (isRedo(event)) {
- event.preventDefault();
- dispatchCommand(editor, REDO_COMMAND);
- } else {
- const prevSelection = editor._editorState._selection;
- if (isSelectAll(event)) {
- event.preventDefault();
- if (dispatchCommand(editor, SELECT_ALL_COMMAND, event)) {
- markHandledSelectionCommandInsertText(inputState);
- }
- } else if (prevSelection !== null && !$isRangeSelection(prevSelection)) {
- // Only RangeSelection can use the native cut/copy/select all
- if (isCopy(event)) {
- event.preventDefault();
- dispatchCommand(editor, COPY_COMMAND, event);
- } else if (isCut(event)) {
- event.preventDefault();
- dispatchCommand(editor, CUT_COMMAND, event);
- }
- }
+ let keyDownShortcuts = editor._keyDownShortcuts;
+ if (keyDownShortcuts === null) {
+ keyDownShortcuts = compileKeyboardShortcuts(buildKeyDownShortcuts());
+ editor._keyDownShortcuts = keyDownShortcuts;
+ }
+ const shortcut = keyDownShortcuts.match(event);
+ if (shortcut) {
+ shortcut.onMatch(event, editor);
}
if (isModifier(event)) {
diff --git a/packages/lexical/src/LexicalKeyboardShortcuts.ts b/packages/lexical/src/LexicalKeyboardShortcuts.ts
new file mode 100644
index 00000000000..6c19200e60f
--- /dev/null
+++ b/packages/lexical/src/LexicalKeyboardShortcuts.ts
@@ -0,0 +1,285 @@
+/**
+ * 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.
+ *
+ */
+
+import type {LexicalCommand, LexicalEditor} from './LexicalEditor';
+import type {BaseSelection} from './LexicalSelection';
+import type {
+ KeyboardEventControlOrOther,
+ KeyboardEventModifierMask,
+ KeyboardEventModifiers,
+} from './LexicalUtils';
+
+import invariant from '@lexical/internal/invariant';
+
+import {IS_APPLE} from './environment';
+import {CONTROL_OR_OTHER_KEY} from './LexicalConstants';
+
+/**
+ * @experimental
+ *
+ * The data that describes which keyboard events a shortcut matches: an
+ * `event.key` value (case-insensitive) plus a
+ * {@link KeyboardEventModifierMask}. The matching semantics are identical to
+ * {@link isExactShortcutMatch}, including the `event.code` fallback for
+ * single-character keys on non-Latin keyboard layouts.
+ */
+export interface KeyboardShortcutMatch {
+ /**
+ * The `KeyboardEvent.key` to match, case-insensitive
+ * (e.g. `'b'`, `'1'`, `'Enter'`, `'ArrowLeft'`)
+ */
+ key: string;
+ /**
+ * The expected state of the modifier keys. A modifier that is omitted or
+ * `false` must not be pressed, `true` must be pressed, and `'any'` is
+ * ignored. The default of `{}` matches only events with no modifiers.
+ */
+ modifiers?: KeyboardEventModifierMask;
+ /**
+ * The unshifted key to display to the user, only relevant when the shift
+ * modifier is true on non-Apple environments.
+ */
+ unshiftedKey?: string;
+}
+
+/**
+ * @experimental
+ *
+ * A keyboard shortcut is pure data: the key and modifiers to match, and the
+ * command to dispatch (with the matched KeyboardEvent as its payload) when
+ * it does. Keeping the action to a command keeps the mapping declarative —
+ * a shortcut table can be rendered as a menu (see
+ * `formatKeyboardShortcut` in `@lexical/extension`), remapped, or
+ * serialized, and the behavior lives in command listeners where any other
+ * UI can share it.
+ */
+export interface KeyboardShortcut extends KeyboardShortcutMatch {
+ /**
+ * The command dispatched with the matched KeyboardEvent as its payload.
+ * The event is considered handled when the dispatch is handled; an
+ * unhandled dispatch falls through to any other shortcut on the same key
+ * and modifiers. Listeners are responsible for calling
+ * `event.preventDefault()` if the default action must be suppressed.
+ */
+ command: LexicalCommand;
+ /**
+ * A human readable description of what the shortcut does, for building
+ * menus or help dialogs from a shortcut table
+ */
+ description?: string;
+ /**
+ * Called with the current selection before the command is dispatched;
+ * returning true skips this shortcut (falling through to any other
+ * shortcut on the same key and modifiers). Menu builders may use the
+ * same predicate to render an item as disabled.
+ *
+ * @param selection - The current editor selection, or null if none exists.
+ * @param editor - The editor where KEY_DOWN_COMMAND originated (may
+ * differ from the registration editor in nested-editor setups).
+ * @returns `true` to skip this shortcut, `false` to allow it.
+ */
+ $disabled?: (
+ selection: null | BaseSelection,
+ editor: LexicalEditor,
+ ) => boolean;
+ /**
+ * Optional middleware around the command dispatch, for shortcuts that
+ * must run additional code (e.g. setting some state) without defining a
+ * wrapper command. It is responsible for calling `$next()` — which
+ * dispatches the command on the originating editor — and returning
+ * whether the event was handled (an unhandled event falls through to
+ * any other shortcut on the same key and modifiers).
+ *
+ * @param command - The shortcut's command.
+ * @param event - The matched KeyboardEvent.
+ * @param $next - Dispatches the shortcut's command on the originating
+ * editor and returns whether the dispatch was handled.
+ * @param editor - The editor where KEY_DOWN_COMMAND originated (may
+ * differ from the registration editor in nested-editor setups).
+ */
+ $dispatch?: (
+ command: LexicalCommand,
+ event: KeyboardEvent,
+ $next: () => boolean,
+ editor: LexicalEditor,
+ ) => boolean;
+ /**
+ * By default, shortcut keypresses that originate in nested editors
+ * but were not handled by that editor are ignored. Set to `true`
+ * when you want matching events to bubble up to this handler.
+ *
+ * This only has an effect when the shortcut listener is registered at a
+ * priority above `COMMAND_PRIORITY_EDITOR`: the nested editor registers
+ * the core key-down handler at that priority and it always reports the
+ * event as handled, which ends the dispatch before it reaches the outer
+ * editor's editor-priority queue.
+ */
+ bubbleFromNestedEditors?: boolean;
+}
+
+/**
+ * The modifier mask for the primary shortcut modifier:
+ * ⌘ (metaKey) on Apple platforms and Ctrl elsewhere.
+ */
+export const CONTROL_OR_META: KeyboardEventModifierMask &
+ KeyboardEventControlOrOther = {
+ [CONTROL_OR_OTHER_KEY]: 'metaKey',
+ ctrlKey: !IS_APPLE,
+ metaKey: IS_APPLE,
+};
+
+/**
+ * The modifier mask for the secondary shortcut modifier:
+ * Option (altKey) on Apple platforms and Ctrl elsewhere, conventionally
+ * used for word-level editing and block-format shortcuts.
+ */
+export const CONTROL_OR_ALT: KeyboardEventModifierMask &
+ KeyboardEventControlOrOther = {
+ [CONTROL_OR_OTHER_KEY]: 'altKey',
+ altKey: IS_APPLE,
+ ctrlKey: !IS_APPLE,
+};
+
+const MODIFIER_BITS = [
+ ['altKey', 1],
+ ['ctrlKey', 2],
+ ['metaKey', 4],
+ ['shiftKey', 8],
+] as const;
+
+function getEventModifierBits(event: KeyboardEventModifiers): number {
+ let bits = 0;
+ for (const [prop, bit] of MODIFIER_BITS) {
+ if (event[prop]) {
+ bits |= bit;
+ }
+ }
+ return bits;
+}
+
+/**
+ * Enumerate the modifier bitmasks that satisfy the mask, expanding each
+ * `'any'` into both states (so a mask with two `'any'` yields four
+ * bitmasks, and a fully concrete mask yields exactly one).
+ */
+function getMaskModifierBits(mask: KeyboardEventModifierMask): number[] {
+ let combos = [0];
+ for (const [prop, bit] of MODIFIER_BITS) {
+ const expected = mask[prop] || false;
+ if (expected === 'any') {
+ combos = combos.concat(combos.map(bits => bits | bit));
+ } else if (expected) {
+ combos = combos.map(bits => bits | bit);
+ }
+ }
+ return combos;
+}
+
+function pushEntry(map: Map, mapKey: string, shortcut: S) {
+ const entry = map.get(mapKey);
+ if (entry) {
+ entry.push(shortcut);
+ } else {
+ map.set(mapKey, [shortcut]);
+ }
+}
+
+/**
+ * @experimental @internal
+ *
+ * A shortcut table compiled for O(1) dispatch. Look-up is by a composite of
+ * the event's modifier bitmask and its `key` (with a second look-up by
+ * `code` for non-Latin layouts), so the cost of {@link match} /
+ * {@link matches} is independent of the number of shortcuts in the table.
+ */
+export class CompiledKeyboardShortcuts<
+ S extends KeyboardShortcutMatch = KeyboardShortcut,
+> {
+ /** `${modifierBits}:${key.toLowerCase()}` -> shortcuts in insertion order */
+ private byKey: Map = new Map();
+ /**
+ * `${modifierBits}:${code}` (e.g. `Digit1`, `KeyB`) -> shortcuts, used
+ * only when `event.key` is not a single ASCII character so that
+ * single-character shortcuts still work on non-Latin keyboard layouts
+ * (the same fallback as {@link isExactShortcutMatch})
+ */
+ private byCode: Map = new Map();
+
+ add(shortcut: S): this {
+ const {key, modifiers = {}} = shortcut;
+ invariant(key.length > 0, 'KeyboardShortcutMatch: key must be non-empty');
+ const lowerKey = key.toLowerCase();
+ for (const bits of getMaskModifierBits(modifiers)) {
+ pushEntry(this.byKey, `${bits}:${lowerKey}`, shortcut);
+ if (key.length === 1) {
+ if (/[0-9]/.test(key)) {
+ pushEntry(this.byCode, `${bits}:Digit${key}`, shortcut);
+ } else if (/[a-z]/.test(lowerKey)) {
+ pushEntry(
+ this.byCode,
+ `${bits}:Key${lowerKey.toUpperCase()}`,
+ shortcut,
+ );
+ }
+ }
+ }
+ return this;
+ }
+
+ /**
+ * All shortcuts matching the event, in insertion order.
+ * Matches by `key` precede matches by the `code` fallback.
+ * @see {@link match} for the single-result fast path.
+ */
+ matches(event: KeyboardEventModifiers): S[] {
+ const key = event.key;
+ if (!key) {
+ return [];
+ }
+ const bits = getEventModifierBits(event);
+ const byKey = this.byKey.get(`${bits}:${key.toLowerCase()}`);
+ const matches = byKey ? byKey.slice() : [];
+ // The code fallback only applies when event.key is not a single ASCII
+ // character, otherwise it would break remapped layouts (Dvorak, etc.)
+ if (
+ this.byCode.size > 0 &&
+ !(key.length === 1 && key.charCodeAt(0) <= 127)
+ ) {
+ const byCode = this.byCode.get(`${bits}:${event.code}`);
+ if (byCode) {
+ matches.push(...byCode);
+ }
+ }
+ return matches;
+ }
+
+ /**
+ * The first shortcut matching the event, if any.
+ * @see {@link matches} for the full list of matching shortcuts.
+ */
+ match(event: KeyboardEventModifiers): S | undefined {
+ return this.matches(event)[0];
+ }
+}
+
+/**
+ * @experimental @internal
+ *
+ * Compile a table of keyboard shortcuts down to a form that dispatches
+ * based on the pressed key and modifiers in O(1), instead of testing each
+ * shortcut in sequence.
+ */
+export function compileKeyboardShortcuts(
+ shortcuts: Iterable,
+): CompiledKeyboardShortcuts {
+ const compiled = new CompiledKeyboardShortcuts();
+ for (const shortcut of shortcuts) {
+ compiled.add(shortcut);
+ }
+ return compiled;
+}
diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts
index e07c467d23b..d6eecffb514 100644
--- a/packages/lexical/src/LexicalUtils.ts
+++ b/packages/lexical/src/LexicalUtils.ts
@@ -23,6 +23,7 @@ import {
$isRootNode,
$isTabNode,
$isTextNode,
+ CONTROL_OR_META,
DecoratorNode,
DEFAULT_EDITOR_DOM_CONFIG,
type ElementFormatType,
@@ -42,6 +43,7 @@ import {
import {
COMPOSITION_START_CHAR,
COMPOSITION_SUFFIX,
+ CONTROL_OR_OTHER_KEY,
DOM_DOCUMENT_FRAGMENT_TYPE,
DOM_DOCUMENT_TYPE,
DOM_ELEMENT_TYPE,
@@ -1108,12 +1110,30 @@ export type KeyboardEventModifiers = Pick<
* not be pressed.
*/
export type KeyboardEventModifierMask = {
- [K in Exclude]?:
+ [K in Exclude]?:
| boolean
| undefined
| 'any';
};
+export {CONTROL_OR_OTHER_KEY};
+
+/** @internal */
+export interface KeyboardEventControlOrOther {
+ [CONTROL_OR_OTHER_KEY]?: 'metaKey' | 'altKey';
+}
+
+/** @internal */
+export function keyboardEventMaskForPlatform(
+ mask: KeyboardEventModifierMask & KeyboardEventControlOrOther,
+ isApple: boolean,
+): KeyboardEventModifierMask {
+ const otherKey = mask[CONTROL_OR_OTHER_KEY];
+ return otherKey && isApple !== IS_APPLE
+ ? {...mask, ctrlKey: mask[otherKey], [otherKey]: mask.ctrlKey}
+ : mask;
+}
+
function matchModifier(
event: KeyboardEventModifiers,
mask: KeyboardEventModifierMask,
@@ -1186,162 +1206,10 @@ export function isExactShortcutMatch(
return event.code === expectedCode;
}
-const CONTROL_OR_META = {ctrlKey: !IS_APPLE, metaKey: IS_APPLE};
-const CONTROL_OR_ALT = {altKey: IS_APPLE, ctrlKey: !IS_APPLE};
-
-export function isTab(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'Tab', {
- shiftKey: 'any',
- });
-}
-
-export function isBold(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'b', CONTROL_OR_META);
-}
-
-export function isItalic(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'i', CONTROL_OR_META);
-}
-
-export function isUnderline(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'u', CONTROL_OR_META);
-}
-
-export function isParagraph(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'Enter', {
- altKey: 'any',
- ctrlKey: 'any',
- metaKey: 'any',
- });
-}
-
-export function isLineBreak(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'Enter', {
- altKey: 'any',
- ctrlKey: 'any',
- metaKey: 'any',
- shiftKey: true,
- });
-}
-
-// Inserts a new line after the selection
-
-export function isOpenLineBreak(event: KeyboardEventModifiers): boolean {
- // 79 = KeyO
- return IS_APPLE && isExactShortcutMatch(event, 'o', {ctrlKey: true});
-}
-
-export function isDeleteWordBackward(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'Backspace', CONTROL_OR_ALT);
-}
-
-export function isDeleteWordForward(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'Delete', CONTROL_OR_ALT);
-}
-
-export function isDeleteLineBackward(event: KeyboardEventModifiers): boolean {
- return IS_APPLE && isExactShortcutMatch(event, 'Backspace', {metaKey: true});
-}
-
-export function isDeleteLineForward(event: KeyboardEventModifiers): boolean {
- return (
- IS_APPLE &&
- (isExactShortcutMatch(event, 'Delete', {metaKey: true}) ||
- isExactShortcutMatch(event, 'k', {ctrlKey: true}))
- );
-}
-
-export function isDeleteBackward(event: KeyboardEventModifiers): boolean {
- return (
- isExactShortcutMatch(event, 'Backspace', {shiftKey: 'any'}) ||
- (IS_APPLE && isExactShortcutMatch(event, 'h', {ctrlKey: true}))
- );
-}
-
-export function isDeleteForward(event: KeyboardEventModifiers): boolean {
- return (
- isExactShortcutMatch(event, 'Delete', {}) ||
- (IS_APPLE && isExactShortcutMatch(event, 'd', {ctrlKey: true}))
- );
-}
-
-export function isUndo(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'z', CONTROL_OR_META);
-}
-
-export function isRedo(event: KeyboardEventModifiers): boolean {
- if (IS_APPLE) {
- return isExactShortcutMatch(event, 'z', {metaKey: true, shiftKey: true});
- }
- return (
- isExactShortcutMatch(event, 'y', {ctrlKey: true}) ||
- isExactShortcutMatch(event, 'z', {ctrlKey: true, shiftKey: true})
- );
-}
-
-export function isCopy(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'c', CONTROL_OR_META);
-}
-
-export function isCut(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'x', CONTROL_OR_META);
-}
-
-export function isMoveBackward(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowLeft', {
- shiftKey: 'any',
- });
-}
-
-export function isMoveToStart(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowLeft', {
- ...CONTROL_OR_META,
- shiftKey: 'any',
- });
-}
-
-export function isMoveForward(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowRight', {
- shiftKey: 'any',
- });
-}
-
-export function isMoveToEnd(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowRight', {
- ...CONTROL_OR_META,
- shiftKey: 'any',
- });
-}
-
-export function isMoveUp(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowUp', {
- altKey: 'any',
- shiftKey: 'any',
- });
-}
-
-export function isMoveDown(event: KeyboardEventModifiers): boolean {
- return isExactShortcutMatch(event, 'ArrowDown', {
- altKey: 'any',
- shiftKey: 'any',
- });
-}
-
export function isModifier(event: KeyboardEventModifiers): boolean {
return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey;
}
-export function isSpace(event: KeyboardEventModifiers): boolean {
- return event.key === ' ';
-}
-
-export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
- if (IS_APPLE) {
- return metaKey;
- }
- return ctrlKey;
-}
-
export function isBackspace(event: KeyboardEventModifiers): boolean {
return event.key === 'Backspace';
}
diff --git a/packages/lexical/src/__tests__/unit/LexicalEventsKeyDown.test.ts b/packages/lexical/src/__tests__/unit/LexicalEventsKeyDown.test.ts
new file mode 100644
index 00000000000..bb21a154e62
--- /dev/null
+++ b/packages/lexical/src/__tests__/unit/LexicalEventsKeyDown.test.ts
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ *
+ */
+
+import {vi} from 'vitest';
+
+import {runKeyDownDispatchParityTests} from './keyDownDispatchParity';
+
+// `vi.mock` is hoisted above all imports, so LexicalEvents.ts compiles its
+// keydown shortcut table for the non-Apple platform.
+vi.mock('lexical/src/environment', async importOriginal => ({
+ ...(await importOriginal()),
+ IS_APPLE: false,
+}));
+
+runKeyDownDispatchParityTests(false);
diff --git a/packages/lexical/src/__tests__/unit/LexicalEventsKeyDownApple.test.ts b/packages/lexical/src/__tests__/unit/LexicalEventsKeyDownApple.test.ts
new file mode 100644
index 00000000000..716a405a4bc
--- /dev/null
+++ b/packages/lexical/src/__tests__/unit/LexicalEventsKeyDownApple.test.ts
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ *
+ */
+
+import {vi} from 'vitest';
+
+import {runKeyDownDispatchParityTests} from './keyDownDispatchParity';
+
+// `vi.mock` is hoisted above all imports, so LexicalEvents.ts compiles its
+// keydown shortcut table for the Apple platform.
+vi.mock('lexical/src/environment', async importOriginal => ({
+ ...(await importOriginal()),
+ IS_APPLE: true,
+}));
+
+runKeyDownDispatchParityTests(true);
diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts
index c24056d2c5c..b0018bc92fe 100644
--- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts
+++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts
@@ -26,7 +26,6 @@ import {
getParentElement,
getRegisteredSubtypeMap,
getTextDirection,
- IS_APPLE,
isExactShortcutMatch,
isSelectionWithinEditor,
LineBreakNode,
@@ -53,8 +52,6 @@ import {
getCachedTypeToNodeMap,
getStaticNodeConfig,
isArray,
- isMoveToEnd,
- isMoveToStart,
iterStaticNodeConfigChain,
scheduleMicroTask,
scrollIntoViewIfNeeded,
@@ -340,46 +337,6 @@ describe('LexicalUtils tests', () => {
);
});
- test('isMoveToEnd() / isMoveToStart() accept Shift modifier', () => {
- const modifier = IS_APPLE ? {metaKey: true} : {ctrlKey: true};
-
- const rightWithoutShift = new KeyboardEvent('keydown', {
- ...modifier,
- key: 'ArrowRight',
- });
- const rightWithShift = new KeyboardEvent('keydown', {
- ...modifier,
- key: 'ArrowRight',
- shiftKey: true,
- });
- const leftWithoutShift = new KeyboardEvent('keydown', {
- ...modifier,
- key: 'ArrowLeft',
- });
- const leftWithShift = new KeyboardEvent('keydown', {
- ...modifier,
- key: 'ArrowLeft',
- shiftKey: true,
- });
-
- expect(isMoveToEnd(rightWithoutShift)).toBe(true);
- expect(isMoveToEnd(rightWithShift)).toBe(true);
- expect(isMoveToStart(leftWithoutShift)).toBe(true);
- expect(isMoveToStart(leftWithShift)).toBe(true);
-
- // Wrong direction rejected
- expect(isMoveToEnd(leftWithoutShift)).toBe(false);
- expect(isMoveToStart(rightWithoutShift)).toBe(false);
-
- // Extra Alt modifier rejected
- const rightWithAlt = new KeyboardEvent('keydown', {
- ...modifier,
- altKey: true,
- key: 'ArrowRight',
- });
- expect(isMoveToEnd(rightWithAlt)).toBe(false);
- });
-
test('isTokenOrSegmented()', async () => {
const {editor} = testEnv;
diff --git a/packages/lexical/src/__tests__/unit/keyDownDispatchParity.ts b/packages/lexical/src/__tests__/unit/keyDownDispatchParity.ts
new file mode 100644
index 00000000000..9ce46082dd2
--- /dev/null
+++ b/packages/lexical/src/__tests__/unit/keyDownDispatchParity.ts
@@ -0,0 +1,281 @@
+/**
+ * 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.
+ *
+ */
+
+/**
+ * Shared harness that asserts the compiled keydown shortcut table in
+ * LexicalEvents dispatches exactly the same commands as the predicate
+ * chain it replaced. The reference implementation below is a transcription
+ * of the old `$handleKeyDown` if/else chain (and the `is*` predicates it
+ * called), evaluated over an exhaustive grid of keys and modifier states.
+ *
+ * Import this from a test file that mocks `lexical/src/environment` for the
+ * platform under test and call {@link runKeyDownDispatchParityTests}.
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {
+ type AnyLexicalCommand,
+ COMMAND_PRIORITY_CRITICAL,
+ CONTROL_OR_ALT,
+ CONTROL_OR_META,
+ COPY_COMMAND,
+ CUT_COMMAND,
+ defineExtension,
+ DELETE_CHARACTER_COMMAND,
+ DELETE_LINE_COMMAND,
+ DELETE_WORD_COMMAND,
+ FORMAT_TEXT_COMMAND,
+ INSERT_LINE_BREAK_COMMAND,
+ isExactShortcutMatch,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ KEY_BACKSPACE_COMMAND,
+ KEY_DELETE_COMMAND,
+ KEY_DOWN_COMMAND,
+ KEY_ENTER_COMMAND,
+ KEY_ESCAPE_COMMAND,
+ KEY_MODIFIER_COMMAND,
+ KEY_SPACE_COMMAND,
+ KEY_TAB_COMMAND,
+ type KeyboardEventModifierMask,
+ MOVE_TO_END,
+ MOVE_TO_START,
+ REDO_COMMAND,
+ SELECT_ALL_COMMAND,
+ UNDO_COMMAND,
+} from 'lexical';
+import {describe, expect, test} from 'vitest';
+
+interface ExpectedDispatch {
+ command: AnyLexicalCommand;
+ payload?: unknown;
+ preventsDefault: boolean;
+}
+
+/**
+ * A transcription of the old `$handleKeyDown` predicate chain, in the
+ * original order. `payload: EVENT` marks commands whose payload is the
+ * KeyboardEvent itself.
+ */
+const EVENT = Symbol('EVENT');
+
+function referenceKeyDown(
+ event: KeyboardEvent,
+ isApple: boolean,
+): ExpectedDispatch | null {
+ const m = (key: string, mask: KeyboardEventModifierMask) =>
+ isExactShortcutMatch(event, key, mask);
+ const dispatched = (
+ command: AnyLexicalCommand,
+ payload: unknown,
+ preventsDefault = false,
+ ) => ({command, payload, preventsDefault});
+
+ if (m('ArrowRight', {shiftKey: 'any'})) {
+ return dispatched(KEY_ARROW_RIGHT_COMMAND, EVENT);
+ } else if (
+ isApple
+ ? m('ArrowRight', {metaKey: true, shiftKey: 'any'})
+ : m('End', {shiftKey: 'any'})
+ ) {
+ return dispatched(MOVE_TO_END, EVENT);
+ } else if (m('ArrowLeft', {shiftKey: 'any'})) {
+ return dispatched(KEY_ARROW_LEFT_COMMAND, EVENT);
+ } else if (
+ isApple
+ ? m('ArrowLeft', {metaKey: true, shiftKey: 'any'})
+ : m('Home', {shiftKey: 'any'})
+ ) {
+ return dispatched(MOVE_TO_START, EVENT);
+ } else if (m('ArrowUp', {altKey: 'any', shiftKey: 'any'})) {
+ return dispatched(KEY_ARROW_UP_COMMAND, EVENT);
+ } else if (m('ArrowDown', {altKey: 'any', shiftKey: 'any'})) {
+ return dispatched(KEY_ARROW_DOWN_COMMAND, EVENT);
+ } else if (
+ m('Enter', {altKey: 'any', ctrlKey: 'any', metaKey: 'any', shiftKey: true})
+ ) {
+ return dispatched(KEY_ENTER_COMMAND, EVENT);
+ } else if (event.key === ' ') {
+ return dispatched(KEY_SPACE_COMMAND, EVENT);
+ } else if (isApple && m('o', {ctrlKey: true})) {
+ return dispatched(INSERT_LINE_BREAK_COMMAND, true, true);
+ } else if (m('Enter', {altKey: 'any', ctrlKey: 'any', metaKey: 'any'})) {
+ return dispatched(KEY_ENTER_COMMAND, EVENT);
+ } else if (
+ m('Backspace', {shiftKey: 'any'}) ||
+ (isApple && m('h', {ctrlKey: true}))
+ ) {
+ return event.key === 'Backspace'
+ ? dispatched(KEY_BACKSPACE_COMMAND, EVENT)
+ : dispatched(DELETE_CHARACTER_COMMAND, true, true);
+ } else if (event.key === 'Escape') {
+ return dispatched(KEY_ESCAPE_COMMAND, EVENT);
+ } else if (m('Delete', {}) || (isApple && m('d', {ctrlKey: true}))) {
+ return event.key === 'Delete'
+ ? dispatched(KEY_DELETE_COMMAND, EVENT)
+ : dispatched(DELETE_CHARACTER_COMMAND, false, true);
+ } else if (m('Backspace', CONTROL_OR_ALT)) {
+ return dispatched(DELETE_WORD_COMMAND, true, true);
+ } else if (m('Delete', CONTROL_OR_ALT)) {
+ return dispatched(DELETE_WORD_COMMAND, false, true);
+ } else if (isApple && m('Backspace', {metaKey: true})) {
+ return dispatched(DELETE_LINE_COMMAND, true, true);
+ } else if (
+ isApple &&
+ (m('Delete', {metaKey: true}) || m('k', {ctrlKey: true}))
+ ) {
+ return dispatched(DELETE_LINE_COMMAND, false, true);
+ } else if (m('b', CONTROL_OR_META)) {
+ return dispatched(FORMAT_TEXT_COMMAND, 'bold', true);
+ } else if (m('u', CONTROL_OR_META)) {
+ return dispatched(FORMAT_TEXT_COMMAND, 'underline', true);
+ } else if (m('i', CONTROL_OR_META)) {
+ return dispatched(FORMAT_TEXT_COMMAND, 'italic', true);
+ } else if (m('Tab', {shiftKey: 'any'})) {
+ return dispatched(KEY_TAB_COMMAND, EVENT);
+ } else if (m('z', CONTROL_OR_META)) {
+ return dispatched(UNDO_COMMAND, undefined, true);
+ } else if (
+ isApple
+ ? m('z', {metaKey: true, shiftKey: true})
+ : m('y', {ctrlKey: true}) || m('z', {ctrlKey: true, shiftKey: true})
+ ) {
+ return dispatched(REDO_COMMAND, undefined, true);
+ } else if (m('a', CONTROL_OR_META)) {
+ return dispatched(SELECT_ALL_COMMAND, EVENT, true);
+ }
+ // isCopy / isCut are gated on the previous selection being a non-null,
+ // non-range selection; with no selection they dispatch nothing.
+ return null;
+}
+
+/** Every (key, code) pair exercised against all 16 modifier states */
+const GRID_KEYS: [string, string][] = [
+ ['ArrowRight', 'ArrowRight'],
+ ['ArrowLeft', 'ArrowLeft'],
+ ['ArrowUp', 'ArrowUp'],
+ ['ArrowDown', 'ArrowDown'],
+ ['Enter', 'Enter'],
+ [' ', 'Space'],
+ ['Backspace', 'Backspace'],
+ ['Delete', 'Delete'],
+ ['Escape', 'Escape'],
+ ['Tab', 'Tab'],
+ ['a', 'KeyA'],
+ ['b', 'KeyB'],
+ ['c', 'KeyC'],
+ ['d', 'KeyD'],
+ ['h', 'KeyH'],
+ ['i', 'KeyI'],
+ ['k', 'KeyK'],
+ ['o', 'KeyO'],
+ ['q', 'KeyQ'],
+ ['u', 'KeyU'],
+ ['x', 'KeyX'],
+ ['y', 'KeyY'],
+ ['z', 'KeyZ'],
+ // Non-Latin layouts: event.key is non-ASCII, matching falls back to code
+ ['и', 'KeyB'],
+ ['я', 'KeyZ'],
+];
+
+const OBSERVED_COMMANDS: [string, AnyLexicalCommand][] = [
+ ['KEY_ARROW_RIGHT_COMMAND', KEY_ARROW_RIGHT_COMMAND],
+ ['KEY_ARROW_LEFT_COMMAND', KEY_ARROW_LEFT_COMMAND],
+ ['KEY_ARROW_UP_COMMAND', KEY_ARROW_UP_COMMAND],
+ ['KEY_ARROW_DOWN_COMMAND', KEY_ARROW_DOWN_COMMAND],
+ ['MOVE_TO_END', MOVE_TO_END],
+ ['MOVE_TO_START', MOVE_TO_START],
+ ['KEY_ENTER_COMMAND', KEY_ENTER_COMMAND],
+ ['KEY_SPACE_COMMAND', KEY_SPACE_COMMAND],
+ ['INSERT_LINE_BREAK_COMMAND', INSERT_LINE_BREAK_COMMAND],
+ ['KEY_BACKSPACE_COMMAND', KEY_BACKSPACE_COMMAND],
+ ['KEY_DELETE_COMMAND', KEY_DELETE_COMMAND],
+ ['KEY_ESCAPE_COMMAND', KEY_ESCAPE_COMMAND],
+ ['KEY_TAB_COMMAND', KEY_TAB_COMMAND],
+ ['DELETE_CHARACTER_COMMAND', DELETE_CHARACTER_COMMAND],
+ ['DELETE_WORD_COMMAND', DELETE_WORD_COMMAND],
+ ['DELETE_LINE_COMMAND', DELETE_LINE_COMMAND],
+ ['FORMAT_TEXT_COMMAND', FORMAT_TEXT_COMMAND],
+ ['UNDO_COMMAND', UNDO_COMMAND],
+ ['REDO_COMMAND', REDO_COMMAND],
+ ['SELECT_ALL_COMMAND', SELECT_ALL_COMMAND],
+ ['COPY_COMMAND', COPY_COMMAND],
+ ['CUT_COMMAND', CUT_COMMAND],
+ ['KEY_MODIFIER_COMMAND', KEY_MODIFIER_COMMAND],
+];
+
+export function runKeyDownDispatchParityTests(isApple: boolean): void {
+ describe(`$handleKeyDown dispatch parity (IS_APPLE=${isApple})`, () => {
+ test('dispatches the same commands as the legacy predicate chain', () => {
+ const recorded: {command: AnyLexicalCommand; payload: unknown}[] = [];
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ name: 'keydown-parity-test',
+ register: editor2 => {
+ const cleanups = OBSERVED_COMMANDS.map(([, command]) =>
+ editor2.registerCommand(
+ command,
+ payload => {
+ recorded.push({command, payload});
+ return true;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ );
+ return () => cleanups.forEach(cleanup => cleanup());
+ },
+ }),
+ );
+
+ for (const [key, code] of GRID_KEYS) {
+ for (let bits = 0; bits < 16; bits++) {
+ const event = new KeyboardEvent('keydown', {
+ altKey: Boolean(bits & 1),
+ cancelable: true,
+ code,
+ ctrlKey: Boolean(bits & 2),
+ key,
+ metaKey: Boolean(bits & 4),
+ shiftKey: Boolean(bits & 8),
+ });
+ recorded.length = 0;
+ editor.dispatchCommand(KEY_DOWN_COMMAND, event);
+
+ const label = `key=${key} code=${code} alt=${event.altKey} ctrl=${event.ctrlKey} meta=${event.metaKey} shift=${event.shiftKey}`;
+ const expected = referenceKeyDown(event, isApple);
+ const modifierDispatches = recorded.filter(
+ r => r.command === KEY_MODIFIER_COMMAND,
+ );
+ const dispatches = recorded.filter(
+ r => r.command !== KEY_MODIFIER_COMMAND,
+ );
+ // KEY_MODIFIER_COMMAND is always dispatched (in addition to any
+ // matched shortcut) when at least one modifier is pressed
+ expect(modifierDispatches.length, label).toBe(bits === 0 ? 0 : 1);
+ if (expected === null) {
+ expect(dispatches, label).toEqual([]);
+ expect(event.defaultPrevented, label).toBe(false);
+ } else {
+ expect(dispatches.length, label).toBe(1);
+ expect(dispatches[0].command, label).toBe(expected.command);
+ expect(dispatches[0].payload, label).toBe(
+ expected.payload === EVENT ? event : expected.payload,
+ );
+ expect(event.defaultPrevented, label).toBe(
+ expected.preventsDefault,
+ );
+ }
+ }
+ }
+ editor.dispose();
+ });
+ });
+}
diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts
index b55aaa18d99..ad83861600d 100644
--- a/packages/lexical/src/index.ts
+++ b/packages/lexical/src/index.ts
@@ -190,8 +190,16 @@ export type {
SerializedEditorState,
} from './LexicalEditorState';
export {$isEditorState} from './LexicalEditorState';
-export type {EventHandler} from './LexicalEvents';
+export type {EventHandler, KeyDownShortcut} from './LexicalEvents';
export {stopLexicalPropagation} from './LexicalEvents';
+export type {CompiledKeyboardShortcuts} from './LexicalKeyboardShortcuts';
+export {
+ compileKeyboardShortcuts,
+ CONTROL_OR_ALT,
+ CONTROL_OR_META,
+ type KeyboardShortcut,
+ type KeyboardShortcutMatch,
+} from './LexicalKeyboardShortcuts';
export type {
AbstractStaticNodeConfigRecord,
BaseStaticNodeConfig,
@@ -332,6 +340,7 @@ export {
$setFormatFromDOM,
$setSelection,
$splitNode,
+ CONTROL_OR_OTHER_KEY,
type DOMSelectionBoundaryPoints,
findAllLexicalElementsDeep,
getActiveElement,
@@ -375,6 +384,10 @@ export {
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
iterStaticNodeConfigChain,
+ type KeyboardEventControlOrOther,
+ keyboardEventMaskForPlatform,
+ type KeyboardEventModifierMask,
+ type KeyboardEventModifiers,
mountSlotContainer,
type OwnStaticNodeConfig,
removeFromParent,