[lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch - #8876
Open
mayrang wants to merge 23 commits into
Open
[lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch#8876mayrang wants to merge 23 commits into
mayrang wants to merge 23 commits into
Conversation
mayrang
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
July 24, 2026 01:51
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
reviewed
Jul 27, 2026
etrepum
left a comment
Collaborator
There was a problem hiding this comment.
There are also some conflicts to resolve with main
… 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
force-pushed
the
feat/keyboard-shortcuts-dispatch
branch
from
July 31, 2026 10:29
c581447 to
ef33dbf
Compare
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.
…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>
…e actions bar below the editor
… independent display
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR continues etrepum's WIP PR etrepum#18 — replacing the imperative if/else shortcut matching in
$handleKeyDownwith a compiledCompiledKeyboardShortcutstable that dispatches in O(1) via Map lookup keyed by"${modifierBits}:${key.toLowerCase()}", with abyCodefallback for non-Latin keyboard layouts.What changed
packages/lexical/src/LexicalKeyboardShortcuts.ts(new) — extracted fromLexicalUtils.ts. ContainsCompiledKeyboardShortcuts<S>,compileKeyboardShortcuts,registerKeyboardShortcuts,KeyboardShortcut/KeyboardShortcutMatchinterfaces, andCONTROL_OR_META/CONTROL_OR_ALTmodifier masks.packages/lexical/src/LexicalEvents.ts— the ~90-lineif/else ifchain in$handleKeyDownis replaced with a compiled shortcut table using factory helpers (dispatch,prevent,enter,copyOrCut). The 26is*predicate functions inLexicalUtils.tsare removed (they were internal, never exported).packages/lexical-extension/src/KeyboardShortcutsExtension.ts(new) — extension framework integration. Provides signal-based runtime remapping,$disabled/$dispatchcallbacks on individual shortcuts, andmergeConfigfor name-based shortcut overlay across the extension graph.packages/lexical-playground/src/plugins/ShortcutsExtension/(new, replacesShortcutsPlugin/) — migrates the playground's 26 shortcut actions from a React component to an extension. All action callbacks usefromEditor(the editor that originatedKEY_DOWN_COMMAND) instead of a closure-capturededitorprop, which is more correct in nested editor setups.Backwards compatibility
No BC concerns. All removed
is*predicate functions were internal (never exported fromlexical/src/index.ts).KeyboardEventModifierMaskis newly exported (was internal). All new symbols are additive exports.Test plan
pnpm test-unit— 3996 passkeyDownDispatchParity.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,$disabledcallback,$dispatchoverride, signal-based runtime remapping