@fragments-sdk/ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/ui.css +304 -0
- package/dist/blocks/BlogEditor.block.d.ts +3 -0
- package/dist/blocks/BlogEditor.block.d.ts.map +1 -0
- package/dist/components/Editor/Editor.module.scss.cjs +57 -0
- package/dist/components/Editor/Editor.module.scss.cjs.map +1 -0
- package/dist/components/Editor/Editor.module.scss.js +57 -0
- package/dist/components/Editor/Editor.module.scss.js.map +1 -0
- package/dist/components/Editor/index.cjs +548 -0
- package/dist/components/Editor/index.cjs.map +1 -0
- package/dist/components/Editor/index.d.ts +107 -0
- package/dist/components/Editor/index.d.ts.map +1 -0
- package/dist/components/Editor/index.js +531 -0
- package/dist/components/Editor/index.js.map +1 -0
- package/dist/components/Sidebar/index.cjs +6 -11
- package/dist/components/Sidebar/index.cjs.map +1 -1
- package/dist/components/Sidebar/index.d.ts.map +1 -1
- package/dist/components/Sidebar/index.js +6 -11
- package/dist/components/Sidebar/index.js.map +1 -1
- package/dist/index.cjs +22 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/keyboard-shortcuts.cjs +295 -0
- package/dist/utils/keyboard-shortcuts.cjs.map +1 -0
- package/dist/utils/keyboard-shortcuts.d.ts +293 -0
- package/dist/utils/keyboard-shortcuts.d.ts.map +1 -0
- package/dist/utils/keyboard-shortcuts.js +295 -0
- package/dist/utils/keyboard-shortcuts.js.map +1 -0
- package/fragments.json +1 -1
- package/package.json +27 -2
- package/src/blocks/BlogEditor.block.ts +34 -0
- package/src/components/Editor/Editor.fragment.tsx +322 -0
- package/src/components/Editor/Editor.module.scss +333 -0
- package/src/components/Editor/Editor.test.tsx +174 -0
- package/src/components/Editor/index.tsx +815 -0
- package/src/components/Sidebar/index.tsx +7 -14
- package/src/index.ts +43 -0
- package/src/utils/keyboard-shortcuts.test.ts +357 -0
- package/src/utils/keyboard-shortcuts.ts +502 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// Keyboard Shortcuts Registry
|
|
5
|
+
// ============================================
|
|
6
|
+
//
|
|
7
|
+
// Central source of truth for all keyboard shortcuts in @fragments-sdk/ui.
|
|
8
|
+
// Import from here instead of hardcoding key combinations in components.
|
|
9
|
+
//
|
|
10
|
+
// This prevents conflicts (e.g., Sidebar Ctrl+B vs Editor Ctrl+B) and
|
|
11
|
+
// makes shortcuts discoverable for documentation and customization.
|
|
12
|
+
//
|
|
13
|
+
// ADDING A SHORTCUT:
|
|
14
|
+
// 1. Add an entry to KEYBOARD_SHORTCUTS below
|
|
15
|
+
// 2. Import the constant in your component
|
|
16
|
+
// 3. Use useKeyboardShortcut() or matchesShortcut() in your component
|
|
17
|
+
//
|
|
18
|
+
// CUSTOMIZING SHORTCUTS (consumer API):
|
|
19
|
+
// configureShortcuts({ SIDEBAR_TOGGLE: { key: '\\', label: 'Ctrl+\\' } })
|
|
20
|
+
// configureShortcuts({ SIDEBAR_TOGGLE: null }) // disable
|
|
21
|
+
//
|
|
22
|
+
|
|
23
|
+
import { useEffect, useRef, type RefObject } from 'react';
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
export interface KeyboardShortcut {
|
|
30
|
+
/** The key to match (e.g., 'b', 'k', 'Escape'). Case-insensitive. */
|
|
31
|
+
key: string;
|
|
32
|
+
/** Require Ctrl (Windows/Linux) or Cmd (macOS) */
|
|
33
|
+
meta?: boolean;
|
|
34
|
+
/** Require Shift */
|
|
35
|
+
shift?: boolean;
|
|
36
|
+
/** Require Alt/Option */
|
|
37
|
+
alt?: boolean;
|
|
38
|
+
/** Human-readable label for display (e.g., "Ctrl+B", "⌘B") */
|
|
39
|
+
label: string;
|
|
40
|
+
/** Which component owns this shortcut */
|
|
41
|
+
component: string;
|
|
42
|
+
/** What the shortcut does */
|
|
43
|
+
description: string;
|
|
44
|
+
/**
|
|
45
|
+
* Scope controls when the shortcut is active:
|
|
46
|
+
* - 'global': Listens on document (e.g., sidebar toggle)
|
|
47
|
+
* - 'component': Only active when component is focused/mounted
|
|
48
|
+
*/
|
|
49
|
+
scope: 'global' | 'component';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================
|
|
53
|
+
// Shortcut Definitions
|
|
54
|
+
// ============================================
|
|
55
|
+
|
|
56
|
+
export const KEYBOARD_SHORTCUTS = {
|
|
57
|
+
// ----- Sidebar -----
|
|
58
|
+
SIDEBAR_TOGGLE: {
|
|
59
|
+
key: 'b',
|
|
60
|
+
meta: true,
|
|
61
|
+
label: 'Ctrl+B',
|
|
62
|
+
component: 'Sidebar',
|
|
63
|
+
description: 'Toggle sidebar collapse/expand',
|
|
64
|
+
scope: 'global',
|
|
65
|
+
},
|
|
66
|
+
SIDEBAR_CLOSE_MOBILE: {
|
|
67
|
+
key: 'Escape',
|
|
68
|
+
label: 'Escape',
|
|
69
|
+
component: 'Sidebar',
|
|
70
|
+
description: 'Close mobile sidebar drawer',
|
|
71
|
+
scope: 'global',
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// ----- Editor (TipTap handles these natively, metadata for display) -----
|
|
75
|
+
EDITOR_BOLD: {
|
|
76
|
+
key: 'b',
|
|
77
|
+
meta: true,
|
|
78
|
+
label: 'Ctrl+B',
|
|
79
|
+
component: 'Editor',
|
|
80
|
+
description: 'Toggle bold formatting',
|
|
81
|
+
scope: 'component',
|
|
82
|
+
},
|
|
83
|
+
EDITOR_ITALIC: {
|
|
84
|
+
key: 'i',
|
|
85
|
+
meta: true,
|
|
86
|
+
label: 'Ctrl+I',
|
|
87
|
+
component: 'Editor',
|
|
88
|
+
description: 'Toggle italic formatting',
|
|
89
|
+
scope: 'component',
|
|
90
|
+
},
|
|
91
|
+
EDITOR_STRIKETHROUGH: {
|
|
92
|
+
key: 's',
|
|
93
|
+
meta: true,
|
|
94
|
+
shift: true,
|
|
95
|
+
label: 'Ctrl+Shift+S',
|
|
96
|
+
component: 'Editor',
|
|
97
|
+
description: 'Toggle strikethrough formatting',
|
|
98
|
+
scope: 'component',
|
|
99
|
+
},
|
|
100
|
+
EDITOR_LINK: {
|
|
101
|
+
key: 'k',
|
|
102
|
+
meta: true,
|
|
103
|
+
label: 'Ctrl+K',
|
|
104
|
+
component: 'Editor',
|
|
105
|
+
description: 'Insert or edit link',
|
|
106
|
+
scope: 'component',
|
|
107
|
+
},
|
|
108
|
+
EDITOR_CODE: {
|
|
109
|
+
key: 'e',
|
|
110
|
+
meta: true,
|
|
111
|
+
label: 'Ctrl+E',
|
|
112
|
+
component: 'Editor',
|
|
113
|
+
description: 'Toggle inline code',
|
|
114
|
+
scope: 'component',
|
|
115
|
+
},
|
|
116
|
+
EDITOR_BULLET_LIST: {
|
|
117
|
+
key: '8',
|
|
118
|
+
meta: true,
|
|
119
|
+
shift: true,
|
|
120
|
+
label: 'Ctrl+Shift+8',
|
|
121
|
+
component: 'Editor',
|
|
122
|
+
description: 'Toggle bullet list',
|
|
123
|
+
scope: 'component',
|
|
124
|
+
},
|
|
125
|
+
EDITOR_ORDERED_LIST: {
|
|
126
|
+
key: '7',
|
|
127
|
+
meta: true,
|
|
128
|
+
shift: true,
|
|
129
|
+
label: 'Ctrl+Shift+7',
|
|
130
|
+
component: 'Editor',
|
|
131
|
+
description: 'Toggle ordered list',
|
|
132
|
+
scope: 'component',
|
|
133
|
+
},
|
|
134
|
+
EDITOR_HEADING1: {
|
|
135
|
+
key: '1',
|
|
136
|
+
meta: true,
|
|
137
|
+
alt: true,
|
|
138
|
+
label: 'Ctrl+Alt+1',
|
|
139
|
+
component: 'Editor',
|
|
140
|
+
description: 'Toggle heading level 1',
|
|
141
|
+
scope: 'component',
|
|
142
|
+
},
|
|
143
|
+
EDITOR_HEADING2: {
|
|
144
|
+
key: '2',
|
|
145
|
+
meta: true,
|
|
146
|
+
alt: true,
|
|
147
|
+
label: 'Ctrl+Alt+2',
|
|
148
|
+
component: 'Editor',
|
|
149
|
+
description: 'Toggle heading level 2',
|
|
150
|
+
scope: 'component',
|
|
151
|
+
},
|
|
152
|
+
EDITOR_HEADING3: {
|
|
153
|
+
key: '3',
|
|
154
|
+
meta: true,
|
|
155
|
+
alt: true,
|
|
156
|
+
label: 'Ctrl+Alt+3',
|
|
157
|
+
component: 'Editor',
|
|
158
|
+
description: 'Toggle heading level 3',
|
|
159
|
+
scope: 'component',
|
|
160
|
+
},
|
|
161
|
+
EDITOR_BLOCKQUOTE: {
|
|
162
|
+
key: 'b',
|
|
163
|
+
meta: true,
|
|
164
|
+
shift: true,
|
|
165
|
+
label: 'Ctrl+Shift+B',
|
|
166
|
+
component: 'Editor',
|
|
167
|
+
description: 'Toggle blockquote',
|
|
168
|
+
scope: 'component',
|
|
169
|
+
},
|
|
170
|
+
EDITOR_UNDO: {
|
|
171
|
+
key: 'z',
|
|
172
|
+
meta: true,
|
|
173
|
+
label: 'Ctrl+Z',
|
|
174
|
+
component: 'Editor',
|
|
175
|
+
description: 'Undo last action',
|
|
176
|
+
scope: 'component',
|
|
177
|
+
},
|
|
178
|
+
EDITOR_REDO: {
|
|
179
|
+
key: 'z',
|
|
180
|
+
meta: true,
|
|
181
|
+
shift: true,
|
|
182
|
+
label: 'Ctrl+Shift+Z',
|
|
183
|
+
component: 'Editor',
|
|
184
|
+
description: 'Redo last undone action',
|
|
185
|
+
scope: 'component',
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// ----- Prompt -----
|
|
189
|
+
PROMPT_SUBMIT: {
|
|
190
|
+
key: 'Enter',
|
|
191
|
+
label: 'Enter',
|
|
192
|
+
component: 'Prompt',
|
|
193
|
+
description: 'Submit prompt (when submitOnEnter is true)',
|
|
194
|
+
scope: 'component',
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// ----- NavigationMenu -----
|
|
198
|
+
NAV_TOGGLE: {
|
|
199
|
+
key: 'Enter',
|
|
200
|
+
label: 'Enter',
|
|
201
|
+
component: 'NavigationMenu',
|
|
202
|
+
description: 'Toggle menu item open/closed',
|
|
203
|
+
scope: 'component',
|
|
204
|
+
},
|
|
205
|
+
NAV_CLOSE: {
|
|
206
|
+
key: 'Escape',
|
|
207
|
+
label: 'Escape',
|
|
208
|
+
component: 'NavigationMenu',
|
|
209
|
+
description: 'Close menu and return focus to trigger',
|
|
210
|
+
scope: 'component',
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// ----- Command -----
|
|
214
|
+
COMMAND_SELECT: {
|
|
215
|
+
key: 'Enter',
|
|
216
|
+
label: 'Enter',
|
|
217
|
+
component: 'Command',
|
|
218
|
+
description: 'Select active command item',
|
|
219
|
+
scope: 'component',
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
// ----- Collapsible -----
|
|
223
|
+
COLLAPSIBLE_TOGGLE: {
|
|
224
|
+
key: 'Enter',
|
|
225
|
+
label: 'Enter',
|
|
226
|
+
component: 'Collapsible',
|
|
227
|
+
description: 'Toggle collapsible open/closed',
|
|
228
|
+
scope: 'component',
|
|
229
|
+
},
|
|
230
|
+
} as const satisfies Record<string, KeyboardShortcut>;
|
|
231
|
+
|
|
232
|
+
export type ShortcutName = keyof typeof KEYBOARD_SHORTCUTS;
|
|
233
|
+
|
|
234
|
+
// ============================================
|
|
235
|
+
// Helpers
|
|
236
|
+
// ============================================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if a KeyboardEvent matches a shortcut definition.
|
|
240
|
+
*
|
|
241
|
+
* Usage:
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { KEYBOARD_SHORTCUTS, matchesShortcut } from '../../utils/keyboard-shortcuts';
|
|
244
|
+
*
|
|
245
|
+
* const handleKeyDown = (e: KeyboardEvent) => {
|
|
246
|
+
* if (matchesShortcut(e, KEYBOARD_SHORTCUTS.SIDEBAR_TOGGLE)) {
|
|
247
|
+
* e.preventDefault();
|
|
248
|
+
* toggleSidebar();
|
|
249
|
+
* }
|
|
250
|
+
* };
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
export function matchesShortcut(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
|
|
254
|
+
if (shortcut.meta && !(event.metaKey || event.ctrlKey)) return false;
|
|
255
|
+
if (shortcut.shift && !event.shiftKey) return false;
|
|
256
|
+
if (shortcut.alt && !event.altKey) return false;
|
|
257
|
+
|
|
258
|
+
// For modifier-only checks, ensure no extra modifiers are pressed
|
|
259
|
+
if (!shortcut.meta && (event.metaKey || event.ctrlKey)) return false;
|
|
260
|
+
if (!shortcut.shift && event.shiftKey) return false;
|
|
261
|
+
if (!shortcut.alt && event.altKey) return false;
|
|
262
|
+
|
|
263
|
+
return event.key.toLowerCase() === shortcut.key.toLowerCase();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get a human-readable shortcut label, adapting for macOS vs other platforms.
|
|
268
|
+
* Returns "⌘B" on Mac, "Ctrl+B" elsewhere.
|
|
269
|
+
*/
|
|
270
|
+
export function getShortcutLabel(shortcut: KeyboardShortcut): string {
|
|
271
|
+
if (typeof navigator === 'undefined') return shortcut.label;
|
|
272
|
+
|
|
273
|
+
const isMac = navigator.platform?.includes('Mac') || navigator.userAgent?.includes('Mac');
|
|
274
|
+
if (!isMac) return shortcut.label;
|
|
275
|
+
|
|
276
|
+
const parts: string[] = [];
|
|
277
|
+
if (shortcut.meta) parts.push('⌘');
|
|
278
|
+
if (shortcut.shift) parts.push('⇧');
|
|
279
|
+
if (shortcut.alt) parts.push('⌥');
|
|
280
|
+
parts.push(shortcut.key.toUpperCase());
|
|
281
|
+
return parts.join('');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Find all shortcuts that conflict with a given shortcut (same key combo, different component).
|
|
286
|
+
* Useful for debugging and documentation.
|
|
287
|
+
*/
|
|
288
|
+
export function findConflicts(name: ShortcutName): KeyboardShortcut[] {
|
|
289
|
+
const target = KEYBOARD_SHORTCUTS[name] as KeyboardShortcut;
|
|
290
|
+
return (Object.entries(KEYBOARD_SHORTCUTS) as [string, KeyboardShortcut][])
|
|
291
|
+
.filter(([key, shortcut]) => {
|
|
292
|
+
if (key === name) return false;
|
|
293
|
+
return (
|
|
294
|
+
shortcut.key.toLowerCase() === target.key.toLowerCase() &&
|
|
295
|
+
!!shortcut.meta === !!target.meta &&
|
|
296
|
+
!!shortcut.shift === !!target.shift &&
|
|
297
|
+
!!shortcut.alt === !!target.alt
|
|
298
|
+
);
|
|
299
|
+
})
|
|
300
|
+
.map(([, shortcut]) => shortcut);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get all registered shortcuts, optionally filtered by component or scope.
|
|
305
|
+
*/
|
|
306
|
+
export function getShortcuts(filter?: { component?: string; scope?: 'global' | 'component' }): KeyboardShortcut[] {
|
|
307
|
+
return (Object.values(KEYBOARD_SHORTCUTS) as KeyboardShortcut[]).filter((shortcut) => {
|
|
308
|
+
if (filter?.component && shortcut.component !== filter.component) return false;
|
|
309
|
+
if (filter?.scope && shortcut.scope !== filter.scope) return false;
|
|
310
|
+
return true;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================
|
|
315
|
+
// Editable Element Detection
|
|
316
|
+
// ============================================
|
|
317
|
+
|
|
318
|
+
/** Text-like input types where typing shortcuts should not fire global handlers */
|
|
319
|
+
const TEXT_INPUT_TYPES = new Set([
|
|
320
|
+
'text', 'search', 'url', 'tel', 'email', 'password', 'number',
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if an element is an editable area (input, textarea, contenteditable, role="textbox").
|
|
325
|
+
* Global shortcuts should skip firing when the user is typing in one of these.
|
|
326
|
+
*/
|
|
327
|
+
export function isEditableElement(element: Element | null): boolean {
|
|
328
|
+
if (!element || !('tagName' in element)) return false;
|
|
329
|
+
|
|
330
|
+
const tag = element.tagName;
|
|
331
|
+
|
|
332
|
+
// <textarea>
|
|
333
|
+
if (tag === 'TEXTAREA') return true;
|
|
334
|
+
|
|
335
|
+
// <input> with text-like type
|
|
336
|
+
if (tag === 'INPUT') {
|
|
337
|
+
const type = (element as HTMLInputElement).type?.toLowerCase() || 'text';
|
|
338
|
+
return TEXT_INPUT_TYPES.has(type);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// contenteditable="true" or contenteditable=""
|
|
342
|
+
const htmlEl = element as HTMLElement;
|
|
343
|
+
if (htmlEl.isContentEditable) return true;
|
|
344
|
+
// Fallback: check attribute directly (isContentEditable can be unreliable for detached elements)
|
|
345
|
+
const ceAttr = htmlEl.contentEditable;
|
|
346
|
+
if (ceAttr === 'true' || ceAttr === '') return true;
|
|
347
|
+
|
|
348
|
+
// role="textbox" (TipTap uses this)
|
|
349
|
+
if (typeof element.getAttribute === 'function' && element.getAttribute('role') === 'textbox') return true;
|
|
350
|
+
|
|
351
|
+
// Check ancestors for contenteditable (e.g., a <p> inside a [contenteditable] div)
|
|
352
|
+
// Walk up manually because jsdom's closest doesn't reliably match property-set contentEditable
|
|
353
|
+
let ancestor = element.parentElement;
|
|
354
|
+
while (ancestor) {
|
|
355
|
+
if ((ancestor as HTMLElement).isContentEditable) return true;
|
|
356
|
+
const ancestorCe = (ancestor as HTMLElement).contentEditable;
|
|
357
|
+
if (ancestorCe === 'true' || ancestorCe === '') return true;
|
|
358
|
+
ancestor = ancestor.parentElement;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ============================================
|
|
365
|
+
// Shortcut Override API
|
|
366
|
+
// ============================================
|
|
367
|
+
|
|
368
|
+
/** Module-level override store. null = disabled, Partial<KeyboardShortcut> = merged with default. */
|
|
369
|
+
const shortcutOverrides = new Map<ShortcutName, Partial<KeyboardShortcut> | null>();
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Configure shortcut overrides at app startup. Mirrors the `configureTheme()` API.
|
|
373
|
+
*
|
|
374
|
+
* - Partial overrides merge with the default (e.g., `{ key: '\\' }` keeps label/component/etc.)
|
|
375
|
+
* - `null` disables a shortcut entirely
|
|
376
|
+
* - Omitted keys are left unchanged
|
|
377
|
+
* - Multiple calls merge additively (last write wins per key)
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```ts
|
|
381
|
+
* import { configureShortcuts } from '@fragments-sdk/ui';
|
|
382
|
+
*
|
|
383
|
+
* // Remap sidebar toggle to Ctrl+\
|
|
384
|
+
* configureShortcuts({ SIDEBAR_TOGGLE: { key: '\\', label: 'Ctrl+\\' } });
|
|
385
|
+
*
|
|
386
|
+
* // Disable sidebar toggle entirely
|
|
387
|
+
* configureShortcuts({ SIDEBAR_TOGGLE: null });
|
|
388
|
+
* ```
|
|
389
|
+
*/
|
|
390
|
+
export function configureShortcuts(
|
|
391
|
+
overrides: Partial<Record<ShortcutName, Partial<KeyboardShortcut> | null>>
|
|
392
|
+
): void {
|
|
393
|
+
for (const [name, value] of Object.entries(overrides) as [ShortcutName, Partial<KeyboardShortcut> | null][]) {
|
|
394
|
+
if (!(name in KEYBOARD_SHORTCUTS)) continue;
|
|
395
|
+
if (value === undefined) continue;
|
|
396
|
+
shortcutOverrides.set(name, value);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resolve a shortcut by name, applying any overrides.
|
|
402
|
+
* Returns `null` if the shortcut has been disabled via `configureShortcuts({ name: null })`.
|
|
403
|
+
*/
|
|
404
|
+
export function getResolvedShortcut(name: ShortcutName): KeyboardShortcut | null {
|
|
405
|
+
const override = shortcutOverrides.get(name);
|
|
406
|
+
|
|
407
|
+
// Explicitly disabled
|
|
408
|
+
if (override === null) return null;
|
|
409
|
+
|
|
410
|
+
const defaultShortcut = KEYBOARD_SHORTCUTS[name] as KeyboardShortcut;
|
|
411
|
+
|
|
412
|
+
// No override — return default
|
|
413
|
+
if (override === undefined) return defaultShortcut;
|
|
414
|
+
|
|
415
|
+
// Merge override into default
|
|
416
|
+
return { ...defaultShortcut, ...override };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Clear all shortcut overrides. Primarily useful for tests.
|
|
421
|
+
*/
|
|
422
|
+
export function resetShortcutOverrides(): void {
|
|
423
|
+
shortcutOverrides.clear();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============================================
|
|
427
|
+
// useKeyboardShortcut Hook
|
|
428
|
+
// ============================================
|
|
429
|
+
|
|
430
|
+
export interface UseKeyboardShortcutOptions {
|
|
431
|
+
/** Shortcut name from KEYBOARD_SHORTCUTS */
|
|
432
|
+
name: ShortcutName;
|
|
433
|
+
/** Handler called when the shortcut fires */
|
|
434
|
+
handler: () => void;
|
|
435
|
+
/** Whether the shortcut is active (default: true) */
|
|
436
|
+
enabled?: boolean;
|
|
437
|
+
/**
|
|
438
|
+
* Override the shortcut's scope for this registration:
|
|
439
|
+
* - 'global': listens on `document`, skips editable elements
|
|
440
|
+
* - 'component': listens on `ref` element only
|
|
441
|
+
* If omitted, uses the scope from the shortcut definition.
|
|
442
|
+
*/
|
|
443
|
+
scope?: 'global' | 'component';
|
|
444
|
+
/** Required when scope is 'component' — the element to attach the listener to */
|
|
445
|
+
ref?: RefObject<Element | null>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Register a keyboard shortcut handler with automatic scope and editable-area handling.
|
|
450
|
+
*
|
|
451
|
+
* For global shortcuts, automatically skips when focus is in an editable element
|
|
452
|
+
* (input, textarea, contenteditable, role="textbox") so component-scoped shortcuts
|
|
453
|
+
* like Editor's Ctrl+B take precedence over Sidebar's Ctrl+B.
|
|
454
|
+
*
|
|
455
|
+
* Respects `configureShortcuts()` overrides — if a shortcut is remapped or disabled,
|
|
456
|
+
* the hook automatically adapts.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```tsx
|
|
460
|
+
* useKeyboardShortcut({
|
|
461
|
+
* name: 'SIDEBAR_TOGGLE',
|
|
462
|
+
* handler: toggleSidebar,
|
|
463
|
+
* enabled: enableKeyboardShortcut && collapsible !== 'none',
|
|
464
|
+
* });
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
export function useKeyboardShortcut({
|
|
468
|
+
name,
|
|
469
|
+
handler,
|
|
470
|
+
enabled = true,
|
|
471
|
+
scope: scopeOverride,
|
|
472
|
+
ref,
|
|
473
|
+
}: UseKeyboardShortcutOptions): void {
|
|
474
|
+
// Use a ref for handler to avoid re-subscribing on every render
|
|
475
|
+
const handlerRef = useRef(handler);
|
|
476
|
+
handlerRef.current = handler;
|
|
477
|
+
|
|
478
|
+
useEffect(() => {
|
|
479
|
+
if (!enabled) return;
|
|
480
|
+
|
|
481
|
+
const resolved = getResolvedShortcut(name);
|
|
482
|
+
if (!resolved) return; // disabled via configureShortcuts
|
|
483
|
+
|
|
484
|
+
const effectiveScope = scopeOverride ?? resolved.scope;
|
|
485
|
+
|
|
486
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
487
|
+
if (!matchesShortcut(e, resolved)) return;
|
|
488
|
+
|
|
489
|
+
// Global shortcuts skip editable elements
|
|
490
|
+
if (effectiveScope === 'global' && isEditableElement(e.target as Element)) return;
|
|
491
|
+
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
handlerRef.current();
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const target = effectiveScope === 'component' ? ref?.current : document;
|
|
497
|
+
if (!target) return;
|
|
498
|
+
|
|
499
|
+
target.addEventListener('keydown', handleKeyDown as EventListener);
|
|
500
|
+
return () => target.removeEventListener('keydown', handleKeyDown as EventListener);
|
|
501
|
+
}, [name, enabled, scopeOverride, ref]);
|
|
502
|
+
}
|