quilt-vanilla 0.3.0 → 0.4.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.
Files changed (45) hide show
  1. package/dist/actions.d.ts +14 -0
  2. package/dist/actions.d.ts.map +1 -0
  3. package/dist/actions.js +115 -0
  4. package/dist/actions.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/menu.d.ts +9 -3
  10. package/dist/menu.d.ts.map +1 -1
  11. package/dist/menu.js +110 -37
  12. package/dist/menu.js.map +1 -1
  13. package/dist/renderer.d.ts.map +1 -1
  14. package/dist/renderer.js +35 -8
  15. package/dist/renderer.js.map +1 -1
  16. package/dist/shortcut-registration.d.ts +9 -0
  17. package/dist/shortcut-registration.d.ts.map +1 -0
  18. package/dist/shortcut-registration.js +95 -0
  19. package/dist/shortcut-registration.js.map +1 -0
  20. package/dist/shortcuts.d.ts +7 -6
  21. package/dist/shortcuts.d.ts.map +1 -1
  22. package/dist/shortcuts.js +28 -50
  23. package/dist/shortcuts.js.map +1 -1
  24. package/dist/styles.css +25 -9
  25. package/dist/tabs.d.ts.map +1 -1
  26. package/dist/tabs.js +10 -12
  27. package/dist/tabs.js.map +1 -1
  28. package/dist/types.d.ts +21 -5
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js.map +1 -1
  31. package/dist/workspace-api.d.ts +8 -2
  32. package/dist/workspace-api.d.ts.map +1 -1
  33. package/dist/workspace-api.js +14 -0
  34. package/dist/workspace-api.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/actions.ts +125 -0
  37. package/src/index.ts +6 -0
  38. package/src/menu.ts +123 -43
  39. package/src/renderer.ts +41 -7
  40. package/src/shortcut-registration.ts +106 -0
  41. package/src/shortcuts.ts +38 -63
  42. package/src/styles.css +25 -9
  43. package/src/tabs.ts +9 -11
  44. package/src/types.ts +43 -5
  45. package/src/workspace-api.ts +23 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilt-vanilla",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Vanilla DOM split panes, tabs, themes, and same-origin window popouts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "prepack": "node ../../scripts/build-package.mjs dom --dependencies"
31
31
  },
32
32
  "dependencies": {
33
- "quilt-core": "0.3.0"
33
+ "quilt-core": "0.4.0"
34
34
  },
35
35
  "sideEffects": [
36
36
  "**/*.css"
package/src/actions.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { findNode, findParent, groups, paneIds } from 'quilt-core';
2
+ import type { Group, Pane } from 'quilt-core';
3
+ import type { ActionContext, ResolvedLayoutOptions, WorkspaceAction } from './types.js';
4
+ import { actionOrder } from './shortcut-registration.js';
5
+
6
+ export function createActions(
7
+ options: ResolvedLayoutOptions,
8
+ dependencies: {
9
+ context(): ActionContext;
10
+ disposed(): boolean;
11
+ create(action: WorkspaceAction, group: Group, pane: Pane | undefined): void;
12
+ close(id: string, kind?: 'pane' | 'group'): Promise<boolean>;
13
+ popout(id: string): Promise<boolean>;
14
+ report(error: unknown): void;
15
+ },
16
+ ) {
17
+ function resolve(context?: ActionContext) {
18
+ const layout = options.store.getSnapshot();
19
+ const target =
20
+ context?.groupId !== undefined || context?.paneId !== undefined
21
+ ? context
22
+ : dependencies.context();
23
+ const id =
24
+ target.groupId ??
25
+ (target.paneId === undefined
26
+ ? undefined
27
+ : groups(layout.root).find((group) => group.panes.includes(target.paneId!))?.id);
28
+ const node = id === undefined ? undefined : findNode(layout.root, id);
29
+ const group = node?.kind === 'group' ? node : undefined;
30
+ const paneId = target.paneId ?? group?.active;
31
+ const pane = paneId ? layout.panes[paneId] : undefined;
32
+ const valid =
33
+ !(target.groupId !== undefined && !group) &&
34
+ !(target.paneId !== undefined && (!pane || !group?.panes.includes(target.paneId)));
35
+ return { layout, group, pane, valid };
36
+ }
37
+ function canExecuteAction(action: WorkspaceAction, context?: ActionContext): boolean {
38
+ if (dependencies.disposed() || !actionOrder.includes(action)) return false;
39
+ const { layout, group, pane, valid } = resolve(context);
40
+ if (!valid) return false;
41
+ if (action === 'restoreClosedTab') return options.store.canRestoreClosedTab();
42
+ if (!group) return false;
43
+ const allowed = (cap: 'split' | 'move' | 'close') =>
44
+ group.panes.every((id) => options.store.can(id, cap));
45
+ switch (action) {
46
+ case 'addTab':
47
+ return !!options.tabs?.list().length && allowed('move');
48
+ case 'splitLeft':
49
+ case 'splitRight':
50
+ case 'splitUp':
51
+ case 'splitDown':
52
+ return (!group.panes.length || !!options.tabs?.list().length) && allowed('split');
53
+ case 'closeActiveTab':
54
+ return !!pane && options.store.can(pane.id, 'close');
55
+ case 'closePane':
56
+ return !!group.panes.length && allowed('close');
57
+ case 'closeEmptyPane':
58
+ return !group.panes.length && group.id !== layout.root.id;
59
+ case 'popout':
60
+ return options.popouts !== false && !!pane && options.store.can(pane.id, 'popout');
61
+ case 'joinSiblingRegion': {
62
+ const parent = findParent(layout.root, group.id);
63
+ return !!parent && paneIds(parent).every((id) => options.store.can(id, 'join'));
64
+ }
65
+ default:
66
+ return true;
67
+ }
68
+ }
69
+ async function executeAction(action: WorkspaceAction, context?: ActionContext): Promise<boolean> {
70
+ try {
71
+ if (!canExecuteAction(action, context)) return false;
72
+ const { layout, group, pane } = resolve(context);
73
+ switch (action) {
74
+ case 'restoreClosedTab':
75
+ options.store.restoreClosedTab();
76
+ break;
77
+ case 'maximize':
78
+ options.store.maximize(layout.maximized === group!.id ? null : group!.id);
79
+ break;
80
+ case 'addTab':
81
+ case 'splitLeft':
82
+ case 'splitRight':
83
+ case 'splitUp':
84
+ case 'splitDown':
85
+ dependencies.create(action, group!, pane);
86
+ break;
87
+ case 'closeActiveTab':
88
+ return await dependencies.close(pane!.id);
89
+ case 'closePane':
90
+ return await dependencies.close(group!.id, 'group');
91
+ case 'closeEmptyPane':
92
+ options.store.removeEmptyGroup(group!.id);
93
+ break;
94
+ case 'popout':
95
+ return await dependencies.popout(pane!.id);
96
+ case 'joinSiblingRegion':
97
+ options.store.join(group!.id, { source: 'user' });
98
+ break;
99
+ case 'tabOrientationDefault':
100
+ options.store.setTabPlacement(group!.id, undefined);
101
+ break;
102
+ case 'tabOrientationHorizontal':
103
+ options.store.setTabPlacement(group!.id, 'top');
104
+ break;
105
+ case 'tabOrientationVertical':
106
+ options.store.setTabPlacement(group!.id, 'left');
107
+ break;
108
+ case 'tabDisplayDefault':
109
+ options.store.setTabDisplay(group!.id, undefined);
110
+ break;
111
+ case 'tabDisplayAutomatic':
112
+ options.store.setTabDisplay(group!.id, 'automatic');
113
+ break;
114
+ case 'tabDisplayCompact':
115
+ options.store.setTabDisplay(group!.id, 'compact');
116
+ break;
117
+ }
118
+ return true;
119
+ } catch (error) {
120
+ dependencies.report(error);
121
+ return false;
122
+ }
123
+ }
124
+ return { canExecuteAction, executeAction };
125
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,10 @@ export type {
20
20
  InitialConfiguration,
21
21
  WorkspaceSettings,
22
22
  KeyBinding,
23
+ WorkspaceAction,
24
+ ActionContext,
25
+ RegisteredShortcut,
26
+ ShortcutRegistration,
23
27
  CloseRequest,
24
28
  } from './types.js';
25
29
 
@@ -53,3 +57,5 @@ export type { WorkspacePreset } from './workspace.js';
53
57
  export { themeProperties } from './theme.js';
54
58
  export { defaultMessages } from './messages.js';
55
59
  export type { Messages } from './messages.js';
60
+
61
+ export { formatShortcut } from './shortcut-registration.js';
package/src/menu.ts CHANGED
@@ -4,20 +4,23 @@ import type { ActionIcon } from './action-icons.js';
4
4
  import { fillTabPicker } from './picker.js';
5
5
  import { findNode, findParent, groups, paneIds } from 'quilt-core';
6
6
  import type { Group, Pane } from 'quilt-core';
7
- import type { ResolvedLayoutOptions } from './types.js';
8
- import type { Windows } from './windows.js';
7
+ import type { ResolvedLayoutOptions, WorkspaceAction, ActionContext } from './types.js';
8
+ import type { createActions } from './actions.js';
9
+ import { registeredShortcuts, formatShortcut, ariaShortcut } from './shortcut-registration.js';
9
10
  import { el, Scope } from './lifetime.js';
10
11
  export function createPaneMenu(
11
12
  root: HTMLElement,
12
13
  options: ResolvedLayoutOptions,
13
- windows: Pick<Windows, 'open'>,
14
14
  refresh: () => void,
15
15
  report: (e: unknown) => void,
16
- requestClose: (id: string, kind?: 'pane' | 'group') => Promise<boolean>,
16
+ actions: ReturnType<typeof createActions>,
17
17
  ) {
18
18
  const doc = root.ownerDocument,
19
19
  win = doc.defaultView!;
20
20
  let current: Scope | undefined;
21
+ let shortcutTarget:
22
+ { dialog: HTMLDialogElement; context: ActionContext; close(): void } | undefined;
23
+ let refreshShortcuts = () => {};
21
24
  const persistentPickers = new Map<string, Scope>();
22
25
  const act = (fn: () => void) => {
23
26
  try {
@@ -26,11 +29,10 @@ export function createPaneMenu(
26
29
  report(error);
27
30
  }
28
31
  };
29
- function button(text: string, title: string, action: () => void) {
32
+ function button(text: string, label: string, action: () => void) {
30
33
  const b = el(doc, 'button', 'layouts-button', text);
31
34
  b.type = 'button';
32
- b.title = title;
33
- b.setAttribute('aria-label', title);
35
+ b.setAttribute('aria-label', label);
34
36
  b.onclick = () => act(action);
35
37
  return b;
36
38
  }
@@ -59,17 +61,38 @@ export function createPaneMenu(
59
61
  : message(options, 'Empty pane actions'),
60
62
  );
61
63
  local.add(() => dialog.remove());
62
- const add = (icon: ActionIcon, label: string, enabled: boolean, fn: () => void) => {
64
+ const context = {
65
+ groupId: group.id,
66
+ ...(pane && group.panes.includes(pane.id) ? { paneId: pane.id } : {}),
67
+ };
68
+ const hints: { element: HTMLButtonElement; action: WorkspaceAction }[] = [];
69
+ const bindAction = (element: HTMLButtonElement, action: WorkspaceAction) => {
70
+ hints.push({ element, action });
71
+ element.disabled = !actions.canExecuteAction(action, context);
72
+ element.onclick = () => {
73
+ local.dispose();
74
+ void actions.executeAction(action, context);
75
+ };
76
+ };
77
+ const add = (
78
+ icon: ActionIcon,
79
+ label: string,
80
+ enabled: boolean,
81
+ fn: (() => void) | WorkspaceAction,
82
+ target: HTMLElement = dialog,
83
+ ) => {
63
84
  const b = button(label, label, () => {
64
85
  local.dispose();
65
- act(fn);
86
+ if (typeof fn === 'function') act(fn);
66
87
  });
67
88
  const glyph = el(doc, 'span', 'layouts-tab-icon');
68
89
  glyph.setAttribute('aria-hidden', 'true');
69
90
  act(() => glyph.append(actionIcon(doc, icon, options)));
70
91
  b.replaceChildren(glyph, el(doc, 'span', '', label.replace(/^\+ /, '')));
71
92
  b.disabled = !enabled;
72
- dialog.append(b);
93
+ if (typeof fn === 'string') bindAction(b, fn);
94
+ if (target !== dialog) b.setAttribute('role', 'menuitem');
95
+ target.append(b);
73
96
  return b;
74
97
  };
75
98
  const allowed = (cap: 'split' | 'join' | 'move') =>
@@ -188,7 +211,7 @@ export function createPaneMenu(
188
211
  );
189
212
  return;
190
213
  }
191
- add('add-tab', message(options, '+ Add tab'), available && allowed('move'), () => create());
214
+ add('add-tab', message(options, '+ Add tab'), available && allowed('move'), 'addTab');
192
215
  const canCreate = !group.panes.length || available;
193
216
  const flyouts: { trigger: HTMLElement; flyout: HTMLElement }[] = [];
194
217
  let positionMenus = () => {};
@@ -207,7 +230,14 @@ export function createPaneMenu(
207
230
  flyout.setAttribute('role', 'menu');
208
231
  flyout.setAttribute('aria-label', label);
209
232
  flyouts.push({ trigger, flyout });
233
+ let closeTimer: number | undefined;
234
+ const cancelClose = () => {
235
+ win.clearTimeout(closeTimer);
236
+ closeTimer = undefined;
237
+ };
238
+ local.add(cancelClose);
210
239
  const setOpen = (open: boolean) => {
240
+ cancelClose();
211
241
  if (open) {
212
242
  for (const other of flyouts) {
213
243
  other.flyout.hidden = true;
@@ -221,7 +251,11 @@ export function createPaneMenu(
221
251
  local.listen(container, 'pointerenter', () => {
222
252
  if (!trigger.disabled) setOpen(true);
223
253
  });
224
- local.listen(container, 'pointerleave', () => setOpen(false));
254
+ // Allow the pointer to cross the dialog padding or move diagonally into the flyout.
255
+ local.listen(container, 'pointerleave', () => {
256
+ cancelClose();
257
+ closeTimer = win.setTimeout(() => setOpen(false), 300);
258
+ });
225
259
  local.listen(trigger, 'keydown', (event) => {
226
260
  if ((event as KeyboardEvent).key === 'ArrowRight') {
227
261
  event.preventDefault();
@@ -241,16 +275,14 @@ export function createPaneMenu(
241
275
  return flyout;
242
276
  }
243
277
  const flyout = submenu(message(options, 'Split'), 'split-right', canCreate && allowed('split'));
244
- for (const [label, axis, before] of [
245
- [message(options, 'Split left'), 'horizontal', true],
246
- [message(options, 'Split right'), 'horizontal', false],
247
- [message(options, 'Split up'), 'vertical', true],
248
- [message(options, 'Split down'), 'vertical', false],
278
+ for (const [label, axis, action] of [
279
+ [message(options, 'Split left'), 'horizontal', 'splitLeft'],
280
+ [message(options, 'Split right'), 'horizontal', 'splitRight'],
281
+ [message(options, 'Split up'), 'vertical', 'splitUp'],
282
+ [message(options, 'Split down'), 'vertical', 'splitDown'],
249
283
  ] as const) {
250
- const option = button(label, label, () => {
251
- local.dispose();
252
- create(axis, before);
253
- });
284
+ const option = button(label, label, () => {});
285
+ bindAction(option, action);
254
286
  const icon = el(doc, 'span', 'layouts-tab-icon');
255
287
  icon.setAttribute('aria-hidden', 'true');
256
288
  icon.append(actionIcon(doc, axis === 'horizontal' ? 'split-right' : 'split-below', options));
@@ -263,7 +295,7 @@ export function createPaneMenu(
263
295
  'join',
264
296
  message(options, 'Join sibling region'),
265
297
  Boolean(joinParent) && paneIds(joinParent!).every((id) => options.store.can(id, 'join')),
266
- () => options.store.join(group.id, { source: 'user' }),
298
+ 'joinSiblingRegion',
267
299
  );
268
300
  let preview: Scope | undefined;
269
301
  const clearPreview = () => {
@@ -334,20 +366,14 @@ export function createPaneMenu(
334
366
  ? message(options, 'Restore region')
335
367
  : message(options, 'Maximize region'),
336
368
  true,
337
- () =>
338
- options.store.maximize(
339
- options.store.getSnapshot().maximized === group.id ? null : group.id,
340
- ),
369
+ 'maximize',
341
370
  );
342
371
  if (group.panes.length && pane && options.popouts !== false) {
343
372
  add(
344
373
  'popout',
345
374
  message(options, 'Open in window'),
346
375
  options.store.can(pane.id, 'popout'),
347
- () => {
348
- void windows.open(pane.id);
349
- refresh();
350
- },
376
+ 'popout',
351
377
  );
352
378
  }
353
379
  const orientation = submenu(message(options, 'Tab orientation'), 'tab-orientation');
@@ -357,10 +383,15 @@ export function createPaneMenu(
357
383
  ['left', message(options, 'Vertical')],
358
384
  ] as const) {
359
385
  const selected = group.tabPlacement === value;
360
- const option = button(label, label, () => {
361
- options.store.setTabPlacement(group.id, value);
362
- local.dispose();
363
- });
386
+ const option = button(label, label, () => {});
387
+ bindAction(
388
+ option,
389
+ value === 'top'
390
+ ? 'tabOrientationHorizontal'
391
+ : value === 'left'
392
+ ? 'tabOrientationVertical'
393
+ : 'tabOrientationDefault',
394
+ );
364
395
  option.setAttribute('role', 'menuitemradio');
365
396
  option.setAttribute('aria-checked', String(selected));
366
397
  const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
@@ -375,10 +406,15 @@ export function createPaneMenu(
375
406
  ['compact', message(options, 'Compact')],
376
407
  ] as const) {
377
408
  const selected = group.tabDisplay === value;
378
- const option = button(label, label, () => {
379
- options.store.setTabDisplay(group.id, value);
380
- local.dispose();
381
- });
409
+ const option = button(label, label, () => {});
410
+ bindAction(
411
+ option,
412
+ value === 'automatic'
413
+ ? 'tabDisplayAutomatic'
414
+ : value === 'compact'
415
+ ? 'tabDisplayCompact'
416
+ : 'tabDisplayDefault',
417
+ );
382
418
  option.setAttribute('role', 'menuitemradio');
383
419
  option.setAttribute('aria-checked', String(selected));
384
420
  const mark = el(doc, 'span', 'layouts-tab-icon', selected ? '✓' : '');
@@ -387,31 +423,34 @@ export function createPaneMenu(
387
423
  display.append(option);
388
424
  }
389
425
  if (group.panes.length && pane) {
426
+ const close = submenu(message(options, 'Close'), 'close');
390
427
  add(
391
428
  'close',
392
429
  message(options, 'Close active tab'),
393
430
  options.store.can(pane.id, 'close'),
394
- () => void requestClose(pane.id),
431
+ 'closeActiveTab',
432
+ close,
395
433
  );
396
434
  add(
397
435
  'close',
398
436
  message(options, 'Close pane'),
399
437
  group.panes.every((id) => options.store.can(id, 'close')),
400
- () => void requestClose(group.id, 'group'),
438
+ 'closePane',
439
+ close,
401
440
  );
402
441
  } else {
403
442
  add(
404
443
  'close',
405
444
  message(options, 'Close empty pane'),
406
445
  options.store.getSnapshot().root.id !== group.id,
407
- () => options.store.removeEmptyGroup(group.id),
446
+ 'closeEmptyPane',
408
447
  );
409
448
  }
410
449
  add(
411
450
  'restore',
412
451
  message(options, 'Restore closed tab'),
413
452
  options.store.canRestoreClosedTab(),
414
- () => options.store.restoreClosedTab(),
453
+ 'restoreClosedTab',
415
454
  );
416
455
  add('cancel', message(options, 'Cancel'), true, () => {});
417
456
  root.append(dialog);
@@ -462,8 +501,47 @@ export function createPaneMenu(
462
501
  local.dispose();
463
502
  }
464
503
  });
504
+ const updateHints = () => {
505
+ const registrations = registeredShortcuts(options);
506
+ for (const { element, action } of hints) {
507
+ element.querySelector('.layouts-shortcut')?.remove();
508
+ element.removeAttribute('aria-keyshortcuts');
509
+ element.disabled = !actions.canExecuteAction(action, context);
510
+ const label = element.getAttribute('aria-label') ?? '';
511
+ const registration = registrations.find((entry) => entry.action === action);
512
+ element.removeAttribute('title');
513
+ if (!registration) continue;
514
+ const labels = registration.bindings.map((binding) => {
515
+ try {
516
+ return options.formatShortcut?.(binding) ?? formatShortcut(binding);
517
+ } catch (error) {
518
+ report(error);
519
+ return formatShortcut(binding);
520
+ }
521
+ });
522
+ const badge = el(doc, 'kbd', 'layouts-shortcut', labels[0]);
523
+ badge.setAttribute('aria-hidden', 'true');
524
+ element.append(badge);
525
+ element.title = `${label} (${labels.join(' / ')})`;
526
+ if (!element.disabled)
527
+ element.setAttribute(
528
+ 'aria-keyshortcuts',
529
+ registration.bindings.map(ariaShortcut).join(' '),
530
+ );
531
+ }
532
+ positionMenus();
533
+ };
534
+ refreshShortcuts = updateHints;
535
+ local.add(() => {
536
+ if (refreshShortcuts === updateHints) refreshShortcuts = () => {};
537
+ });
538
+ const target = { dialog, context, close: () => local.dispose() };
539
+ shortcutTarget = target;
540
+ local.add(() => {
541
+ if (shortcutTarget === target) shortcutTarget = undefined;
542
+ });
465
543
  dialog.showModal();
466
- positionMenus();
544
+ updateHints();
467
545
  const observer = new win.ResizeObserver(positionMenus);
468
546
  observer.observe(dialog);
469
547
  observer.observe(anchor);
@@ -478,6 +556,8 @@ export function createPaneMenu(
478
556
  dialog.focus({ preventScroll: true });
479
557
  }
480
558
  return {
559
+ getShortcutTarget: () => shortcutTarget,
560
+ refreshShortcuts: () => refreshShortcuts(),
481
561
  open,
482
562
  addTab(anchor: HTMLElement, group: Group) {
483
563
  const layout = options.store.getSnapshot();
package/src/renderer.ts CHANGED
@@ -10,6 +10,8 @@ import { bindCorners } from './corners.js';
10
10
  import { applyTheme } from './theme.js';
11
11
  import { fillTabs } from './tabs.js';
12
12
  import { bindShortcuts } from './shortcuts.js';
13
+ import { createActions } from './actions.js';
14
+ import { registeredShortcuts } from './shortcut-registration.js';
13
15
  import {
14
16
  LayoutStore,
15
17
  createLayout,
@@ -142,13 +144,42 @@ function mountLayoutInternal(
142
144
  for (const region of regions.values()) region.scope.dispose();
143
145
  regions.clear();
144
146
  });
145
- const menu = createPaneMenu(root, options, windows, render, error, requestClose);
147
+ const actions = createActions(options, {
148
+ context: () => shortcutContext(),
149
+ disposed: () => disposed,
150
+ close: requestClose,
151
+ popout: (id) => windows.open(id),
152
+ report: error,
153
+ create(action, group, pane) {
154
+ const region = regions.get(group.id);
155
+ if (!region) throw new Error('Region is not mounted');
156
+ const anchor = region.header ?? region.element;
157
+ if (action === 'addTab') menu.addTab(anchor, group);
158
+ else {
159
+ const direction =
160
+ action === 'splitLeft'
161
+ ? 'left'
162
+ : action === 'splitRight'
163
+ ? 'right'
164
+ : action === 'splitUp'
165
+ ? 'top'
166
+ : 'bottom';
167
+ menu.open(anchor, pane, group, direction);
168
+ }
169
+ },
170
+ });
171
+ const menu = createPaneMenu(root, options, render, error, actions);
146
172
  scope.add(() => menu.dispose());
147
173
  scope.add(() => dragScope?.dispose());
148
- bindShortcuts(root, options, scope, act, (group) => {
149
- const region = regions.get(group.id);
150
- if (region) menu.addTab(region.header ?? region.element, group);
151
- });
174
+ const shortcutContext = bindShortcuts(
175
+ root,
176
+ options,
177
+ scope,
178
+ actions.canExecuteAction,
179
+ actions.executeAction,
180
+ menu.getShortcutTarget,
181
+ );
182
+ registeredShortcuts(options);
152
183
  const renderOptions = { ...options, onError: error };
153
184
  function button(text: string, title: string, action: () => void) {
154
185
  const b = el(doc, 'button', 'layouts-button', text);
@@ -807,6 +838,8 @@ function mountLayoutInternal(
807
838
  render();
808
839
  return {
809
840
  store: options.store,
841
+ ...actions,
842
+ getShortcuts: () => registeredShortcuts(options),
810
843
  requestClose,
811
844
  refreshTheme,
812
845
  updateOptions(next) {
@@ -818,6 +851,7 @@ function mountLayoutInternal(
818
851
  const candidate = { ...options, ...next };
819
852
  if (candidate.registry && (next.renderers || next.tabs))
820
853
  throw new Error('registry cannot be combined with renderers or tabs');
854
+ registeredShortcuts(candidate);
821
855
  validateTheme(candidate.theme ?? {});
822
856
  validateTabBar(candidate.tabBar ?? {});
823
857
  const sameRegistrations =
@@ -843,8 +877,7 @@ function mountLayoutInternal(
843
877
  (!candidate.registry && candidate.tabs !== options.tabs) ||
844
878
  candidate.renderIcon !== options.renderIcon ||
845
879
  candidate.popouts !== options.popouts ||
846
- JSON.stringify(candidate.messages) !== JSON.stringify(options.messages) ||
847
- JSON.stringify(candidate.shortcuts) !== JSON.stringify(options.shortcuts);
880
+ JSON.stringify(candidate.messages) !== JSON.stringify(options.messages);
848
881
  // Clear maps derived from the old registry when returning to low-level registration.
849
882
  if ('registry' in next && !next.registry && options.registry) {
850
883
  Object.assign(options, { renderers: undefined, tabs: undefined });
@@ -854,6 +887,7 @@ function mountLayoutInternal(
854
887
  if ('tabBar' in next) tabBar = structuredClone(options.tabBar ?? {});
855
888
  bindRegistry();
856
889
  refreshOptions(repaintChrome);
890
+ menu.refreshShortcuts();
857
891
  refreshTheme();
858
892
  },
859
893
  exportWorkspace() {
@@ -0,0 +1,106 @@
1
+ import type {
2
+ KeyBinding,
3
+ RegisteredShortcut,
4
+ WorkspaceAction,
5
+ WorkspaceSettings,
6
+ } from './types.js';
7
+
8
+ /** Stable conflict priority. Existing presets retain their original priority. */
9
+ export const actionOrder: readonly WorkspaceAction[] = [
10
+ 'maximize',
11
+ 'addTab',
12
+ 'restoreClosedTab',
13
+ 'closeActiveTab',
14
+ 'closePane',
15
+ 'closeEmptyPane',
16
+ 'popout',
17
+ 'joinSiblingRegion',
18
+ 'splitLeft',
19
+ 'splitRight',
20
+ 'splitUp',
21
+ 'splitDown',
22
+ 'tabOrientationDefault',
23
+ 'tabOrientationHorizontal',
24
+ 'tabOrientationVertical',
25
+ 'tabDisplayDefault',
26
+ 'tabDisplayAutomatic',
27
+ 'tabDisplayCompact',
28
+ ];
29
+ const defaults: Partial<Record<WorkspaceAction, readonly KeyBinding[]>> = {
30
+ maximize: [{ key: '`' }, { key: ' ', alt: true }],
31
+ addTab: [{ key: 't' }],
32
+ restoreClosedTab: [{ key: 'r' }],
33
+ };
34
+ export function registeredShortcuts(options: {
35
+ shortcuts?: WorkspaceSettings['shortcuts'];
36
+ }): readonly RegisteredShortcut[] {
37
+ const result: RegisteredShortcut[] = [];
38
+ for (const action of actionOrder) {
39
+ const value =
40
+ options.shortcuts === true
41
+ ? !!defaults[action]
42
+ : typeof options.shortcuts === 'object'
43
+ ? options.shortcuts[action]
44
+ : false;
45
+ if (!value) continue;
46
+ const extended = typeof value === 'object' && 'bindings' in value ? value : undefined;
47
+ const configured = extended ? extended.bindings : value;
48
+ const bindings: readonly KeyBinding[] =
49
+ configured === true
50
+ ? (defaults[action] ?? [])
51
+ : Array.isArray(configured)
52
+ ? configured
53
+ : [configured as KeyBinding];
54
+ const handling = extended?.handling ?? 'quilt';
55
+ if (handling !== 'quilt' && handling !== 'external')
56
+ throw new Error('Invalid shortcut handling');
57
+ const normalized = bindings.map((binding) => {
58
+ if (
59
+ !binding ||
60
+ typeof binding.key !== 'string' ||
61
+ !binding.key ||
62
+ (binding.key !== ' ' && /\s/.test(binding.key))
63
+ )
64
+ throw new Error('Invalid shortcut key');
65
+ for (const modifier of ['ctrl', 'alt', 'shift', 'meta'] as const)
66
+ if (binding[modifier] !== undefined && typeof binding[modifier] !== 'boolean')
67
+ throw new Error(`Invalid shortcut modifier: ${modifier}`);
68
+ return Object.freeze({
69
+ key: binding.key,
70
+ ctrl: !!binding.ctrl,
71
+ alt: !!binding.alt,
72
+ shift: !!binding.shift,
73
+ meta: !!binding.meta,
74
+ });
75
+ });
76
+ if (normalized.length)
77
+ result.push(Object.freeze({ action, handling, bindings: Object.freeze(normalized) }));
78
+ }
79
+ return Object.freeze(result);
80
+ }
81
+ export function formatShortcut(binding: Readonly<KeyBinding>): string {
82
+ return [
83
+ binding.ctrl && 'Ctrl',
84
+ binding.alt && 'Alt',
85
+ binding.shift && 'Shift',
86
+ binding.meta && 'Meta',
87
+ binding.key === ' '
88
+ ? 'Space'
89
+ : binding.key.length === 1
90
+ ? binding.key.toUpperCase()
91
+ : binding.key,
92
+ ]
93
+ .filter(Boolean)
94
+ .join(' + ');
95
+ }
96
+ export function ariaShortcut(binding: Readonly<KeyBinding>): string {
97
+ return [
98
+ binding.ctrl && 'Control',
99
+ binding.alt && 'Alt',
100
+ binding.shift && 'Shift',
101
+ binding.meta && 'Meta',
102
+ binding.key === ' ' ? 'Space' : binding.key === '+' ? 'Plus' : binding.key,
103
+ ]
104
+ .filter(Boolean)
105
+ .join('+');
106
+ }