Skip to content

Commit 3359e9c

Browse files
mayrangclaudeetrepum
authored
[lexical][lexical-extension][lexical-playground] Refactor: Compiled keyboard shortcut dispatch (#8876)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Bob Ippolito <bob@redivi.com>
1 parent 1fa5018 commit 3359e9c

43 files changed

Lines changed: 3090 additions & 968 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ For E2E testing workflow:
4040
- `pnpm run tsc` - Run TypeScript compiler
4141
- `pnpm run ci-check` - Run all checks (TypeScript, Flow, Prettier, ESLint)
4242

43+
**Never commit changes to `scripts/error-codes/codes.json`.**
44+
That edit is not yours to make — revert it to the state of
45+
`main` before staging, and never `git add` the file.
46+
4347
### Searching and refactoring
4448

4549
Prefer **ast-grep** over line-oriented regex (`grep`/`sed`) for anything

dev-examples/mdast-editor/src/extensions/MdastFootnoteExtension.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
COMMAND_PRIORITY_BEFORE_EDITOR,
6565
COMMAND_PRIORITY_EDITOR,
6666
configExtension,
67+
CONTROL_OR_META,
6768
createCommand,
6869
createState,
6970
defineExtension,
@@ -72,7 +73,6 @@ import {
7273
type ElementDOMSlot,
7374
ElementNode,
7475
HISTORIC_TAG,
75-
IS_APPLE,
7676
isExactShortcutMatch,
7777
isHTMLElement,
7878
KEY_DOWN_COMMAND,
@@ -1159,9 +1159,8 @@ export const MdastFootnoteExtension = defineExtension({
11591159
if (
11601160
editor.isEditable() &&
11611161
isExactShortcutMatch(event, 'f', {
1162+
...CONTROL_OR_META,
11621163
altKey: true,
1163-
ctrlKey: !IS_APPLE,
1164-
metaKey: IS_APPLE,
11651164
})
11661165
) {
11671166
event.preventDefault();

packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ describe('CodeImportExtension', () => {
143143
// <table> rule out-prioritizes TableExtension's generic one.
144144
dependencies: [TableExtension, CodeExtension],
145145
name: 'table-code-host',
146+
theme: {tableScrollableWrapper: ''},
146147
}),
147148
);
148149
importInto(
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import {
10+
$getSelection,
11+
type BaseSelection,
12+
COMMAND_PRIORITY_NORMAL,
13+
type CommandListenerPriority,
14+
type CommandListenerPriorityBefore,
15+
compileKeyboardShortcuts,
16+
defineExtension,
17+
IS_APPLE,
18+
KEY_DOWN_COMMAND,
19+
keyboardEventMaskForPlatform,
20+
type KeyboardShortcut,
21+
type KeyboardShortcutMatch,
22+
type LexicalEditor,
23+
safeCast,
24+
shallowMergeConfig,
25+
} from 'lexical';
26+
27+
import {namedSignals} from './namedSignals';
28+
import {effect} from './signals';
29+
30+
export interface FormatKeyboardShortcutOptions {
31+
/** Override the platform convention (defaults to the runtime platform) */
32+
isApple?: boolean;
33+
/** The separator between segments (default `'+'`) */
34+
separator?: string;
35+
}
36+
37+
const MODIFIERS = [
38+
['ctrlKey', 'Ctrl'],
39+
['altKey', 'Alt'],
40+
['shiftKey', 'Shift'],
41+
['metaKey', 'Meta'],
42+
] as const;
43+
44+
const UNIVERSAL_KEYS: Record<string, string | undefined> = {
45+
' ': 'Space',
46+
};
47+
48+
const APPLE_KEYS: Record<string, string | undefined> = {
49+
...UNIVERSAL_KEYS,
50+
Alt: '\u2325',
51+
ArrowDown: '\u2193',
52+
ArrowLeft: '\u2190',
53+
ArrowRight: '\u2192',
54+
ArrowUp: '\u2191',
55+
Backspace: '\u232B',
56+
CapsLock: '\u21EA',
57+
Ctrl: '\u2303',
58+
Delete: '\u2326',
59+
End: '\u2198',
60+
Enter: '\u21A9',
61+
Escape: '\u238B',
62+
Home: '\u2196',
63+
Meta: '\u2318',
64+
PageDown: '\u21DF',
65+
PageUp: '\u21DE',
66+
Shift: '\u21E7',
67+
Tab: '\u21E5',
68+
};
69+
const SHIFT_APPLE_KEYS: Record<string, string | undefined> = {
70+
...APPLE_KEYS,
71+
Tab: '\u21E4',
72+
};
73+
74+
/**
75+
* Format the key binding of a shortcut as a human readable string for
76+
* menus, tooltips, and help dialogs (e.g. `'⌘+Shift+K'` on Apple platforms
77+
* and `'Ctrl+Shift+K'` elsewhere). Modifiers with an `'any'` mask are not
78+
* displayed.
79+
*/
80+
export function formatKeyboardShortcut(
81+
shortcut: KeyboardShortcutMatch,
82+
options: FormatKeyboardShortcutOptions = {},
83+
): string[] {
84+
const {isApple = IS_APPLE} = options;
85+
const {unshiftedKey, key} = shortcut;
86+
const modifiers = keyboardEventMaskForPlatform(
87+
shortcut.modifiers || {},
88+
isApple,
89+
);
90+
const segments: string[] = [];
91+
const keyNames = isApple
92+
? modifiers.shiftKey === true
93+
? SHIFT_APPLE_KEYS
94+
: APPLE_KEYS
95+
: UNIVERSAL_KEYS;
96+
for (const [k, name] of MODIFIERS) {
97+
if (modifiers[k] === true) {
98+
// Apple omits the shift modifier in cases where unshifted key
99+
// differs from the key, e.g. 'shift+/', is displayed as '?'
100+
if (isApple && k === 'shiftKey' && unshiftedKey && key.length === 1) {
101+
continue;
102+
}
103+
segments.push(keyNames[name] || name);
104+
}
105+
}
106+
segments.push(
107+
keyNames[key] ||
108+
(!isApple && modifiers.shiftKey === true && unshiftedKey) ||
109+
(key.length === 1 && key.toUpperCase()) ||
110+
key,
111+
);
112+
return segments;
113+
}
114+
115+
/**
116+
* Keyboard shortcuts by name. The names exist so that other extensions and
117+
* applications can overlay the table: configuring an existing name remaps
118+
* that shortcut, configuring it to null disables it, and new names add new
119+
* shortcuts.
120+
* @experimental
121+
*/
122+
export type NamedKeyboardShortcuts = Record<
123+
string,
124+
KeyboardShortcut | readonly KeyboardShortcut[] | null
125+
>;
126+
127+
/**
128+
* Configuration for {@link KeyboardShortcutsExtension}.
129+
* @experiemental
130+
*/
131+
export interface KeyboardShortcutsConfig {
132+
/** When `true`, the shortcut listener is not registered */
133+
disabled: boolean;
134+
/**
135+
* The `KEY_DOWN_COMMAND` priority (default {@link COMMAND_PRIORITY_NORMAL}).
136+
*
137+
* This must be a priority *above* {@link COMMAND_PRIORITY_EDITOR}. Every
138+
* editor registers the core `$handleKeyDown` at
139+
* {@link COMMAND_PRIORITY_EDITOR} and it unconditionally reports the event
140+
* as handled, so a shortcut listener at that priority or later is never
141+
* reached. That also rules out
142+
* {@link COMMAND_PRIORITY_BEFORE_EDITOR}: command dispatch walks priorities
143+
* from {@link COMMAND_PRIORITY_CRITICAL} down to
144+
* {@link COMMAND_PRIORITY_EDITOR} on the *outside* and the nested editor
145+
* chain on the inside, so a nested editor's own `$handleKeyDown` ends the
146+
* dispatch before any listener the parent has in the editor-priority queue —
147+
* which would make {@link KeyboardShortcut.bubbleFromNestedEditors}
148+
* impossible to satisfy.
149+
*/
150+
priority: CommandListenerPriority | CommandListenerPriorityBefore;
151+
/** The named shortcut table, merged by name across the extension graph */
152+
shortcuts: NamedKeyboardShortcuts;
153+
}
154+
155+
/**
156+
* @experimental @internal
157+
*
158+
* Compile the given shortcuts and register a single
159+
* {@link KEY_DOWN_COMMAND} listener that dispatches each matched shortcut's
160+
* command with the KeyboardEvent as its payload (unless its `$disabled`
161+
* predicate returns true for the current selection). When several
162+
* shortcuts match the same event they are tried in the given order until
163+
* one command dispatch is handled.
164+
*
165+
* @returns A cleanup function that unregisters the listener.
166+
*/
167+
function registerKeyboardShortcuts(
168+
editor: LexicalEditor,
169+
shortcuts: Iterable<KeyboardShortcut>,
170+
priority: CommandListenerPriority | CommandListenerPriorityBefore,
171+
): () => void {
172+
const compiled = compileKeyboardShortcuts(shortcuts);
173+
return editor.registerCommand(
174+
KEY_DOWN_COMMAND,
175+
(event, fromEditor) => {
176+
let selection: undefined | null | BaseSelection;
177+
for (const shortcut of compiled.matches(event)) {
178+
if (editor !== fromEditor && !shortcut.bubbleFromNestedEditors) {
179+
continue;
180+
}
181+
if (shortcut.$disabled) {
182+
if (selection === undefined) {
183+
selection = $getSelection();
184+
}
185+
if (shortcut.$disabled(selection, fromEditor)) {
186+
continue;
187+
}
188+
}
189+
const $next = fromEditor.dispatchCommand.bind(
190+
fromEditor,
191+
shortcut.command,
192+
event,
193+
);
194+
if (
195+
shortcut.$dispatch
196+
? shortcut.$dispatch(shortcut.command, event, $next, fromEditor)
197+
: $next()
198+
) {
199+
return true;
200+
}
201+
}
202+
return false;
203+
},
204+
priority,
205+
);
206+
}
207+
208+
function isReadonlyArray<T>(x: unknown): x is readonly T[] {
209+
return Array.isArray(x);
210+
}
211+
212+
function flattenKeyboardShortcuts(
213+
shortcuts: readonly KeyboardShortcut[] | KeyboardShortcut | null,
214+
): readonly KeyboardShortcut[] {
215+
return isReadonlyArray(shortcuts) ? shortcuts : shortcuts ? [shortcuts] : [];
216+
}
217+
218+
/**
219+
* Merge by name, as {@link shallowMergeConfig} would, except that the
220+
* overriding names come *first* in object entry iteration so that they are
221+
* also the first to be offered a matching keypress.
222+
*/
223+
function mergeNamedShortcuts(
224+
config: NamedKeyboardShortcuts,
225+
overrides: undefined | NamedKeyboardShortcuts,
226+
) {
227+
if (!overrides) {
228+
return config;
229+
}
230+
const dest = {...overrides};
231+
for (const [k, v0] of Object.entries(config)) {
232+
if (dest[k] === undefined) {
233+
dest[k] = v0;
234+
}
235+
}
236+
return dest;
237+
}
238+
239+
/**
240+
* @experimental
241+
*
242+
* Dispatches a table of keyboard shortcuts from a single compiled
243+
* `KEY_DOWN_COMMAND` listener, in O(1) per keypress.
244+
*
245+
* The table is merged across the whole extension graph by name: any
246+
* extension or app config can add shortcuts under new names, remap an
247+
* existing name to a different key or handler, or disable one by
248+
* configuring it to null. The output exposes the config as signals, so the
249+
* table can also be remapped at runtime through the `shortcuts` signal
250+
* (the listener is recompiled on change).
251+
*
252+
* Configuring an existing name always replaces its mapping outright, and a
253+
* name may be mapped to an array to give it several bindings at once. The
254+
* overriding names are also matched first, ahead of the names they did not
255+
* override, when more than one shortcut matches the same keypress.
256+
*/
257+
export const KeyboardShortcutsExtension = /* @__PURE__ */ defineExtension({
258+
build(editor, config, state) {
259+
return namedSignals(config);
260+
},
261+
config: /* @__PURE__ */ safeCast<KeyboardShortcutsConfig>({
262+
disabled: false,
263+
priority: COMMAND_PRIORITY_NORMAL,
264+
shortcuts: {},
265+
}),
266+
mergeConfig(config, overrides) {
267+
const merged = shallowMergeConfig(config, overrides);
268+
merged.shortcuts = mergeNamedShortcuts(
269+
config.shortcuts,
270+
overrides.shortcuts,
271+
);
272+
return merged;
273+
},
274+
name: '@lexical/extension/KeyboardShortcuts',
275+
register(editor, config, state) {
276+
const {disabled, priority, shortcuts} = state.getOutput();
277+
return effect(() => {
278+
if (!disabled.value) {
279+
const allShortcuts: KeyboardShortcut[] = [];
280+
for (const shortcutConfig of Object.values(shortcuts.value)) {
281+
for (const v of flattenKeyboardShortcuts(shortcutConfig)) {
282+
allShortcuts.push(v);
283+
}
284+
}
285+
return registerKeyboardShortcuts(editor, allShortcuts, priority.value);
286+
}
287+
});
288+
},
289+
});

packages/lexical-extension/src/LexicalBuilder.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,28 @@ export class LexicalBuilder {
244244
}
245245
}
246246

247+
/**
248+
* @param configs - Ownership passes to the builder, which retains the array
249+
* and may append to it. Callers must pass an array nobody else holds.
250+
*/
247251
addEdge(
248252
fromExtensionName: string,
249253
toExtensionName: string,
250254
configs: LexicalExtensionConfig<AnyLexicalExtension>[],
251255
) {
252256
const outgoing = this.outgoingConfigEdges.get(fromExtensionName);
253257
if (outgoing) {
254-
outgoing.set(toExtensionName, configs);
258+
// An extension may reach the same dependency more than once (e.g. two
259+
// configExtension entries for it, or both a direct and a peer
260+
// dependency). Every config has to be kept in the order it was seen,
261+
// otherwise all but the last would be silently discarded instead of
262+
// merged.
263+
const existing = outgoing.get(toExtensionName);
264+
if (existing) {
265+
existing.push(...configs);
266+
} else {
267+
outgoing.set(toExtensionName, configs);
268+
}
255269
} else {
256270
this.outgoingConfigEdges.set(
257271
fromExtensionName,

packages/lexical-extension/src/PreventSelectAllExtension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import {
10+
CONTROL_OR_META,
1011
defineExtension,
11-
IS_APPLE,
1212
isExactShortcutMatch,
1313
isHTMLElement,
1414
registerEventListener,
@@ -22,7 +22,7 @@ import {effect} from './signals';
2222
function captureKeydown(e: KeyboardEvent) {
2323
const target = e.target;
2424
if (
25-
isExactShortcutMatch(e, 'a', {ctrlKey: !IS_APPLE, metaKey: IS_APPLE}) &&
25+
isExactShortcutMatch(e, 'a', CONTROL_OR_META) &&
2626
isHTMLElement(target) &&
2727
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
2828
) {

0 commit comments

Comments
 (0)