quilt-vanilla 0.2.4 → 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 (74) hide show
  1. package/README.md +35 -39
  2. package/dist/actions.d.ts +14 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +115 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/chrome-size.d.ts +7 -0
  7. package/dist/chrome-size.d.ts.map +1 -0
  8. package/dist/chrome-size.js +86 -0
  9. package/dist/chrome-size.js.map +1 -0
  10. package/dist/close.d.ts.map +1 -1
  11. package/dist/close.js +3 -1
  12. package/dist/close.js.map +1 -1
  13. package/dist/corners.d.ts +2 -1
  14. package/dist/corners.d.ts.map +1 -1
  15. package/dist/corners.js +2 -2
  16. package/dist/corners.js.map +1 -1
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/menu.d.ts +9 -3
  22. package/dist/menu.d.ts.map +1 -1
  23. package/dist/menu.js +110 -37
  24. package/dist/menu.js.map +1 -1
  25. package/dist/panes.d.ts.map +1 -1
  26. package/dist/panes.js +84 -52
  27. package/dist/panes.js.map +1 -1
  28. package/dist/registry.d.ts +1 -0
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +4 -3
  31. package/dist/registry.js.map +1 -1
  32. package/dist/renderer.d.ts.map +1 -1
  33. package/dist/renderer.js +128 -36
  34. package/dist/renderer.js.map +1 -1
  35. package/dist/resize.d.ts +2 -1
  36. package/dist/resize.d.ts.map +1 -1
  37. package/dist/resize.js +2 -2
  38. package/dist/resize.js.map +1 -1
  39. package/dist/shortcut-registration.d.ts +9 -0
  40. package/dist/shortcut-registration.d.ts.map +1 -0
  41. package/dist/shortcut-registration.js +95 -0
  42. package/dist/shortcut-registration.js.map +1 -0
  43. package/dist/shortcuts.d.ts +7 -6
  44. package/dist/shortcuts.d.ts.map +1 -1
  45. package/dist/shortcuts.js +28 -50
  46. package/dist/shortcuts.js.map +1 -1
  47. package/dist/styles.css +74 -9
  48. package/dist/tabs.d.ts.map +1 -1
  49. package/dist/tabs.js +10 -12
  50. package/dist/tabs.js.map +1 -1
  51. package/dist/types.d.ts +50 -13
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js.map +1 -1
  54. package/dist/workspace-api.d.ts +186 -0
  55. package/dist/workspace-api.d.ts.map +1 -0
  56. package/dist/workspace-api.js +409 -0
  57. package/dist/workspace-api.js.map +1 -0
  58. package/package.json +2 -2
  59. package/src/actions.ts +125 -0
  60. package/src/chrome-size.ts +99 -0
  61. package/src/close.ts +6 -1
  62. package/src/corners.ts +2 -1
  63. package/src/index.ts +31 -4
  64. package/src/menu.ts +123 -43
  65. package/src/panes.ts +82 -55
  66. package/src/registry.ts +6 -4
  67. package/src/renderer.ts +181 -54
  68. package/src/resize.ts +2 -1
  69. package/src/shortcut-registration.ts +106 -0
  70. package/src/shortcuts.ts +38 -63
  71. package/src/styles.css +74 -9
  72. package/src/tabs.ts +9 -11
  73. package/src/types.ts +67 -14
  74. package/src/workspace-api.ts +583 -0
package/src/renderer.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { chromeMinimum } from './chrome-size.js';
1
2
  import { closeRequests } from './close.js';
2
3
  import { dockLayout, parseWorkspace } from './workspace.js';
3
4
  import { validateTheme, themePixels } from './theme.js';
@@ -9,9 +10,20 @@ import { bindCorners } from './corners.js';
9
10
  import { applyTheme } from './theme.js';
10
11
  import { fillTabs } from './tabs.js';
11
12
  import { bindShortcuts } from './shortcuts.js';
12
- import { allocate, bounds, DIVIDER, findNode, groups, paneIds } from 'quilt-core';
13
+ import { createActions } from './actions.js';
14
+ import { registeredShortcuts } from './shortcut-registration.js';
15
+ import {
16
+ LayoutStore,
17
+ createLayout,
18
+ allocate,
19
+ bounds as modelBounds,
20
+ DIVIDER,
21
+ findNode,
22
+ groups,
23
+ paneIds,
24
+ } from 'quilt-core';
13
25
  import type { Group, Layout, Node, Pane } from 'quilt-core';
