Skip to content

[lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch - #8876

Open
mayrang wants to merge 23 commits into
facebook:mainfrom
mayrang:feat/keyboard-shortcuts-dispatch
Open

[lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch#8876
mayrang wants to merge 23 commits into
facebook:mainfrom
mayrang:feat/keyboard-shortcuts-dispatch

Conversation

@mayrang

@mayrang mayrang commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

This PR continues etrepum's WIP PR etrepum#18 — replacing the imperative if/else shortcut matching in $handleKeyDown with a compiled CompiledKeyboardShortcuts table that dispatches in O(1) via Map lookup keyed by "${modifierBits}:${key.toLowerCase()}", with a byCode fallback for non-Latin keyboard layouts.

What changed

packages/lexical/src/LexicalKeyboardShortcuts.ts (new) — extracted from LexicalUtils.ts. Contains CompiledKeyboardShortcuts<S>, compileKeyboardShortcuts, registerKeyboardShortcuts, KeyboardShortcut / KeyboardShortcutMatch interfaces, and CONTROL_OR_META / CONTROL_OR_ALT modifier masks.

packages/lexical/src/LexicalEvents.ts — the ~90-line if/else if chain in $handleKeyDown is replaced with a compiled shortcut table using factory helpers (dispatch, prevent, enter, copyOrCut). The 26 is* predicate functions in LexicalUtils.ts are removed (they were internal, never exported).

packages/lexical-extension/src/KeyboardShortcutsExtension.ts (new) — extension framework integration. Provides signal-based runtime remapping, $disabled / $dispatch callbacks on individual shortcuts, and mergeConfig for name-based shortcut overlay across the extension graph.

packages/lexical-playground/src/plugins/ShortcutsExtension/ (new, replaces ShortcutsPlugin/) — migrates the playground's 26 shortcut actions from a React component to an extension. All action callbacks use fromEditor (the editor that originated KEY_DOWN_COMMAND) instead of a closure-captured editor prop, which is more correct in nested editor setups.

Backwards compatibility

No BC concerns. All removed is* predicate functions were internal (never exported from lexical/src/index.ts). KeyboardEventModifierMask is newly exported (was internal). All new symbols are additive exports.

Test plan

  • pnpm test-unit — 3996 pass
  • e2e (chromium) — 771 pass
  • Parity test (keyDownDispatchParity.ts): verifies the compiled table matches the old if/else chain for 25 keys × 16 modifier combinations on both Apple and non-Apple platforms, including non-Latin layout fallback ('и'/KeyB, 'я'/KeyZ)
  • KeyboardShortcutsExtension.test.ts: shortcut dispatch, $disabled callback, $dispatch override, signal-based runtime remapping
  • Manual playground testing (Chrome, Safari, Firefox on macOS): 43 scenarios covering block format, lists, text format, alignment, font size, indent, link, comment, and core shortcuts (bold/italic/underline/undo/redo/tab/enter). All 43 scenarios behave identically to playground.lexical.dev (main).
  • tsc / flow / prettier / eslint clean

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Aug 4, 2026 7:10pm
lexical-playground Ready Ready Preview Aug 4, 2026 7:10pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jul 24, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jul 24, 2026

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are also some conflicts to resolve with main

Comment thread packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts Outdated
Comment thread packages/lexical-playground/src/plugins/ShortcutsExtension/index.ts Outdated
Comment thread packages/lexical-playground/src/plugins/ShortcutsExtension/shortcuts.ts Outdated
Comment thread packages/lexical/src/LexicalEvents.ts Outdated
claude and others added 11 commits July 31, 2026 03:31
… in LexicalEvents

Move the compiled keyboard shortcut dispatcher into the lexical core
(LexicalKeyboardShortcuts.ts) so that the editor's own $handleKeyDown
uses the same code as KeyboardShortcutsExtension. The 28-branch
predicate chain in LexicalEvents is now a declarative table compiled
once (lazily) into the O(1) modifier-bitmask + key lookup, and the ~30
single-use is* predicates it called (isBold, isMoveForward, isRedo,
etc.) are deleted from LexicalUtils along with their CONTROL_OR_META /
CONTROL_OR_ALT constants.

- lexical: add CompiledKeyboardShortcuts, compileKeyboardShortcuts,
  registerKeyboardShortcuts, and the KeyboardShortcut types to the
  public API; rewrite $handleKeyDown on the compiled table.
- @lexical/extension: KeyboardShortcutsExtension now reuses the core
  implementation and re-exports it, shrinking the extension module to
  just the named-table config/overlay logic.
- Parity is verified by an exhaustive test that transcribes the old
  predicate chain and compares dispatched commands, payloads, and
  preventDefault against the new table for 25 key/code pairs x 16
  modifier states on both Apple and non-Apple platforms (including
  non-Latin layout code fallbacks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… jsx build

The build pipeline parses .ts modules with the JSX plugin enabled, so
the generic arrow function `<T>(command, payload) => ...` in
buildKeyDownShortcuts was misparsed as a JSX tag and failed
`pnpm run build` in CI (tsc, eslint, and vitest all accepted it).
Use a function declaration instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llup build

@babel/preset-react unconditionally enables JSX parsing for every file
it processes, so applying it to all extensions made the build parse .ts
modules as TSX, where a generic arrow function like `<T>(x: T) => x`
is misparsed as a JSX opening tag (tsc accepts it in .ts, so only the
build failed). Move the preset into a babel overrides block gated on
/\.[jt]sx$/ so .ts is parsed as plain TypeScript, and restore the
generic arrow in buildKeyDownShortcuts that previously had to be
written as a function declaration to work around this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the repeated {key, modifiers, onMatch} object literals in
buildKeyDownShortcuts with dispatch/prevent/enter/copyOrCut factories
so each binding is a one-line call; only the three entries with
genuinely custom behavior (Backspace, select-all, Apple Ctrl+O) remain
literals. Also derive getEventModifierBits from the shared
MODIFIER_BITS table instead of repeating each modifier constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed shortcuts with $disabled and $dispatch

Redesign KeyboardShortcut as pure data: a shortcut now maps a key
binding to a LexicalCommand<KeyboardEvent> that is dispatched with the
matched event as its payload, instead of holding an arbitrary handler
function. This keeps the table declarative - it can be extracted to
build a shortcuts menu (with the new formatKeyboardShortcut display
helper and optional description field), remapped through the
KeyboardShortcutsExtension signal, and the behavior lives in command
listeners that menus and buttons can share. Two escape hatches:
$disabled(selection, editor) is checked before dispatch (and can drive
disabled menu items), and $dispatch(command, event, $next, editor) is
optional middleware for shortcuts that must run additional code around
the dispatch without defining a wrapper command.

- lexical: export CONTROL_OR_META and CONTROL_OR_ALT (used by the core
  keydown table, the playground, and most shortcut definitions) and
  formatKeyboardShortcut.
- lexical-playground: migrate ShortcutsPlugin (React) to a
  ShortcutsExtension (no React): each action is a
  SHORTCUT_COMMANDS[name] command registered by the extension, the key
  bindings are contributed to the KeyboardShortcutsExtension signal
  table, blockType/fontSize/isLink are derived from the editor state
  instead of ToolbarContext, and the insert-link shortcut publishes an
  isLinkEditMode signal that Editor.tsx consumes in place of its React
  state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and move formatKeyboardShortcut

registerKeyboardShortcuts now calls the internal dispatchCommand
primitive instead of editor.dispatchCommand, and binds $next with
Function.prototype.bind (only when a $dispatch middleware is present)
instead of allocating an arrow closure per dispatch. Move
formatKeyboardShortcut and FormatKeyboardShortcutOptions out of the
lexical core into @lexical/extension - display formatting for menus
doesn't need to bloat the core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A generator is unnecessary here since the match lists are so cheap to
materialize; an array is simpler for callers and menu builders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch(), polish JSDoc

- registerKeyboardShortcuts: use fromEditor (the editor where
  KEY_DOWN_COMMAND originated) for $disabled, $dispatch, and command
  dispatch so nested-editor shortcuts target the correct editor
- Playground ShortcutsExtension: forward fromEditor to all 26 action
  callbacks instead of capturing the registration editor
- CompiledKeyboardShortcuts.match(): direct Map lookup instead of
  allocating via matches()[0]
- KeyboardEventModifierMask: exclude 'code' (not a modifier)
- bind(null, ...) → arrow closure for $next in $dispatch
- options.priority ?? COMMAND_PRIORITY_NORMAL
- JSDoc: @param tags for $disabled/$dispatch, @see cross-refs,
  {@link} consistency
- Add $dispatch false fall-through test
…view findings

- Deduplicate match() by delegating to matches()[0]
- Add invariant for empty key in CompiledKeyboardShortcuts.add()
- Move event.preventDefault() after action in playground listen()
- Flatten nested ternary in formatKeyboardShortcut
…export

- Add missing @param tags for $dispatch (command, event)
- Replace cross-package {@link} with plain text reference
- Add JSDoc to KeyboardShortcutsConfig.disabled and shortcuts fields
- Add @returns tag to $disabled
- Broaden CONTROL_OR_ALT description to cover block-format shortcuts
- Export CompiledKeyboardShortcuts as type-only (class convention)
…ived shortcuts, per-editor cache

- Extract $-prefixed format functions in ToolbarPlugin/utils.ts so
  ShortcutsExtension can call them directly inside command listeners
  without an editor.update() wrapper
- Remove redundant undefined from void-payload dispatchCommand calls
- Derive SHORTCUTS display strings from SHORTCUT_BINDINGS via
  formatKeyboardShortcut(), eliminating duplicated key definitions
- Move module-global keyDownShortcuts to a per-editor WeakMap
mayrang added 2 commits August 2, 2026 03:38
Kept isEscape, isDelete, isSelectAll from main. Inlined CONTROL_OR_META
in isSelectAll to avoid circular import with LexicalKeyboardShortcuts.
…yDownShortcut

Move the compiled keydown shortcut table from a module-level WeakMap to
`editor._keyDownShortcuts` so the keyboard map is introspectable on the
editor instance. Export `KeyDownShortcut` as an `@internal` type.
etrepum and others added 6 commits August 2, 2026 12:11
…t delegation, make bubbling explicit (disabled by default)
Our branch extracted the body of clearFormatting into the $clearFormatting
$function; main (facebook#8889) appended a reset of the format/style cached on the
RangeSelection. Kept both: the reset now lives at the end of
$clearFormatting, still inside the range/table selection branch and after
the collapsed-selection early return, so clearFormatting remains the thin
editor.update wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onfigured more than once

LexicalBuilder.addEdge keyed outgoing config edges by target extension
name and overwrote the entry, so an extension that reached the same
dependency twice — two configExtension entries for it, or both a direct
and a peer dependency — silently kept only the last config and never
merged the rest.

Append to the existing entry instead. The configs parameter was already
a fresh array at both call sites; document that ownership passes to the
builder so that stays true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… default and drop shortcut delegation

The default KEY_DOWN_COMMAND priority had moved to
COMMAND_PRIORITY_BEFORE_EDITOR, which made bubbleFromNestedEditors
impossible to satisfy: triggerCommandListeners 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. A
nested editor therefore ends the dispatch before anything the parent has
in the editor-priority queue, and BEFORE_EDITOR is the front of exactly
that queue. Restore COMMAND_PRIORITY_NORMAL and document the floor on
both the config field and the bubbleFromNestedEditors option.

Also drop the implicit delegation from mergeNamedShortcuts: configuring
an existing name now replaces its mapping outright, as a shallow merge
would. The one thing that still differs from shallowMergeConfig is that
overriding names come first in entry iteration so they are also matched
first, which is now the only behavior that function documents.

Adds unit tests for the shortcut table merge, the listener priority, and
nested editor bubbling, which had no coverage at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A markdown link to AGENTS.md is only prose an agent may or may not follow,
and CLAUDE.md is the file that is loaded automatically. Use the @path
import syntax so AGENTS.md is inlined into context instead, and drop the
surrounding boilerplate, which described the file rather than saying
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running ESLint over a file that adds an invariant rewrites codes.json as
a side effect, even without --fix, so it keeps turning up in unrelated
diffs. Now that AGENTS.md is loaded into agent context, say so there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants