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.
- package/README.md +35 -39
- package/dist/actions.d.ts +14 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +115 -0
- package/dist/actions.js.map +1 -0
- package/dist/chrome-size.d.ts +7 -0
- package/dist/chrome-size.d.ts.map +1 -0
- package/dist/chrome-size.js +86 -0
- package/dist/chrome-size.js.map +1 -0
- package/dist/close.d.ts.map +1 -1
- package/dist/close.js +3 -1
- package/dist/close.js.map +1 -1
- package/dist/corners.d.ts +2 -1
- package/dist/corners.d.ts.map +1 -1
- package/dist/corners.js +2 -2
- package/dist/corners.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/menu.d.ts +9 -3
- package/dist/menu.d.ts.map +1 -1
- package/dist/menu.js +110 -37
- package/dist/menu.js.map +1 -1
- package/dist/panes.d.ts.map +1 -1
- package/dist/panes.js +84 -52
- package/dist/panes.js.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -3
- package/dist/registry.js.map +1 -1
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +128 -36
- package/dist/renderer.js.map +1 -1
- package/dist/resize.d.ts +2 -1
- package/dist/resize.d.ts.map +1 -1
- package/dist/resize.js +2 -2
- package/dist/resize.js.map +1 -1
- package/dist/shortcut-registration.d.ts +9 -0
- package/dist/shortcut-registration.d.ts.map +1 -0
- package/dist/shortcut-registration.js +95 -0
- package/dist/shortcut-registration.js.map +1 -0
- package/dist/shortcuts.d.ts +7 -6
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +28 -50
- package/dist/shortcuts.js.map +1 -1
- package/dist/styles.css +74 -9
- package/dist/tabs.d.ts.map +1 -1
- package/dist/tabs.js +10 -12
- package/dist/tabs.js.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workspace-api.d.ts +186 -0
- package/dist/workspace-api.d.ts.map +1 -0
- package/dist/workspace-api.js +409 -0
- package/dist/workspace-api.js.map +1 -0
- package/package.json +2 -2
- package/src/actions.ts +125 -0
- package/src/chrome-size.ts +99 -0
- package/src/close.ts +6 -1
- package/src/corners.ts +2 -1
- package/src/index.ts +31 -4
- package/src/menu.ts +123 -43
- package/src/panes.ts +82 -55
- package/src/registry.ts +6 -4
- package/src/renderer.ts +181 -54
- package/src/resize.ts +2 -1
- package/src/shortcut-registration.ts +106 -0
- package/src/shortcuts.ts +38 -63
- package/src/styles.css +74 -9
- package/src/tabs.ts +9 -11
- package/src/types.ts +67 -14
- 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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
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(
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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"])
|
|
50
|
+
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
|
|
47
51
|
) ||
|
|
48
|
-
doc.
|
|
52
|
+
Array.from(doc.querySelectorAll('dialog[open]')).some((dialog) => dialog !== menu?.dialog)
|
|
49
53
|
)
|
|
50
54
|
return;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|