From 3359e9c6f0cc48d95355b25f42dc5b6eaca4489b Mon Sep 17 00:00:00 2001 From: mayrang Date: Thu, 13 Aug 2026 20:57:40 +0000 Subject: [PATCH] [lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch (#8876) Co-authored-by: Claude Co-authored-by: Bob Ippolito --- AGENTS.md | 4 + .../src/extensions/MdastFootnoteExtension.ts | 5 +- .../unit/CodeImportExtension.test.ts | 1 + .../src/KeyboardShortcutsExtension.ts | 289 +++++ .../lexical-extension/src/LexicalBuilder.ts | 16 +- .../src/PreventSelectAllExtension.ts | 4 +- .../unit/KeyboardShortcutsExtension.test.ts | 993 ++++++++++++++++++ .../src/__tests__/unit/LexicalBuilder.test.ts | 57 + packages/lexical-extension/src/index.ts | 13 + .../__tests__/keyboardShortcuts/index.mjs | 8 +- packages/lexical-playground/src/App.tsx | 2 + packages/lexical-playground/src/Editor.tsx | 36 +- .../src/images/icons/apple.svg | 1 + .../src/images/icons/keyboard.svg | 1 + .../src/images/icons/windows.svg | 1 + packages/lexical-playground/src/index.css | 5 + .../src/plugins/ActionsPlugin/index.tsx | 11 + .../plugins/FindReplaceExtension/index.tsx | 7 +- .../ShortcutsHelpDialog.css | 137 +++ .../ShortcutsHelpDialog.tsx | 91 ++ .../src/plugins/ShortcutsExtension/index.ts | 235 +++++ .../plugins/ShortcutsExtension/shortcuts.ts | 136 +++ .../ShortcutsPlugin/ShortcutsHelpDialog.tsx | 46 - .../src/plugins/ShortcutsPlugin/index.tsx | 163 --- .../src/plugins/ShortcutsPlugin/shortcuts.ts | 206 ---- .../src/plugins/ToolbarPlugin/fontSize.tsx | 9 +- .../src/plugins/ToolbarPlugin/index.tsx | 84 +- .../src/plugins/ToolbarPlugin/utils.ts | 344 +++--- packages/lexical-playground/src/ui/Modal.css | 1 + .../lexical-table/src/LexicalTableNode.ts | 2 +- .../unit/LexicalTableSelectionHelpers.test.ts | 3 + .../__tests__/unit/LexicalTableUtils.test.ts | 1 + .../unit/TableImportExtension.test.ts | 1 + packages/lexical/src/LexicalConstants.ts | 8 + packages/lexical/src/LexicalEditor.ts | 5 + packages/lexical/src/LexicalEvents.ts | 294 ++++-- .../lexical/src/LexicalKeyboardShortcuts.ts | 285 +++++ packages/lexical/src/LexicalUtils.ts | 174 +-- .../unit/LexicalEventsKeyDown.test.ts | 20 + .../unit/LexicalEventsKeyDownApple.test.ts | 20 + .../src/__tests__/unit/LexicalUtils.test.ts | 43 - .../__tests__/unit/keyDownDispatchParity.ts | 281 +++++ packages/lexical/src/index.ts | 15 +- 43 files changed, 3090 insertions(+), 968 deletions(-) create mode 100644 packages/lexical-extension/src/KeyboardShortcutsExtension.ts create mode 100644 packages/lexical-extension/src/__tests__/unit/KeyboardShortcutsExtension.test.ts create mode 100644 packages/lexical-playground/src/images/icons/apple.svg create mode 100644 packages/lexical-playground/src/images/icons/keyboard.svg create mode 100644 packages/lexical-playground/src/images/icons/windows.svg create mode 100644 packages/lexical-playground/src/plugins/ShortcutsExtension/ShortcutsHelpDialog.css create mode 100644 packages/lexical-playground/src/plugins/ShortcutsExtension/ShortcutsHelpDialog.tsx create mode 100644 packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts create mode 100644 packages/lexical-playground/src/plugins/ShortcutsExtension/shortcuts.ts delete mode 100644 packages/lexical-playground/src/plugins/ShortcutsPlugin/ShortcutsHelpDialog.tsx delete mode 100644 packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx delete mode 100644 packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts create mode 100644 packages/lexical/src/LexicalKeyboardShortcuts.ts create mode 100644 packages/lexical/src/__tests__/unit/LexicalEventsKeyDown.test.ts create mode 100644 packages/lexical/src/__tests__/unit/LexicalEventsKeyDownApple.test.ts create mode 100644 packages/lexical/src/__tests__/unit/keyDownDispatchParity.ts 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 && ( + ))} +
+
+ + + + + + + + {Object.entries(getShortcuts(isApple)).map(([action, keys]) => ( + + + + + ))} + +
ActionShortcut
+ {humanize(action)} + + {keys.map((v, i) => ( + + {i > 0 && ( + + )} + {v} + + ))} +
+ + ); +} diff --git a/packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts b/packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts new file mode 100644 index 00000000000..2a36d488375 --- /dev/null +++ b/packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts @@ -0,0 +1,235 @@ +/** + * 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 { + KeyboardShortcutsExtension, + type NamedKeyboardShortcuts, + signal, +} from '@lexical/extension'; +import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {$isListNode, ListNode} from '@lexical/list'; +import {$isHeadingNode} from '@lexical/rich-text'; +import {$getSelectionStyleValueForProperty} from '@lexical/selection'; +import { + $findMatchingParent, + $getNearestNodeOfType, + mergeRegister, +} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + $isRootOrShadowRoot, + COMMAND_PRIORITY_EDITOR, + configExtension, + createCommand, + defineExtension, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + INDENT_CONTENT_COMMAND, + type LexicalCommand, + type LexicalEditor, + type LexicalNode, + OUTDENT_CONTENT_COMMAND, +} from 'lexical'; + +import {DEFAULT_FONT_SIZE} from '../../context/ToolbarContext'; +import {getSelectedNode} from '../../utils/getSelectedNode'; +import {sanitizeUrl} from '../../utils/url'; +import {INSERT_INLINE_COMMAND} from '../CommentPlugin'; +import { + $clearFormatting, + $formatBulletList, + $formatCheckList, + $formatCode, + $formatHeading, + $formatNumberedList, + $formatParagraph, + $formatQuote, + $updateFontSize, + UpdateFontSizeType, +} from '../ToolbarPlugin/utils'; +import {SHORTCUT_BINDINGS} from './shortcuts'; + +export type ShortcutName = keyof typeof SHORTCUT_BINDINGS; + +const SHORTCUT_NAMES = Object.keys(SHORTCUT_BINDINGS) as ShortcutName[]; + +/** + * One command per shortcut action, dispatched with the matched + * KeyboardEvent as its payload. The key bindings in + * {@link SHORTCUT_BINDINGS} are pure data mapping keystrokes to these + * commands, so shortcuts can be remapped (or dispatched from menus and + * buttons) without touching the behavior registered by + * {@link ShortcutsExtension}. + */ +const SHORTCUT_COMMANDS: Record< + ShortcutName, + LexicalCommand +> = Object.fromEntries( + SHORTCUT_NAMES.map(name => [ + name, + createCommand(`@lexical/playground/Shortcuts/${name}`), + ]), +) as Record>; + +function buildShortcuts(): NamedKeyboardShortcuts { + return Object.fromEntries( + SHORTCUT_NAMES.map(name => [ + name, + {...SHORTCUT_BINDINGS[name], command: SHORTCUT_COMMANDS[name]}, + ]), + ); +} + +function $findTopLevelElement(node: LexicalNode): LexicalNode { + let topLevelElement = + node.getKey() === 'root' + ? node + : $findMatchingParent(node, e => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }); + if (topLevelElement === null) { + topLevelElement = node.getTopLevelElementOrThrow(); + } + return topLevelElement; +} + +/** + * The block type of the current selection (mirrors the computation the + * toolbar uses for its block format dropdown) + */ +function $getBlockType(): string { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return ''; + } + const anchorNode = selection.anchor.getNode(); + const element = $findTopLevelElement(anchorNode); + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + return parentList ? parentList.getListType() : element.getListType(); + } + return $isHeadingNode(element) ? element.getTag() : element.getType(); +} + +function $getFontSizeInputValue(): string { + const selection = $getSelection(); + return $isRangeSelection(selection) + ? $getSelectionStyleValueForProperty( + selection, + 'font-size', + `${DEFAULT_FONT_SIZE}px`, + ).slice(0, -2) + : `${DEFAULT_FONT_SIZE}`; +} + +/** + * Registers the playground's keyboard shortcuts through + * {@link KeyboardShortcutsExtension}: the key bindings are contributed to + * its named shortcut table (a signal, so they can be remapped or disabled + * at runtime), and this extension registers the command listeners that + * implement each action. + * + * The output exposes an `isLinkEditMode` signal that the link shortcut + * sets, consumed by the floating link editor UI. + */ +export const ShortcutsExtension = /* @__PURE__ */ defineExtension({ + build() { + return {isLinkEditMode: /* @__PURE__ */ signal(false)}; + }, + dependencies: [ + /* @__PURE__ */ configExtension(KeyboardShortcutsExtension, { + shortcuts: /* @__PURE__ */ buildShortcuts(), + }), + ], + name: '@lexical/playground/Shortcuts', + register(editor, config, state) { + const {isLinkEditMode} = state.getOutput(); + const listen = ( + name: ShortcutName, + $onShortcut: (fromEditor: LexicalEditor) => void, + ) => + editor.registerCommand( + SHORTCUT_COMMANDS[name], + (event, fromEditor) => { + $onShortcut(fromEditor); + event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + return mergeRegister( + listen('NORMAL', () => $formatParagraph()), + listen('HEADING1', () => $formatHeading($getBlockType(), 'h1')), + listen('HEADING2', () => $formatHeading($getBlockType(), 'h2')), + listen('HEADING3', () => $formatHeading($getBlockType(), 'h3')), + listen('NUMBERED_LIST', () => $formatNumberedList($getBlockType())), + listen('BULLET_LIST', () => $formatBulletList($getBlockType())), + listen('CHECK_LIST', () => $formatCheckList($getBlockType())), + listen('CODE_BLOCK', () => $formatCode($getBlockType())), + listen('QUOTE', () => $formatQuote($getBlockType())), + listen('ADD_COMMENT', e => e.dispatchCommand(INSERT_INLINE_COMMAND)), + listen('INCREASE_FONT_SIZE', () => + $updateFontSize(UpdateFontSizeType.increment, $getFontSizeInputValue()), + ), + listen('DECREASE_FONT_SIZE', () => + $updateFontSize(UpdateFontSizeType.decrement, $getFontSizeInputValue()), + ), + listen('INSERT_CODE_BLOCK', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'), + ), + listen('STRIKETHROUGH', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'), + ), + listen('LOWERCASE', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'lowercase'), + ), + listen('UPPERCASE', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'uppercase'), + ), + listen('CAPITALIZE', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'capitalize'), + ), + listen('CENTER_ALIGN', e => + e.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'), + ), + listen('JUSTIFY_ALIGN', e => + e.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'), + ), + listen('LEFT_ALIGN', e => + e.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'), + ), + listen('RIGHT_ALIGN', e => + e.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'), + ), + listen('SUBSCRIPT', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript'), + ), + listen('SUPERSCRIPT', e => + e.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'), + ), + listen('INDENT', e => e.dispatchCommand(INDENT_CONTENT_COMMAND)), + listen('OUTDENT', e => e.dispatchCommand(OUTDENT_CONTENT_COMMAND)), + listen('CLEAR_FORMATTING', () => $clearFormatting()), + listen('INSERT_LINK', e => { + const selection = $getSelection(); + let isLink = false; + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + isLink = $isLinkNode(node) || $isLinkNode(node.getParent()); + } + isLinkEditMode.value = !isLink; + e.dispatchCommand( + TOGGLE_LINK_COMMAND, + isLink ? null : sanitizeUrl('https://'), + ); + }), + ); + }, +}); diff --git a/packages/lexical-playground/src/plugins/ShortcutsExtension/shortcuts.ts b/packages/lexical-playground/src/plugins/ShortcutsExtension/shortcuts.ts new file mode 100644 index 00000000000..93c201ed152 --- /dev/null +++ b/packages/lexical-playground/src/plugins/ShortcutsExtension/shortcuts.ts @@ -0,0 +1,136 @@ +/** + * 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 {KeyboardShortcutMatch} from 'lexical'; + +import {formatKeyboardShortcut} from '@lexical/extension'; +import {CONTROL_OR_META, IS_APPLE} from 'lexical'; + +/* eslint-disable sort-keys-fix/sort-keys-fix */ + +const CONTROL_OR_META_ALT = {...CONTROL_OR_META, altKey: true}; +const CONTROL_OR_META_SHIFT = {...CONTROL_OR_META, shiftKey: true}; +const CONTROL_SHIFT = {ctrlKey: true, shiftKey: true}; + +/** + * The key bindings for the shortcuts that ShortcutsExtension handles (a + * subset of the {@link SHORTCUTS} display strings below; the rest are + * handled by the rich-text and history extensions). Each binding is paired + * with its command in the ShortcutsExtension and contributed to the + * KeyboardShortcutsExtension table, which compiles them down to an O(1) + * dispatch by key and modifiers. + */ +export const SHORTCUT_BINDINGS = Object.freeze({ + NORMAL: {key: '0', modifiers: CONTROL_OR_META_ALT}, + HEADING1: {key: '1', modifiers: CONTROL_OR_META_ALT}, + HEADING2: {key: '2', modifiers: CONTROL_OR_META_ALT}, + HEADING3: {key: '3', modifiers: CONTROL_OR_META_ALT}, + NUMBERED_LIST: {key: '7', modifiers: CONTROL_OR_META_SHIFT}, + BULLET_LIST: {key: '8', modifiers: CONTROL_OR_META_SHIFT}, + CHECK_LIST: {key: '9', modifiers: CONTROL_OR_META_SHIFT}, + CODE_BLOCK: {key: 'c', modifiers: CONTROL_OR_META_ALT}, + QUOTE: {key: 'q', modifiers: CONTROL_SHIFT}, + ADD_COMMENT: {key: 'm', modifiers: CONTROL_OR_META_ALT}, + + INCREASE_FONT_SIZE: { + key: '>', + modifiers: CONTROL_OR_META_SHIFT, + unshiftedKey: '.', + }, + DECREASE_FONT_SIZE: { + key: '<', + modifiers: CONTROL_OR_META_SHIFT, + unshiftedKey: ',', + }, + INSERT_CODE_BLOCK: {key: 'c', modifiers: CONTROL_OR_META_SHIFT}, + STRIKETHROUGH: {key: 'x', modifiers: CONTROL_OR_META_SHIFT}, + LOWERCASE: {key: '1', modifiers: CONTROL_SHIFT}, + UPPERCASE: {key: '2', modifiers: CONTROL_SHIFT}, + CAPITALIZE: {key: '3', modifiers: CONTROL_SHIFT}, + CENTER_ALIGN: {key: 'e', modifiers: CONTROL_OR_META_SHIFT}, + JUSTIFY_ALIGN: {key: 'j', modifiers: CONTROL_OR_META_SHIFT}, + LEFT_ALIGN: {key: 'l', modifiers: CONTROL_OR_META_SHIFT}, + RIGHT_ALIGN: {key: 'r', modifiers: CONTROL_OR_META_SHIFT}, + + SUBSCRIPT: {key: ',', modifiers: CONTROL_OR_META}, + SUPERSCRIPT: {key: '.', modifiers: CONTROL_OR_META}, + INDENT: {key: ']', modifiers: CONTROL_OR_META}, + OUTDENT: {key: '[', modifiers: CONTROL_OR_META}, + CLEAR_FORMATTING: {key: '\\', modifiers: CONTROL_OR_META}, + INSERT_LINK: {key: 'k', modifiers: CONTROL_OR_META}, +}) satisfies Record; + +const BUILTIN_SHORTCUT_BINDINGS = Object.freeze({ + // Core shortcuts handled by the editor, not by ShortcutsExtension + BOLD: {key: 'b', modifiers: CONTROL_OR_META}, + ITALIC: {key: 'i', modifiers: CONTROL_OR_META}, + UNDERLINE: {key: 'u', modifiers: CONTROL_OR_META}, + UNDO: {key: 'z', modifiers: CONTROL_OR_META}, + REDO: {key: 'z', modifiers: {...CONTROL_OR_META, shiftKey: true}}, +}) satisfies Record; +const BUILTIN_SHORTCUT_BINDINGS_APPLE = Object.freeze({}) satisfies Record< + string, + KeyboardShortcutMatch +>; +const BUILTIN_SHORTCUT_BINDINGS_OTHER = Object.freeze({ + REDO: { + key: 'y', + modifiers: {ctrlKey: true}, + }, +}) satisfies Record; + +type TypedEntries = {[K in keyof T]: [K, T[K]]}[keyof T][]; +function typedEntries(o: T): TypedEntries { + return Object.entries(o) as TypedEntries; +} + +type ShortcutKey = + | keyof typeof SHORTCUT_BINDINGS + | keyof typeof BUILTIN_SHORTCUT_BINDINGS + | keyof typeof BUILTIN_SHORTCUT_BINDINGS_APPLE + | keyof typeof BUILTIN_SHORTCUT_BINDINGS_OTHER; + +function _getShortcuts(isApple: boolean) { + const entries: [ShortcutKey, KeyboardShortcutMatch][] = [ + ...typedEntries(SHORTCUT_BINDINGS), + ...typedEntries(BUILTIN_SHORTCUT_BINDINGS), + ...typedEntries( + isApple + ? BUILTIN_SHORTCUT_BINDINGS_APPLE + : BUILTIN_SHORTCUT_BINDINGS_OTHER, + ), + ]; + return Object.freeze({ + ...(Object.fromEntries( + entries.map(([k, v]) => [k, formatKeyboardShortcut(v, {isApple})]), + ) as Record), + }); +} + +/** + * Human-readable display string segments derived from {@link SHORTCUT_BINDINGS}, + * used for button tooltips, aria-labels, and menu hints. + */ +export const SHORTCUTS = _getShortcuts(IS_APPLE); +/** + * {@link SHORTCUTS} formatted for the other platform (!IS_APPLE) + */ +export const ALTERNATE_SHORTCUTS = _getShortcuts(!IS_APPLE); + +export function getShortcuts(isApple: boolean) { + return IS_APPLE === isApple ? SHORTCUTS : ALTERNATE_SHORTCUTS; +} + +const SEPARATOR = IS_APPLE ? '' : '+'; + +/** + * Return the human-readable flat string for a key of {@link SHORTCUT_BINDINGS} + */ +export function shortcut(k: keyof typeof SHORTCUTS) { + return SHORTCUTS[k].join(SEPARATOR); +} diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/ShortcutsHelpDialog.tsx b/packages/lexical-playground/src/plugins/ShortcutsPlugin/ShortcutsHelpDialog.tsx deleted file mode 100644 index b0e08ceacc7..00000000000 --- a/packages/lexical-playground/src/plugins/ShortcutsPlugin/ShortcutsHelpDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * 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 {JSX} from 'react'; - -import {SHORTCUTS} from './shortcuts'; - -function humanize(key: string): string { - return key - .toLowerCase() - .replace(/_/g, ' ') - .replace(/(\d+)/g, ' $1') - .trim() - .replace(/^(\w)/, c => c.toUpperCase()); -} - -export default function ShortcutsHelpDialog(): JSX.Element { - const entries = Object.entries(SHORTCUTS); - return ( -
- - - - - - - - - {entries.map(([action, keys]) => ( - - - - - ))} - -
ActionShortcut
{humanize(action)} - {keys} -
-
- ); -} diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx deleted file mode 100644 index 8750210a412..00000000000 --- a/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 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 {TOGGLE_LINK_COMMAND} from '@lexical/link'; -import { - COMMAND_PRIORITY_NORMAL, - FORMAT_ELEMENT_COMMAND, - FORMAT_TEXT_COMMAND, - INDENT_CONTENT_COMMAND, - isModifierMatch, - KEY_DOWN_COMMAND, - type LexicalEditor, - OUTDENT_CONTENT_COMMAND, -} from 'lexical'; -import {type Dispatch, useEffect} from 'react'; - -import {useToolbarState} from '../../context/ToolbarContext'; -import {sanitizeUrl} from '../../utils/url'; -import {INSERT_INLINE_COMMAND} from '../CommentPlugin'; -import { - clearFormatting, - formatBulletList, - formatCheckList, - formatCode, - formatHeading, - formatNumberedList, - formatParagraph, - formatQuote, - updateFontSize, - UpdateFontSizeType, -} from '../ToolbarPlugin/utils'; -import { - getFormatHeading, - isAddComment, - isCapitalize, - isCenterAlign, - isClearFormatting, - isDecreaseFontSize, - isFormatBulletList, - isFormatCheckList, - isFormatCode, - isFormatNumberedList, - isFormatParagraph, - isFormatQuote, - isIncreaseFontSize, - isIndent, - isInsertCodeBlock, - isInsertLink, - isJustifyAlign, - isLeftAlign, - isLowercase, - isOutdent, - isRightAlign, - isStrikeThrough, - isSubscript, - isSuperscript, - isUppercase, -} from './shortcuts'; - -export default function ShortcutsPlugin({ - editor, - setIsLinkEditMode, -}: { - editor: LexicalEditor; - setIsLinkEditMode: Dispatch; -}): null { - const {toolbarState} = useToolbarState(); - - useEffect(() => { - const keyboardShortcutsHandler = (event: KeyboardEvent) => { - // Short-circuit, a least one modifier must be set - if (isModifierMatch(event, {})) { - return false; - } - const headingSize = getFormatHeading(event); - if (headingSize) { - formatHeading(editor, toolbarState.blockType, headingSize); - } else if (isFormatParagraph(event)) { - formatParagraph(editor); - } else if (isFormatBulletList(event)) { - formatBulletList(editor, toolbarState.blockType); - } else if (isFormatNumberedList(event)) { - formatNumberedList(editor, toolbarState.blockType); - } else if (isFormatCheckList(event)) { - formatCheckList(editor, toolbarState.blockType); - } else if (isFormatCode(event)) { - formatCode(editor, toolbarState.blockType); - } else if (isFormatQuote(event)) { - formatQuote(editor, toolbarState.blockType); - } else if (isStrikeThrough(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); - } else if (isLowercase(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'lowercase'); - } else if (isUppercase(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'uppercase'); - } else if (isCapitalize(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'capitalize'); - } else if (isIndent(event)) { - editor.dispatchCommand(INDENT_CONTENT_COMMAND); - } else if (isOutdent(event)) { - editor.dispatchCommand(OUTDENT_CONTENT_COMMAND); - } else if (isCenterAlign(event)) { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); - } else if (isLeftAlign(event)) { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); - } else if (isRightAlign(event)) { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'); - } else if (isJustifyAlign(event)) { - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'); - } else if (isSubscript(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript'); - } else if (isSuperscript(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); - } else if (isInsertCodeBlock(event)) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); - } else if (isIncreaseFontSize(event)) { - updateFontSize( - editor, - UpdateFontSizeType.increment, - toolbarState.fontSizeInputValue, - ); - } else if (isDecreaseFontSize(event)) { - updateFontSize( - editor, - UpdateFontSizeType.decrement, - toolbarState.fontSizeInputValue, - ); - } else if (isClearFormatting(event)) { - clearFormatting(editor); - } else if (isInsertLink(event)) { - const url = toolbarState.isLink ? null : sanitizeUrl('https://'); - setIsLinkEditMode(!toolbarState.isLink); - editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); - } else if (isAddComment(event)) { - editor.dispatchCommand(INSERT_INLINE_COMMAND); - } else { - // No match for any of the event handlers - return false; - } - event.preventDefault(); - return true; - }; - - return editor.registerCommand( - KEY_DOWN_COMMAND, - keyboardShortcutsHandler, - COMMAND_PRIORITY_NORMAL, - ); - }, [ - editor, - toolbarState.isLink, - toolbarState.blockType, - toolbarState.fontSizeInputValue, - setIsLinkEditMode, - ]); - - return null; -} diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts b/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts deleted file mode 100644 index 283cf0bb14c..00000000000 --- a/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * 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 {HeadingTagType} from '@lexical/rich-text'; - -import {IS_APPLE, isExactShortcutMatch} from 'lexical'; - -//disable eslint sorting rule for quick reference to shortcuts -/* eslint-disable sort-keys-fix/sort-keys-fix */ -export const SHORTCUTS = Object.freeze({ - // (Ctrl|⌘) + (Alt|Option) + shortcuts - NORMAL: IS_APPLE ? '⌘+Opt+0' : 'Ctrl+Alt+0', - HEADING1: IS_APPLE ? '⌘+Opt+1' : 'Ctrl+Alt+1', - HEADING2: IS_APPLE ? '⌘+Opt+2' : 'Ctrl+Alt+2', - HEADING3: IS_APPLE ? '⌘+Opt+3' : 'Ctrl+Alt+3', - NUMBERED_LIST: IS_APPLE ? '⌘+Shift+7' : 'Ctrl+Shift+7', - BULLET_LIST: IS_APPLE ? '⌘+Shift+8' : 'Ctrl+Shift+8', - CHECK_LIST: IS_APPLE ? '⌘+Shift+9' : 'Ctrl+Shift+9', - CODE_BLOCK: IS_APPLE ? '⌘+Opt+C' : 'Ctrl+Alt+C', - QUOTE: IS_APPLE ? '⌃+Shift+Q' : 'Ctrl+Shift+Q', - ADD_COMMENT: IS_APPLE ? '⌘+Opt+M' : 'Ctrl+Alt+M', - - // (Ctrl|⌘) + Shift + shortcuts - INCREASE_FONT_SIZE: IS_APPLE ? '⌘+Shift+.' : 'Ctrl+Shift+.', - DECREASE_FONT_SIZE: IS_APPLE ? '⌘+Shift+,' : 'Ctrl+Shift+,', - INSERT_CODE_BLOCK: IS_APPLE ? '⌘+Shift+C' : 'Ctrl+Shift+C', - STRIKETHROUGH: IS_APPLE ? '⌘+Shift+X' : 'Ctrl+Shift+X', - LOWERCASE: IS_APPLE ? '⌃+Shift+1' : 'Ctrl+Shift+1', - UPPERCASE: IS_APPLE ? '⌃+Shift+2' : 'Ctrl+Shift+2', - CAPITALIZE: IS_APPLE ? '⌃+Shift+3' : 'Ctrl+Shift+3', - CENTER_ALIGN: IS_APPLE ? '⌘+Shift+E' : 'Ctrl+Shift+E', - JUSTIFY_ALIGN: IS_APPLE ? '⌘+Shift+J' : 'Ctrl+Shift+J', - LEFT_ALIGN: IS_APPLE ? '⌘+Shift+L' : 'Ctrl+Shift+L', - RIGHT_ALIGN: IS_APPLE ? '⌘+Shift+R' : 'Ctrl+Shift+R', - - // (Ctrl|⌘) + shortcuts - SUBSCRIPT: IS_APPLE ? '⌘+,' : 'Ctrl+,', - SUPERSCRIPT: IS_APPLE ? '⌘+.' : 'Ctrl+.', - INDENT: IS_APPLE ? '⌘+]' : 'Ctrl+]', - OUTDENT: IS_APPLE ? '⌘+[' : 'Ctrl+[', - CLEAR_FORMATTING: IS_APPLE ? '⌘+\\' : 'Ctrl+\\', - REDO: IS_APPLE ? '⌘+Shift+Z' : 'Ctrl+Y', - UNDO: IS_APPLE ? '⌘+Z' : 'Ctrl+Z', - BOLD: IS_APPLE ? '⌘+B' : 'Ctrl+B', - ITALIC: IS_APPLE ? '⌘+I' : 'Ctrl+I', - UNDERLINE: IS_APPLE ? '⌘+U' : 'Ctrl+U', - INSERT_LINK: IS_APPLE ? '⌘+K' : 'Ctrl+K', -}); - -const CONTROL_OR_META = {ctrlKey: !IS_APPLE, metaKey: IS_APPLE}; - -export function isFormatParagraph(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '0', { - ...CONTROL_OR_META, - altKey: true, - }); -} - -export function getFormatHeading( - event: KeyboardEvent, -): HeadingTagType | undefined { - for (const key of ['1', '2', '3'] as const) { - if (isExactShortcutMatch(event, key, {...CONTROL_OR_META, altKey: true})) { - return `h${key}`; - } - } -} - -export function isFormatNumberedList(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '7', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isFormatBulletList(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '8', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isFormatCheckList(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '9', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isFormatCode(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'c', { - ...CONTROL_OR_META, - altKey: true, - }); -} - -export function isFormatQuote(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'q', { - ctrlKey: true, - shiftKey: true, - }); -} - -export function isLowercase(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '1', {ctrlKey: true, shiftKey: true}); -} - -export function isUppercase(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '2', {ctrlKey: true, shiftKey: true}); -} - -export function isCapitalize(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '3', {ctrlKey: true, shiftKey: true}); -} - -export function isStrikeThrough(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'x', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isIndent(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, ']', CONTROL_OR_META); -} - -export function isOutdent(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '[', CONTROL_OR_META); -} - -export function isCenterAlign(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'e', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isLeftAlign(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'l', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isRightAlign(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'r', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isJustifyAlign(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'j', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isSubscript(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, ',', CONTROL_OR_META); -} - -export function isSuperscript(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '.', CONTROL_OR_META); -} - -export function isInsertCodeBlock(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'c', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isIncreaseFontSize(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '>', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isDecreaseFontSize(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '<', { - ...CONTROL_OR_META, - shiftKey: true, - }); -} - -export function isClearFormatting(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, '\\', CONTROL_OR_META); -} - -export function isInsertLink(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'k', CONTROL_OR_META); -} - -export function isAddComment(event: KeyboardEvent): boolean { - return isExactShortcutMatch(event, 'm', { - ...CONTROL_OR_META, - altKey: true, - }); -} diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx index e9e9087fb4e..725f2d14016 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.tsx @@ -6,10 +6,9 @@ * */ -import type {LexicalEditor} from 'lexical'; - import './fontSize.css'; +import {type LexicalEditor} from 'lexical'; import * as React from 'react'; import { @@ -17,7 +16,7 @@ import { MIN_ALLOWED_FONT_SIZE, } from '../../context/ToolbarContext'; import {isKeyboardInput} from '../../utils/focusUtils'; -import {SHORTCUTS} from '../ShortcutsPlugin/shortcuts'; +import {shortcut} from '../ShortcutsExtension/shortcuts'; import { updateFontSize, updateFontSizeInSelection, @@ -151,7 +150,7 @@ export default function FontSize({ }} className="toolbar-item font-decrement" aria-label="Decrease font size" - title={`Decrease font size (${SHORTCUTS.DECREASE_FONT_SIZE})`}> + title={`Decrease font size (${shortcut('DECREASE_FONT_SIZE')})`}> @@ -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')}`}> {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} /> - - {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,