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.
- 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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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/renderer.d.ts.map +1 -1
- package/dist/renderer.js +35 -8
- package/dist/renderer.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 +25 -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 +21 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workspace-api.d.ts +8 -2
- package/dist/workspace-api.d.ts.map +1 -1
- package/dist/workspace-api.js +14 -0
- package/dist/workspace-api.js.map +1 -1
- package/package.json +2 -2
- package/src/actions.ts +125 -0
- package/src/index.ts +6 -0
- package/src/menu.ts +123 -43
- package/src/renderer.ts +41 -7
- package/src/shortcut-registration.ts +106 -0
- package/src/shortcuts.ts +38 -63
- package/src/styles.css +25 -9
- package/src/tabs.ts +9 -11
- package/src/types.ts +43 -5
- 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
|
+
"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.
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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'),
|
|
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
|
-
|
|
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,
|
|
245
|
-
[message(options, 'Split left'), 'horizontal',
|
|
246
|
-
[message(options, 'Split right'), 'horizontal',
|
|
247
|
-
[message(options, 'Split up'), 'vertical',
|
|
248
|
-
[message(options, 'Split down'), 'vertical',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|