quilt-vanilla 0.2.4 → 0.3.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/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 +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.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 +94 -29
- 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/styles.css +49 -0
- package/dist/types.d.ts +29 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workspace-api.d.ts +180 -0
- package/dist/workspace-api.d.ts.map +1 -0
- package/dist/workspace-api.js +395 -0
- package/dist/workspace-api.js.map +1 -0
- package/package.json +2 -2
- package/src/chrome-size.ts +99 -0
- package/src/close.ts +6 -1
- package/src/corners.ts +2 -1
- package/src/index.ts +25 -4
- package/src/panes.ts +82 -55
- package/src/registry.ts +6 -4
- package/src/renderer.ts +141 -48
- package/src/resize.ts +2 -1
- package/src/styles.css +49 -0
- package/src/types.ts +24 -9
- package/src/workspace-api.ts +561 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilt-vanilla",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.3.0"
|
|
34
34
|
},
|
|
35
35
|
"sideEffects": [
|
|
36
36
|
"**/*.css"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { TabBarStyle } from './types.js';
|
|
2
|
+
import { themePixels } from './theme.js';
|
|
3
|
+
|
|
4
|
+
/** Measure only chrome, at its smallest presentation, without touching live views. */
|
|
5
|
+
export function chromeMinimum(
|
|
6
|
+
region: HTMLElement,
|
|
7
|
+
header: HTMLElement,
|
|
8
|
+
style: TabBarStyle,
|
|
9
|
+
): { minWidth: number; minHeight: number } {
|
|
10
|
+
if (header.hidden) return { minWidth: 0, minHeight: 0 };
|
|
11
|
+
const doc = region.ownerDocument;
|
|
12
|
+
const win = doc.defaultView!;
|
|
13
|
+
const left = style.placement === 'left';
|
|
14
|
+
const compact = style.display === 'compact';
|
|
15
|
+
const probe = region.cloneNode(false) as HTMLElement;
|
|
16
|
+
probe.removeAttribute('id');
|
|
17
|
+
probe.removeAttribute('data-node-id');
|
|
18
|
+
probe.dataset.tabPlacement = left ? 'left' : 'top';
|
|
19
|
+
probe.dataset.tabDisplay = compact ? 'compact' : 'automatic';
|
|
20
|
+
probe.dataset.tabAttachment = 'anchored';
|
|
21
|
+
probe.dataset.tabBar = 'full';
|
|
22
|
+
probe.dataset.tabBarFilled = 'true';
|
|
23
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
24
|
+
probe.inert = true;
|
|
25
|
+
probe.style.cssText =
|
|
26
|
+
'position:fixed;left:-10000px;top:0;width:10000px;height:10000px;visibility:hidden;pointer-events:none';
|
|
27
|
+
const bar = header.cloneNode(true) as HTMLElement;
|
|
28
|
+
bar.removeAttribute('style');
|
|
29
|
+
bar.dataset.tabDisplay = probe.dataset.tabDisplay;
|
|
30
|
+
bar.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'));
|
|
31
|
+
bar
|
|
32
|
+
.querySelectorAll('.layouts-tab-cap,.layouts-tab-outline')
|
|
33
|
+
.forEach((element) => element.remove());
|
|
34
|
+
probe.append(bar);
|
|
35
|
+
region.parentElement!.append(probe);
|
|
36
|
+
try {
|
|
37
|
+
const css = (element: Element) => win.getComputedStyle(element);
|
|
38
|
+
const px = (value: string) => parseFloat(value) || 0; // Computed CSS dimensions are pixels.
|
|
39
|
+
const outer = (element: HTMLElement, vertical: boolean) => {
|
|
40
|
+
const c = css(element);
|
|
41
|
+
return (
|
|
42
|
+
(vertical
|
|
43
|
+
? element.getBoundingClientRect().height
|
|
44
|
+
: element.getBoundingClientRect().width) +
|
|
45
|
+
px(vertical ? c.marginTop : c.marginLeft) +
|
|
46
|
+
px(vertical ? c.marginBottom : c.marginRight)
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
const items = [...bar.querySelectorAll<HTMLElement>('.layouts-tab-item')];
|
|
50
|
+
const icon = themePixels(region, '--layouts-icon-size', 16);
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
item.querySelector<HTMLElement>('.layouts-tab-label')!.style.display = 'none';
|
|
53
|
+
if (!left) {
|
|
54
|
+
const close = item.querySelector<HTMLElement>('.layouts-tab-close');
|
|
55
|
+
const showClose = !compact && item.dataset.active === 'true' && !!close;
|
|
56
|
+
if (close) close.style.display = showClose ? 'block' : 'none';
|
|
57
|
+
const minimum = Math.max(
|
|
58
|
+
px(css(item).minWidth),
|
|
59
|
+
icon + (compact ? 16 : 8) + (showClose ? outer(close!, false) : 0),
|
|
60
|
+
);
|
|
61
|
+
item.style.flex = '0 0 ' + minimum + 'px';
|
|
62
|
+
item.style.width = minimum + 'px';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const list = bar.querySelector<HTMLElement>('.layouts-tabs')!;
|
|
66
|
+
const controls = [...bar.children].filter(
|
|
67
|
+
(child): child is HTMLElement =>
|
|
68
|
+
child instanceof win.HTMLElement && child.classList.contains('layouts-button'),
|
|
69
|
+
);
|
|
70
|
+
const c = css(bar);
|
|
71
|
+
const listCss = css(list);
|
|
72
|
+
const tabs =
|
|
73
|
+
items.reduce((sum, item) => sum + outer(item, left), 0) +
|
|
74
|
+
Math.max(0, items.length - 1) * px(left ? listCss.rowGap : listCss.columnGap);
|
|
75
|
+
const main =
|
|
76
|
+
tabs +
|
|
77
|
+
controls.reduce((sum, control) => sum + outer(control, left), 0) +
|
|
78
|
+
Math.max(0, controls.length + (items.length ? 1 : 0) - 1) *
|
|
79
|
+
px(left ? c.rowGap : c.columnGap) +
|
|
80
|
+
px(left ? c.paddingTop : c.paddingLeft) +
|
|
81
|
+
px(left ? c.paddingBottom : c.paddingRight) +
|
|
82
|
+
px(left ? c.borderTopWidth : c.borderLeftWidth) +
|
|
83
|
+
px(left ? c.borderBottomWidth : c.borderRightWidth);
|
|
84
|
+
const cross = left ? bar.getBoundingClientRect().width : bar.getBoundingClientRect().height;
|
|
85
|
+
// Reserve the possible content scrollbar gutter for floating chrome. This also
|
|
86
|
+
// prevents the minimum from oscillating as content begins/stops scrolling.
|
|
87
|
+
const inset =
|
|
88
|
+
style.attachment === 'floating'
|
|
89
|
+
? 2 * Math.max(0, themePixels(region, '--layouts-panel-padding', 8)) +
|
|
90
|
+
Math.max(0, themePixels(region, '--layouts-scrollbar-size', 6))
|
|
91
|
+
: 0;
|
|
92
|
+
return {
|
|
93
|
+
minWidth: Math.ceil((left ? cross : main) + inset),
|
|
94
|
+
minHeight: Math.ceil((left ? main : cross) + inset),
|
|
95
|
+
};
|
|
96
|
+
} finally {
|
|
97
|
+
probe.remove();
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/close.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function closeRequests(
|
|
|
41
41
|
};
|
|
42
42
|
let cleanup = () => {};
|
|
43
43
|
try {
|
|
44
|
-
|
|
44
|
+
const target = kind === 'group' ? findNode(options.store.getSnapshot().root, id) : undefined;
|
|
45
|
+
if (
|
|
46
|
+
(!panes.length && target?.kind !== 'group') ||
|
|
47
|
+
panes.some((pane) => !options.store.can(pane.id, 'close'))
|
|
48
|
+
)
|
|
49
|
+
return false;
|
|
45
50
|
if (requiringConfirmation.length) {
|
|
46
51
|
const decision = options.confirmClose
|
|
47
52
|
? Promise.resolve(options.confirmClose(request))
|
package/src/corners.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function bindCorners(
|
|
|
12
12
|
scope: Scope,
|
|
13
13
|
split: (pane: Pane | undefined, group: Group, direction: Direction, ratio: number) => void,
|
|
14
14
|
report: (error: unknown) => void,
|
|
15
|
+
measureBounds: typeof bounds = bounds,
|
|
15
16
|
) {
|
|
16
17
|
const doc = host.ownerDocument;
|
|
17
18
|
let drag: Scope | undefined;
|
|
@@ -116,7 +117,7 @@ export function bindCorners(
|
|
|
116
117
|
const layout = options.store.getSnapshot();
|
|
117
118
|
const current = findNode(layout.root, id);
|
|
118
119
|
if (!current) return;
|
|
119
|
-
const constraints =
|
|
120
|
+
const constraints = measureBounds(current, layout);
|
|
120
121
|
const min = horizontal ? constraints.minWidth : constraints.minHeight;
|
|
121
122
|
const max = horizontal ? constraints.maxWidth : constraints.maxHeight;
|
|
122
123
|
const total = horizontal ? rect.width : rect.height;
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type
|
|
1
|
+
export { Workspace } from './workspace-api.js';
|
|
2
|
+
export type {
|
|
3
|
+
WorkspaceHandle,
|
|
4
|
+
WorkspaceOptions,
|
|
5
|
+
WorkspaceOptionUpdates,
|
|
6
|
+
WorkspaceEvents,
|
|
7
|
+
WorkspaceChange,
|
|
8
|
+
PaneHandle,
|
|
9
|
+
PaneType,
|
|
10
|
+
PaneTypes,
|
|
11
|
+
AddPaneOptions,
|
|
12
|
+
CloseOptions,
|
|
13
|
+
} from './workspace-api.js';
|
|
14
|
+
export type {
|
|
15
|
+
PaneContext,
|
|
16
|
+
PaneView,
|
|
17
|
+
PaneRenderer,
|
|
18
|
+
TabBarStyle,
|
|
19
|
+
TabBarOptions,
|
|
20
|
+
InitialConfiguration,
|
|
21
|
+
WorkspaceSettings,
|
|
22
|
+
KeyBinding,
|
|
23
|
+
CloseRequest,
|
|
24
|
+
} from './types.js';
|
|
3
25
|
|
|
4
26
|
export { TabRegistry } from './registry.js';
|
|
5
27
|
export type { TabRegistration } from './registry.js';
|
|
6
28
|
export { themes, themeFamilies } from './theme.js';
|
|
7
29
|
export type { LayoutTheme } from './theme.js';
|
|
8
|
-
export {
|
|
30
|
+
export { LayoutError, createLayout, parseLayout, validate } from 'quilt-core';
|
|
9
31
|
export type {
|
|
10
32
|
AutoCollapse,
|
|
11
|
-
LayoutStoreOptions,
|
|
12
33
|
Json,
|
|
13
34
|
Capability,
|
|
14
35
|
Axis,
|
package/src/panes.ts
CHANGED
|
@@ -24,67 +24,94 @@ export function mountPane(
|
|
|
24
24
|
const renderer = Object.hasOwn(options.renderers ?? {}, pane.type)
|
|
25
25
|
? options.renderers?.[pane.type]
|
|
26
26
|
: undefined;
|
|
27
|
-
let view: PaneView;
|
|
27
|
+
let view: PaneView = { dispose() {} };
|
|
28
|
+
let observer: ResizeObserver | undefined;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
const controller = new (win as Window & typeof globalThis).AbortController();
|
|
31
|
+
const cleanups: (() => void)[] = [];
|
|
32
|
+
const reportCleanup = (error: unknown) => {
|
|
33
|
+
try {
|
|
34
|
+
options.onError?.(error);
|
|
35
|
+
} catch {
|
|
36
|
+
/* Finish teardown even if reporting fails. */
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const run = (callback: () => void) => {
|
|
40
|
+
try {
|
|
41
|
+
callback();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
reportCleanup(error);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const dispose = () => {
|
|
47
|
+
if (disposed) return;
|
|
48
|
+
disposed = true;
|
|
49
|
+
run(() => controller.abort());
|
|
50
|
+
run(() => observer?.disconnect());
|
|
51
|
+
run(() => view.dispose());
|
|
52
|
+
for (const cleanup of cleanups.splice(0).reverse()) run(cleanup);
|
|
53
|
+
element.remove();
|
|
54
|
+
};
|
|
28
55
|
let result: MountedPane | undefined;
|
|
29
56
|
const reportError = (error: unknown) => {
|
|
30
57
|
if (result) result.failed = true;
|
|
31
58
|
options.onError?.(error);
|
|
32
59
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} catch (error) {
|
|
62
|
-
options.onError?.(error);
|
|
63
|
-
}
|
|
60
|
+
try {
|
|
61
|
+
if (renderer)
|
|
62
|
+
view = renderer({
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
onCleanup(callback) {
|
|
65
|
+
if (disposed) run(callback);
|
|
66
|
+
else cleanups.push(callback);
|
|
67
|
+
},
|
|
68
|
+
element,
|
|
69
|
+
reportError,
|
|
70
|
+
document: doc,
|
|
71
|
+
window: win,
|
|
72
|
+
pane,
|
|
73
|
+
state: options.getPaneState?.(pane.id),
|
|
74
|
+
location,
|
|
75
|
+
}) ?? { dispose() {} };
|
|
76
|
+
else {
|
|
77
|
+
element.append(
|
|
78
|
+
el(
|
|
79
|
+
doc,
|
|
80
|
+
'div',
|
|
81
|
+
'layouts-placeholder',
|
|
82
|
+
message(options, 'Unknown pane type: {type}. Register a renderer to display this pane.', {
|
|
83
|
+
type: pane.type,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
view = { dispose() {} };
|
|
64
88
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
renderer,
|
|
74
|
-
dispose() {
|
|
75
|
-
if (disposed) return;
|
|
76
|
-
disposed = true;
|
|
77
|
-
observer.disconnect();
|
|
78
|
-
try {
|
|
79
|
-
view.dispose();
|
|
80
|
-
} finally {
|
|
81
|
-
element.remove();
|
|
89
|
+
observer = new ResizeObserver((entries) => {
|
|
90
|
+
const rect = entries[0]?.contentRect;
|
|
91
|
+
if (rect) {
|
|
92
|
+
try {
|
|
93
|
+
view.resize?.(rect.width, rect.height);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
options.onError?.(error);
|
|
96
|
+
}
|
|
82
97
|
}
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
if (location === 'main')
|
|
86
|
-
void view.ready?.catch((error) => {
|
|
87
|
-
if (!disposed) reportError(error);
|
|
88
98
|
});
|
|
89
|
-
|
|
99
|
+
observer.observe(element);
|
|
100
|
+
result = {
|
|
101
|
+
element,
|
|
102
|
+
view,
|
|
103
|
+
pane,
|
|
104
|
+
signature: JSON.stringify(pane),
|
|
105
|
+
renderer,
|
|
106
|
+
dispose,
|
|
107
|
+
};
|
|
108
|
+
if (location === 'main')
|
|
109
|
+
void view.ready?.catch((error) => {
|
|
110
|
+
if (!disposed) reportError(error);
|
|
111
|
+
});
|
|
112
|
+
return result;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
dispose();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
90
117
|
}
|
package/src/registry.ts
CHANGED
|
@@ -46,6 +46,11 @@ export class TabRegistry {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
let instanceSequence = 0;
|
|
49
|
+
export function createPaneId(): string {
|
|
50
|
+
return (
|
|
51
|
+
globalThis.crypto?.randomUUID?.() ?? `quilt-${Date.now().toString(36)}-${++instanceSequence}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
49
54
|
|
|
50
55
|
export interface PaneRegistration<T> {
|
|
51
56
|
type: string;
|
|
@@ -90,10 +95,7 @@ export class PaneRegistry<T> {
|
|
|
90
95
|
title: stored.title,
|
|
91
96
|
...(stored.icon !== undefined ? { icon: stored.icon } : {}),
|
|
92
97
|
...supplied,
|
|
93
|
-
id:
|
|
94
|
-
supplied.id ??
|
|
95
|
-
globalThis.crypto?.randomUUID?.() ??
|
|
96
|
-
`quilt-${Date.now().toString(36)}-${++instanceSequence}`,
|
|
98
|
+
id: supplied.id ?? createPaneId(),
|
|
97
99
|
};
|
|
98
100
|
},
|
|
99
101
|
});
|
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,18 @@ 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 {
|
|
14
|
+
LayoutStore,
|
|
15
|
+
createLayout,
|
|
16
|
+
allocate,
|
|
17
|
+
bounds as modelBounds,
|
|
18
|
+
DIVIDER,
|
|
19
|
+
findNode,
|
|
20
|
+
groups,
|
|
21
|
+
paneIds,
|
|
22
|
+
} from 'quilt-core';
|
|
13
23
|
import type { Group, Layout, Node, Pane } from 'quilt-core';
|
|
14
|
-
import type { LayoutOptions, ResolvedLayoutOptions, MountedLayout } from './types.js';
|
|
24
|
+
import type { LayoutOptions, ResolvedLayoutOptions, MountedLayout, TabBarStyle } from './types.js';
|
|
15
25
|
import { Scope, el, syncChildren } from './lifetime.js';
|
|
16
26
|
import { mountPane } from './panes.js';
|
|
17
27
|
import type { MountedPane } from './panes.js';
|
|
@@ -26,19 +36,51 @@ interface Region {
|
|
|
26
36
|
divider?: HTMLElement;
|
|
27
37
|
signature?: string;
|
|
28
38
|
updateTabBar?: () => void;
|
|
39
|
+
tabStyle?: () => TabBarStyle;
|
|
29
40
|
}
|
|
30
41
|
let nextMount = 0;
|
|
31
42
|
export function mountLayout<State = unknown>(
|
|
32
43
|
host: HTMLElement,
|
|
33
44
|
input: LayoutOptions<State>,
|
|
34
45
|
): MountedLayout<State> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
if ('store' in input)
|
|
47
|
+
throw new Error('Quilt owns its store; pass initialLayout or initialWorkspace');
|
|
48
|
+
if (input.initialLayout !== undefined && input.initialWorkspace !== undefined)
|
|
49
|
+
throw new Error('initialLayout and initialWorkspace are mutually exclusive');
|
|
50
|
+
if (input.registry && (input.renderers || input.tabs))
|
|
51
|
+
throw new Error('registry cannot be combined with renderers or tabs');
|
|
52
|
+
const preset =
|
|
53
|
+
input.initialWorkspace === undefined ? undefined : parseWorkspace(input.initialWorkspace);
|
|
54
|
+
const theme = preset?.theme ?? input.theme ?? {};
|
|
55
|
+
const tabBar = preset?.tabBar ?? input.tabBar ?? {};
|
|
56
|
+
validateTheme(theme);
|
|
57
|
+
validateTabBar(tabBar);
|
|
58
|
+
const store = new LayoutStore(
|
|
59
|
+
dockLayout(preset?.layout ?? input.initialLayout ?? createLayout()),
|
|
60
|
+
{
|
|
61
|
+
autoCollapse: preset?.autoCollapse ?? 'disabled',
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
// The initialization scope rolls back even if setup fails before returning a handle.
|
|
65
|
+
const scope = new Scope();
|
|
66
|
+
scope.add(() => store.dispose());
|
|
67
|
+
try {
|
|
68
|
+
return mountLayoutInternal(
|
|
69
|
+
host,
|
|
70
|
+
{ ...input, store, theme, tabBar } as unknown as ResolvedLayoutOptions,
|
|
71
|
+
scope,
|
|
72
|
+
) as MountedLayout<State>;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
scope.dispose();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
40
77
|
}
|
|
41
|
-
|
|
78
|
+
|
|
79
|
+
function mountLayoutInternal(
|
|
80
|
+
host: HTMLElement,
|
|
81
|
+
input: ResolvedLayoutOptions,
|
|
82
|
+
scope: Scope,
|
|
83
|
+
): MountedLayout {
|
|
42
84
|
const options = { ...input };
|
|
43
85
|
const configure = () => {
|
|
44
86
|
if (options.registry) {
|
|
@@ -58,8 +100,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
58
100
|
const doc = host.ownerDocument,
|
|
59
101
|
win = doc.defaultView;
|
|
60
102
|
if (!win) throw new Error('Layout host must belong to a live document');
|
|
61
|
-
const
|
|
62
|
-
regions = new Map<string, Region>(),
|
|
103
|
+
const regions = new Map<string, Region>(),
|
|
63
104
|
panes = new Map<string, MountedPane>();
|
|
64
105
|
const root = el(doc, 'div', 'layouts');
|
|
65
106
|
applyTheme(root, options.theme ?? {});
|
|
@@ -69,6 +110,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
69
110
|
status.setAttribute('role', 'status');
|
|
70
111
|
root.append(stage, status);
|
|
71
112
|
host.append(root);
|
|
113
|
+
scope.add(() => root.remove());
|
|
72
114
|
let disposed = false,
|
|
73
115
|
rendering = false,
|
|
74
116
|
again = false,
|
|
@@ -89,9 +131,20 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
89
131
|
error(e);
|
|
90
132
|
}
|
|
91
133
|
};
|
|
92
|
-
const
|
|
134
|
+
const closeScope = new Scope();
|
|
135
|
+
scope.add(() => closeScope.dispose());
|
|
136
|
+
const requestClose = closeRequests(root, options, closeScope, error);
|
|
93
137
|
const windows = new Windows(doc, options, error, root);
|
|
138
|
+
scope.add(() => windows.dispose());
|
|
139
|
+
scope.add(() => {
|
|
140
|
+
for (const pane of panes.values()) pane.dispose();
|
|
141
|
+
panes.clear();
|
|
142
|
+
for (const region of regions.values()) region.scope.dispose();
|
|
143
|
+
regions.clear();
|
|
144
|
+
});
|
|
94
145
|
const menu = createPaneMenu(root, options, windows, render, error, requestClose);
|
|
146
|
+
scope.add(() => menu.dispose());
|
|
147
|
+
scope.add(() => dragScope?.dispose());
|
|
95
148
|
bindShortcuts(root, options, scope, act, (group) => {
|
|
96
149
|
const region = regions.get(group.id);
|
|
97
150
|
if (region) menu.addTab(region.header ?? region.element, group);
|
|
@@ -123,24 +176,25 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
123
176
|
r.header = el(doc, 'header', 'layouts-header');
|
|
124
177
|
r.body = el(doc, 'div', 'layouts-body');
|
|
125
178
|
r.element.append(r.header, r.body);
|
|
179
|
+
r.tabStyle = () => {
|
|
180
|
+
const current = findNode(options.store.getSnapshot().root, node.id);
|
|
181
|
+
return {
|
|
182
|
+
...tabBar,
|
|
183
|
+
...(tabBar.regions?.[node.id] ?? {}),
|
|
184
|
+
...(current?.kind === 'group' && current.tabDisplay
|
|
185
|
+
? { display: current.tabDisplay }
|
|
186
|
+
: {}),
|
|
187
|
+
...(current?.kind === 'group' && current.tabPlacement
|
|
188
|
+
? { placement: current.tabPlacement }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
};
|
|
126
192
|
r.updateTabBar = bindTabBar(
|
|
127
193
|
r.element,
|
|
128
194
|
r.header,
|
|
129
195
|
root,
|
|
130
196
|
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
|
-
},
|
|
197
|
+
r.tabStyle,
|
|
144
198
|
() => options.messages ?? {},
|
|
145
199
|
);
|
|
146
200
|
bindCorners(
|
|
@@ -150,6 +204,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
150
204
|
r.scope,
|
|
151
205
|
(pane, group, direction, ratio) => menu.open(r.header!, pane, group, direction, ratio),
|
|
152
206
|
error,
|
|
207
|
+
bounds,
|
|
153
208
|
);
|
|
154
209
|
r.element.setAttribute('aria-label', message(options, 'Pane region'));
|
|
155
210
|
r.scope.listen(r.element, 'dragover', (event) => {
|
|
@@ -203,10 +258,15 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
203
258
|
dragScope?.dispose();
|
|
204
259
|
const drag = new Scope();
|
|
205
260
|
dragScope = drag;
|
|
206
|
-
const resize = isolatedResize(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
261
|
+
const resize = isolatedResize(
|
|
262
|
+
geometryLayout(),
|
|
263
|
+
node.id,
|
|
264
|
+
(child, axis) => {
|
|
265
|
+
const rect = regions.get(child.id)!.element.getBoundingClientRect();
|
|
266
|
+
return axis === 'horizontal' ? rect.width : rect.height;
|
|
267
|
+
},
|
|
268
|
+
bounds,
|
|
269
|
+
);
|
|
210
270
|
r.divider!.setPointerCapture(e.pointerId);
|
|
211
271
|
root.classList.add('layouts-resizing');
|
|
212
272
|
drag.add(() => {
|
|
@@ -258,10 +318,15 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
258
318
|
n.axis === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
|
|
259
319
|
if (keys.includes(e.key)) {
|
|
260
320
|
e.preventDefault();
|
|
261
|
-
const resize = isolatedResize(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
321
|
+
const resize = isolatedResize(
|
|
322
|
+
geometryLayout(),
|
|
323
|
+
node.id,
|
|
324
|
+
(child, axis) => {
|
|
325
|
+
const rect = regions.get(child.id)!.element.getBoundingClientRect();
|
|
326
|
+
return axis === 'horizontal' ? rect.width : rect.height;
|
|
327
|
+
},
|
|
328
|
+
bounds,
|
|
329
|
+
);
|
|
265
330
|
act(() =>
|
|
266
331
|
options.store.resizeMany(
|
|
267
332
|
resize.ratios(
|
|
@@ -558,9 +623,29 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
558
623
|
return node.axis === 'horizontal' ? b.minWidth < b.maxWidth : b.minHeight < b.maxHeight;
|
|
559
624
|
});
|
|
560
625
|
}
|
|
626
|
+
const chromeSizes = new Map<string, { minWidth: number; minHeight: number }>();
|
|
627
|
+
function bounds(node: Node, layout: Layout) {
|
|
628
|
+
return modelBounds(node, layout, (group, constraints) => {
|
|
629
|
+
const chrome = chromeSizes.get(group.id);
|
|
630
|
+
if (!chrome) return constraints;
|
|
631
|
+
const minWidth = Math.max(constraints.minWidth, chrome.minWidth);
|
|
632
|
+
const minHeight = Math.max(constraints.minHeight, chrome.minHeight);
|
|
633
|
+
return {
|
|
634
|
+
minWidth,
|
|
635
|
+
minHeight,
|
|
636
|
+
maxWidth: Math.max(constraints.maxWidth, minWidth),
|
|
637
|
+
maxHeight: Math.max(constraints.maxHeight, minHeight),
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
}
|
|
561
641
|
// Renderer-only gaps participate in bounds/allocation without modifying Layout JSON.
|
|
562
642
|
function geometryLayout(): Layout {
|
|
563
643
|
const layout = options.store.getSnapshot();
|
|
644
|
+
chromeSizes.clear();
|
|
645
|
+
for (const [id, region] of regions) {
|
|
646
|
+
if (region.header && region.tabStyle && region.element.isConnected)
|
|
647
|
+
chromeSizes.set(id, chromeMinimum(region.element, region.header, region.tabStyle()));
|
|
648
|
+
}
|
|
564
649
|
const pixels = (property: string, fallback: number) => themePixels(root, property, fallback);
|
|
565
650
|
const width = pixels('--layouts-resize-handle-width', DIVIDER);
|
|
566
651
|
const disabledWidth = pixels('--layouts-disabled-resize-handle-width', 0);
|
|
@@ -648,9 +733,21 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
648
733
|
render();
|
|
649
734
|
}),
|
|
650
735
|
);
|
|
651
|
-
|
|
736
|
+
// Geometry can change the stage's scrollbar gutter. Defer those writes out
|
|
737
|
+
// of ResizeObserver delivery so WebKit does not detect a nested resize loop.
|
|
738
|
+
let measureFrame = 0;
|
|
739
|
+
const observer = new ResizeObserver(() => {
|
|
740
|
+
if (!measureFrame && !disposed)
|
|
741
|
+
measureFrame = doc.defaultView!.requestAnimationFrame(() => {
|
|
742
|
+
measureFrame = 0;
|
|
743
|
+
if (!disposed) measure();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
652
746
|
observer.observe(stage);
|
|
653
|
-
scope.add(() =>
|
|
747
|
+
scope.add(() => {
|
|
748
|
+
observer.disconnect();
|
|
749
|
+
if (measureFrame) doc.defaultView!.cancelAnimationFrame(measureFrame);
|
|
750
|
+
});
|
|
654
751
|
scope.listen(doc, 'dragend', () => {
|
|
655
752
|
dragId = undefined;
|
|
656
753
|
for (const r of regions.values()) delete r.element.dataset.drop;
|
|
@@ -702,12 +799,22 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
702
799
|
themeObserver.observe(ancestor, { attributes: true });
|
|
703
800
|
themeObserver.observe(doc.head, { childList: true, subtree: true, characterData: true });
|
|
704
801
|
scope.add(() => themeObserver.disconnect());
|
|
802
|
+
// Cancel asynchronous actions before tearing down views and the owned store.
|
|
803
|
+
scope.add(() => {
|
|
804
|
+
closeScope.dispose();
|
|
805
|
+
windows.dispose();
|
|
806
|
+
});
|
|
705
807
|
render();
|
|
706
808
|
return {
|
|
809
|
+
store: options.store,
|
|
707
810
|
requestClose,
|
|
708
811
|
refreshTheme,
|
|
709
812
|
updateOptions(next) {
|
|
710
813
|
if (disposed) return;
|
|
814
|
+
if ('store' in next || 'initialLayout' in next || 'initialWorkspace' in next)
|
|
815
|
+
throw new Error(
|
|
816
|
+
'Initial configuration cannot be updated; use loadWorkspace or store commands',
|
|
817
|
+
);
|
|
711
818
|
const candidate = { ...options, ...next };
|
|
712
819
|
if (candidate.registry && (next.renderers || next.tabs))
|
|
713
820
|
throw new Error('registry cannot be combined with renderers or tabs');
|
|
@@ -801,21 +908,7 @@ function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): M
|
|
|
801
908
|
dispose() {
|
|
802
909
|
if (disposed) return;
|
|
803
910
|
disposed = true;
|
|
804
|
-
menu.dispose();
|
|
805
|
-
dragScope?.dispose();
|
|
806
911
|
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
912
|
},
|
|
820
913
|
};
|
|
821
914
|
}
|