14
- import type { LayoutOptions, ResolvedLayoutOptions, MountedLayout } from './types.js';
26
+ import type { LayoutOptions, ResolvedLayoutOptions, MountedLayout, TabBarStyle } from './types.js';
15
27
  import { Scope, el, syncChildren } from './lifetime.js';
16
28
  import { mountPane } from './panes.js';
17
29
  import type { MountedPane } from './panes.js';
@@ -26,19 +38,51 @@ interface Region {
26
38
  divider?: HTMLElement;
27
39
  signature?: string;
28
40
  updateTabBar?: () => void;
41
+ tabStyle?: () => TabBarStyle;
29
42
  }
30
43
  let nextMount = 0;
31
44
  export function mountLayout<State = unknown>(
32
45
  host: HTMLElement,
33
46
  input: LayoutOptions<State>,
34
47
  ): MountedLayout<State> {
35
- // State typing is a boundary contract; the renderer only forwards the value.
36
- return mountLayoutInternal(
37
- host,
38
- input as unknown as ResolvedLayoutOptions,
39
- ) as MountedLayout<State>;
48
+ if ('store' in input)
49
+ throw new Error('Quilt owns its store; pass initialLayout or initialWorkspace');
50
+ if (input.initialLayout !== undefined && input.initialWorkspace !== undefined)
51
+ throw new Error('initialLayout and initialWorkspace are mutually exclusive');
52
+ if (input.registry && (input.renderers || input.tabs))
53
+ throw new Error('registry cannot be combined with renderers or tabs');
54
+ const preset =
55
+ input.initialWorkspace === undefined ? undefined : parseWorkspace(input.initialWorkspace);
56
+ const theme = preset?.theme ?? input.theme ?? {};
57
+ const tabBar = preset?.tabBar ?? input.tabBar ?? {};
58
+ validateTheme(theme);
59
+ validateTabBar(tabBar);
60
+ const store = new LayoutStore(
61
+ dockLayout(preset?.layout ?? input.initialLayout ?? createLayout()),
62
+ {
63
+ autoCollapse: preset?.autoCollapse ?? 'disabled',
64
+ },
65
+ );
66
+ // The initialization scope rolls back even if setup fails before returning a handle.
67
+ const scope = new Scope();
68
+ scope.add(() => store.dispose());
69
+ try {
70
+ return mountLayoutInternal(
71
+ host,
72
+ { ...input, store, theme, tabBar } as unknown as ResolvedLayoutOptions,
73
+ scope,
74
+ ) as MountedLayout<State>;
75
+ } catch (error) {
76
+ scope.dispose();
77
+ throw error;
78
+ }
40
79
  }
