Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
COMMAND_PRIORITY_BEFORE_EDITOR,
COMMAND_PRIORITY_EDITOR,
configExtension,
CONTROL_OR_META,
createCommand,
createState,
defineExtension,
Expand All @@ -72,7 +73,6 @@ import {
type ElementDOMSlot,
ElementNode,
HISTORIC_TAG,
IS_APPLE,
isExactShortcutMatch,
isHTMLElement,
KEY_DOWN_COMMAND,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe('CodeImportExtension', () => {
// <table> rule out-prioritizes TableExtension's generic one.
dependencies: [TableExtension, CodeExtension],
name: 'table-code-host',
theme: {tableScrollableWrapper: ''},
}),
);
importInto(
Expand Down
289 changes: 289 additions & 0 deletions packages/lexical-extension/src/KeyboardShortcutsExtension.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {
' ': 'Space',
};

const APPLE_KEYS: Record<string, string | undefined> = {
...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<string, string | undefined> = {
...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<KeyboardShortcut>,
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<T>(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<KeyboardShortcutsConfig>({
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);
}
});
},
});
16 changes: 15 additions & 1 deletion packages/lexical-extension/src/LexicalBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,14 +244,28 @@ 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,
configs: LexicalExtensionConfig<AnyLexicalExtension>[],
) {
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,
Expand Down
4 changes: 2 additions & 2 deletions packages/lexical-extension/src/PreventSelectAllExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
*/

import {
CONTROL_OR_META,
defineExtension,
IS_APPLE,
isExactShortcutMatch,
isHTMLElement,
registerEventListener,
Expand All @@ -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')
) {
Expand Down
Loading