41
- function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): MountedLayout {
80
+
81
+ function mountLayoutInternal(
82
+ host: HTMLElement,
83
+ input: ResolvedLayoutOptions,
84
+ scope: Scope,
85
+ ): MountedLayout {
42
86
  const options = { ...input };
43
87
  const configure = () => {
44
88
  if (options.registry) {
@@ -58,8 +102,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
58
102
  const doc = host.ownerDocument,
59
103
  win = doc.defaultView;
60
104
  if (!win) throw new Error('Layout host must belong to a live document');
61
- const scope = new Scope(),
62
- regions = new Map<string, Region>(),
105
+ const regions = new Map<string, Region>(),
63
106
  panes = new Map<string, MountedPane>();
64
107
  const root = el(doc, 'div', 'layouts');
65
108
  applyTheme(root, options.theme ?? {});
@@ -69,6 +112,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
69
112
  status.setAttribute('role', 'status');
70
113
  root.append(stage, status);
71
114
  host.append(root);
115
+ scope.add(() => root.remove());
72
116
  let disposed = false,
73
117
  rendering = false,
74
118
  again = false,
@@ -89,13 +133,53 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
89
133
  error(e);
90
134
  }
91
135
  };
92
- const requestClose = closeRequests(root, options, scope, error);
136
+ const closeScope = new Scope();
137
+ scope.add(() => closeScope.dispose());
138
+ const requestClose = closeRequests(root, options, closeScope, error);
93
139
  const windows = new Windows(doc, options, error, root);
94
- const menu = createPaneMenu(root, options, windows, render, error, requestClose);
95
- bindShortcuts(root, options, scope, act, (group) => {
96
- const region = regions.get(group.id);
97
- if (region) menu.addTab(region.header ?? region.element, group);
140
+ scope.add(() => windows.dispose());
141
+ scope.add(() => {
142
+ for (const pane of panes.values()) pane.dispose();
143
+ panes.clear();
144
+ for (const region of regions.values()) region.scope.dispose();
145
+ regions.clear();
146
+ });
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
+ },
98
170
  });
171
+ const menu = createPaneMenu(root, options, render, error, actions);
172
+ scope.add(() => menu.dispose());
173
+ scope.add(() => dragScope?.dispose());
174
+ const shortcutContext = bindShortcuts(
175
+ root,
176
+ options,
177
+ scope,
178
+ actions.canExecuteAction,
179
+ actions.executeAction,
180
+ menu.getShortcutTarget,
181
+ );
182
+ registeredShortcuts(options);
99
183
  const renderOptions = { ...options, onError: error };
100
184
  function button(text: string, title: string, action: () => void) {
101
185
  const b = el(doc, 'button', 'layouts-button', text);
@@ -123,24 +207,25 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
123
207
  r.header = el(doc, 'header', 'layouts-header');
124
208
  r.body = el(doc, 'div', 'layouts-body');
125
209
  r.element.append(r.header, r.body);
210
+ r.tabStyle = () => {
211
+ const current = findNode(options.store.getSnapshot().root, node.id);
212
+ return {
213
+ ...tabBar,
214
+ ...(tabBar.regions?.[node.id] ?? {}),
215
+ ...(current?.kind === 'group' && current.tabDisplay
216
+ ? { display: current.tabDisplay }
217
+ : {}),
218
+ ...(current?.kind === 'group' && current.tabPlacement
219
+ ? { placement: current.tabPlacement }
220
+ : {}),
221
+ };
222
+ };
126
223
  r.updateTabBar = bindTabBar(
127
224
  r.element,
128
225
  r.header,
129
226
  root,
130
227
  r.scope,
131
- () => {
132
- const current = findNode(options.store.getSnapshot().root, node.id);
133
- return {
134
- ...tabBar,
135
- ...(tabBar.regions?.[node.id] ?? {}),
136
- ...(current?.kind === 'group' && current.tabDisplay
137
- ? { display: current.tabDisplay }
138
- : {}),
139
- ...(current?.kind === 'group' && current.tabPlacement
140
- ? { placement: current.tabPlacement }
141
- : {}),
142
- };
143
- },
228
+ r.tabStyle,
144
229
  () => options.messages ?? {},
145
230
  );
146
231
  bindCorners(
@@ -150,6 +235,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
150
235
  r.scope,
151
236
  (pane, group, direction, ratio) => menu.open(r.header!, pane, group, direction, ratio),
152
237
  error,
238
+ bounds,
153
239
  );
154
240
  r.element.setAttribute('aria-label', message(options, 'Pane region'));
155
241
  r.scope.listen(r.element, 'dragover', (event) => {
@@ -203,10 +289,15 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
203
289
  dragScope?.dispose();
204
290
  const drag = new Scope();
205
291
  dragScope = drag;
206
- const resize = isolatedResize(geometryLayout(), node.id, (child, axis) => {
207
- const rect = regions.get(child.id)!.element.getBoundingClientRect();
208
- return axis === 'horizontal' ? rect.width : rect.height;
209
- });
292
+ const resize = isolatedResize(
293
+ geometryLayout(),
294
+ node.id,
295
+ (child, axis) => {
296
+ const rect = regions.get(child.id)!.element.getBoundingClientRect();
297
+ return axis === 'horizontal' ? rect.width : rect.height;
298
+ },
299
+ bounds,
300
+ );
210
301
  r.divider!.setPointerCapture(e.pointerId);
211
302
  root.classList.add('layouts-resizing');
212
303
  drag.add(() => {
@@ -258,10 +349,15 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
258
349
  n.axis === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
259
350
  if (keys.includes(e.key)) {
260
351
  e.preventDefault();
261
- const resize = isolatedResize(geometryLayout(), node.id, (child, axis) => {
262
- const rect = regions.get(child.id)!.element.getBoundingClientRect();
263
- return axis === 'horizontal' ? rect.width : rect.height;
264
- });
352
+ const resize = isolatedResize(
353
+ geometryLayout(),
354
+ node.id,
355
+ (child, axis) => {
356
+ const rect = regions.get(child.id)!.element.getBoundingClientRect();
357
+ return axis === 'horizontal' ? rect.width : rect.height;
358
+ },
359
+ bounds,
360
+ );
265
361
  act(() =>
266
362
  options.store.resizeMany(
267
363
  resize.ratios(
@@ -558,9 +654,29 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
558
654
  return node.axis === 'horizontal' ? b.minWidth < b.maxWidth : b.minHeight < b.maxHeight;
559
655
  });
560
656
  }
657
+ const chromeSizes = new Map<string, { minWidth: number; minHeight: number }>();
658
+ function bounds(node: Node, layout: Layout) {
659
+ return modelBounds(node, layout, (group, constraints) => {
660
+ const chrome = chromeSizes.get(group.id);
661
+ if (!chrome) return constraints;
662
+ const minWidth = Math.max(constraints.minWidth, chrome.minWidth);
663
+ const minHeight = Math.max(constraints.minHeight, chrome.minHeight);
664
+ return {
665
+ minWidth,
666
+ minHeight,
667
+ maxWidth: Math.max(constraints.maxWidth, minWidth),
668
+ maxHeight: Math.max(constraints.maxHeight, minHeight),
669
+ };
670
+ });
671
+ }
561
672
  // Renderer-only gaps participate in bounds/allocation without modifying Layout JSON.
562
673
  function geometryLayout(): Layout {
563
674
  const layout = options.store.getSnapshot();
675
+ chromeSizes.clear();
676
+ for (const [id, region] of regions) {
677
+ if (region.header && region.tabStyle && region.element.isConnected)
678
+ chromeSizes.set(id, chromeMinimum(region.element, region.header, region.tabStyle()));
679
+ }
564
680
  const pixels = (property: string, fallback: number) => themePixels(root, property, fallback);
565
681
  const width = pixels('--layouts-resize-handle-width', DIVIDER);
566
682
  const disabledWidth = pixels('--layouts-disabled-resize-handle-width', 0);
@@ -648,9 +764,21 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
648
764
  render();
649
765
  }),
650
766
  );
651
- const observer = new ResizeObserver(measure);
767
+ // Geometry can change the stage's scrollbar gutter. Defer those writes out
768
+ // of ResizeObserver delivery so WebKit does not detect a nested resize loop.
769
+ let measureFrame = 0;
770
+ const observer = new ResizeObserver(() => {
771
+ if (!measureFrame && !disposed)
772
+ measureFrame = doc.defaultView!.requestAnimationFrame(() => {
773
+ measureFrame = 0;
774
+ if (!disposed) measure();
775
+ });
776
+ });
652
777
  observer.observe(stage);
653
- scope.add(() => observer.disconnect());
778
+ scope.add(() => {
779
+ observer.disconnect();
780
+ if (measureFrame) doc.defaultView!.cancelAnimationFrame(measureFrame);
781
+ });
654
782
  scope.listen(doc, 'dragend', () => {
655
783
  dragId = undefined;
656
784
  for (const r of regions.values()) delete r.element.dataset.drop;
@@ -702,15 +830,28 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
702
830
  themeObserver.observe(ancestor, { attributes: true });
703
831
  themeObserver.observe(doc.head, { childList: true, subtree: true, characterData: true });
704
832
  scope.add(() => themeObserver.disconnect());
833
+ // Cancel asynchronous actions before tearing down views and the owned store.
834
+ scope.add(() => {
835
+ closeScope.dispose();
836
+ windows.dispose();
837
+ });
705
838
  render();
706
839
  return {
840
+ store: options.store,
841
+ ...actions,
842
+ getShortcuts: () => registeredShortcuts(options),
707
843
  requestClose,
708
844
  refreshTheme,
709
845
  updateOptions(next) {
710
846
  if (disposed) return;
847
+ if ('store' in next || 'initialLayout' in next || 'initialWorkspace' in next)
848
+ throw new Error(
849
+ 'Initial configuration cannot be updated; use loadWorkspace or store commands',
850
+ );
711
851
  const candidate = { ...options, ...next };
712
852
  if (candidate.registry && (next.renderers || next.tabs))
713
853
  throw new Error('registry cannot be combined with renderers or tabs');
854
+ registeredShortcuts(candidate);
714
855
  validateTheme(candidate.theme ?? {});
715
856
  validateTabBar(candidate.tabBar ?? {});
716
857
  const sameRegistrations =
@@ -736,8 +877,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
736
877
  (!candidate.registry && candidate.tabs !== options.tabs) ||
737
878
  candidate.renderIcon !== options.renderIcon ||
738
879
  candidate.popouts !== options.popouts ||
739
- JSON.stringify(candidate.messages) !== JSON.stringify(options.messages) ||
740
- JSON.stringify(candidate.shortcuts) !== JSON.stringify(options.shortcuts);
880
+ JSON.stringify(candidate.messages) !== JSON.stringify(options.messages);
741
881
  // Clear maps derived from the old registry when returning to low-level registration.
742
882
  if ('registry' in next && !next.registry && options.registry) {
743
883
  Object.assign(options, { renderers: undefined, tabs: undefined });
@@ -747,6 +887,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
747
887
  if ('tabBar' in next) tabBar = structuredClone(options.tabBar ?? {});
748
888
  bindRegistry();
749
889
  refreshOptions(repaintChrome);
890
+ menu.refreshShortcuts();
750
891
  refreshTheme();
751
892
  },
752
893
  exportWorkspace() {
@@ -801,21 +942,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
801
942
  dispose() {
802
943
  if (disposed) return;
803
944
  disposed = true;
804
- menu.dispose();
805
- dragScope?.dispose();
806
945
  scope.dispose();
807
- windows.dispose();
808
- for (const p of panes.values()) {
809
- try {
810
- p.dispose();
811
- } catch (e) {
812
- error(e);
813
- }
814
- }
815
- for (const r of regions.values()) r.scope.dispose();
816
- panes.clear();
817
- regions.clear();
818
- root.remove();
819
946
  },
820
947
  };
821
948
  }
package/src/resize.ts CHANGED
@@ -6,6 +6,7 @@ export function isolatedResize(
6
6
  layout: Layout,
7
7
  splitId: string,
8
8
  extent: (node: Node, axis: Axis) => number,
9
+ measureBounds: typeof bounds = bounds,
9
10
  ) {
10
11
  const split = findNode(layout.root, splitId);
11
12
  if (split?.kind !== 'split') throw new Error('Split not found');
@@ -22,7 +23,7 @@ export function isolatedResize(
22
23
  capture(split);
23
24
  function limits(node: Node, start: boolean): [number, number] {
24
25
  if (node.kind === 'group') {
25
- const b = bounds(node, layout),
26
+ const b = measureBounds(node, layout),
26
27
  size = sizes.get(node.id)!;
27
28
  return axis === 'horizontal'
28
29
  ? [b.minWidth - size, b.maxWidth - size]
@@ -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
+ }
package/src/shortcuts.ts CHANGED
@@ -1,14 +1,7 @@
1
- import { findNode } from 'quilt-core';
2
- import type { Group } from 'quilt-core';
3
- import type { ResolvedLayoutOptions, KeyBinding } from './types.js';
1
+ import type { ActionContext, ResolvedLayoutOptions, WorkspaceAction } from './types.js';
4
2
  import type { Scope } from './lifetime.js';
5
- type Action = 'maximize' | 'addTab' | 'restoreClosedTab';
6
- const defaults: Record<Action, readonly KeyBinding[]> = {
7
- maximize: [{ key: '`' }, { key: ' ', alt: true }],
8
- addTab: [{ key: 't' }],
9
- restoreClosedTab: [{ key: 'r' }],
10
- };
11
- export function shortcutEnabled(options: ResolvedLayoutOptions, key: Action | 'middleClickClose') {
3
+ import { registeredShortcuts } from './shortcut-registration.js';
4
+ export function shortcutEnabled(options: ResolvedLayoutOptions, key: 'middleClickClose') {
12
5
  return (
13
6
  options.shortcuts === true ||
14
7
  (typeof options.shortcuts === 'object' && !!options.shortcuts[key])
@@ -19,8 +12,10 @@ export function bindShortcuts(
19
12
  root: HTMLElement,
20
13
  options: ResolvedLayoutOptions,
21
14
  scope: Scope,
22
- act: (fn: () => void) => void,
23
- addTab: (group: Group) => void,
15
+ canExecute: (action: WorkspaceAction, context?: ActionContext) => boolean,
16
+ execute: (action: WorkspaceAction, context?: ActionContext) => Promise<boolean>,
17
+ menuTarget: () =>
18
+ { dialog: HTMLDialogElement; context: ActionContext; close(): void } | undefined,
24
19
  ) {
25
20
  const doc = root.ownerDocument;
26
21
  let hoveredGroup: string | undefined;
@@ -35,65 +30,45 @@ export function bindShortcuts(
35
30
  scope.add(() => {
36
31
  if (hovered.get(doc) === root) hovered.delete(doc);
37
32
  });
33
+ const ownsFocus = () =>
34
+ (doc.activeElement?.closest<HTMLElement>('.layouts') ?? hovered.get(doc)) === root;
35
+ const context = (): ActionContext => {
36
+ if (!ownsFocus()) return {};
37
+ const menu = menuTarget();
38
+ if (menu) return menu.context;
39
+ const focusedGroup = doc.activeElement?.closest<HTMLElement>('.layouts-group')?.dataset.nodeId;
40
+ const groupId = (hovered.get(doc) === root ? hoveredGroup : undefined) ?? focusedGroup;
41
+ return groupId ? { groupId } : {};
42
+ };
38
43
  scope.listen(doc, 'keydown', (event) => {
39
44
  const e = event as KeyboardEvent;
40
45
  const target = e.target as HTMLElement | null;
41
- const focused = doc.activeElement?.closest<HTMLElement>('.layouts');
42
- if ((focused ?? hovered.get(doc)) !== root || e.defaultPrevented || e.repeat || e.isComposing)
43
- return;
46
+ if (!ownsFocus() || e.defaultPrevented || e.repeat || e.isComposing) return;
47
+ const menu = menuTarget();
44
48
  if (
45
49
  target?.closest(
46
- 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), dialog',
50
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
47
51
  ) ||
48
- doc.querySelector('dialog[open]')
52
+ Array.from(doc.querySelectorAll('dialog[open]')).some((dialog) => dialog !== menu?.dialog)
49
53
  )
50
54
  return;
51
- const matches = (binding: KeyBinding) =>
52
- e.key.toLowerCase() === binding.key.toLowerCase() &&
53
- e.ctrlKey === !!binding.ctrl &&
54
- e.altKey === !!binding.alt &&
55
- e.shiftKey === !!binding.shift &&
56
- e.metaKey === !!binding.meta;
57
- const action = (Object.keys(defaults) as Action[]).find((key) => {
58
- const configured =
59
- options.shortcuts === true
60
- ? true
61
- : typeof options.shortcuts === 'object'
62
- ? options.shortcuts[key]
63
- : false;
64
- if (!configured) return false;
65
- const bindings =
66
- configured === true
67
- ? defaults[key]
68
- : Array.isArray(configured)
69
- ? configured
70
- : [configured as KeyBinding];
71
- return bindings.some(matches);
72
- });
73
- if (!action) return;
74
- if (action === 'restoreClosedTab') {
75
- if (!options.store.canRestoreClosedTab()) return;
76
- e.preventDefault();
77
- act(() => options.store.restoreClosedTab());
78
- return;
79
- }
80
- const focusedGroup =
81
- focused === root ? target?.closest<HTMLElement>('.layouts-group')?.dataset.nodeId : undefined;
82
- const id = (hovered.get(doc) === root ? hoveredGroup : undefined) ?? focusedGroup;
83
- const group = id ? findNode(options.store.getSnapshot().root, id) : undefined;
84
- if (group?.kind !== 'group') return;
85
- if (
86
- action === 'addTab' &&
87
- (!options.tabs?.list().length || !group.panes.every((id) => options.store.can(id, 'move')))
88
- )
89
- return;
90
- e.preventDefault();
91
- act(() =>
92
- action === 'addTab'
93
- ? addTab(group)
94
- : options.store.maximize(
95
- options.store.getSnapshot().maximized === group.id ? null : group.id,
96
- ),
55
+ const entry = registeredShortcuts(options).find(
56
+ (entry) =>
57
+ entry.handling === 'quilt' &&
58
+ entry.bindings.some(
59
+ (binding) =>
60
+ e.key.toLowerCase() === binding.key.toLowerCase() &&
61
+ e.ctrlKey === !!binding.ctrl &&
62
+ e.altKey === !!binding.alt &&
63
+ e.shiftKey === !!binding.shift &&
64
+ e.metaKey === !!binding.meta,
65
+ ),
97
66
  );
67
+ const actionContext = context();
68
+ if (!entry || !canExecute(entry.action, actionContext)) return;
69
+ e.preventDefault();
70
+ menu?.close();
71
+ void execute(entry.action, actionContext);
98
72
  });
73
+ return context;
99
74
  }