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.
Files changed (50) hide show
  1. package/README.md +35 -39
  2. package/dist/chrome-size.d.ts +7 -0
  3. package/dist/chrome-size.d.ts.map +1 -0
  4. package/dist/chrome-size.js +86 -0
  5. package/dist/chrome-size.js.map +1 -0
  6. package/dist/close.d.ts.map +1 -1
  7. package/dist/close.js +3 -1
  8. package/dist/close.js.map +1 -1
  9. package/dist/corners.d.ts +2 -1
  10. package/dist/corners.d.ts.map +1 -1
  11. package/dist/corners.js +2 -2
  12. package/dist/corners.js.map +1 -1
  13. package/dist/index.d.ts +5 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/panes.d.ts.map +1 -1
  18. package/dist/panes.js +84 -52
  19. package/dist/panes.js.map +1 -1
  20. package/dist/registry.d.ts +1 -0
  21. package/dist/registry.d.ts.map +1 -1
  22. package/dist/registry.js +4 -3
  23. package/dist/registry.js.map +1 -1
  24. package/dist/renderer.d.ts.map +1 -1
  25. package/dist/renderer.js +94 -29
  26. package/dist/renderer.js.map +1 -1
  27. package/dist/resize.d.ts +2 -1
  28. package/dist/resize.d.ts.map +1 -1
  29. package/dist/resize.js +2 -2
  30. package/dist/resize.js.map +1 -1
  31. package/dist/styles.css +49 -0
  32. package/dist/types.d.ts +29 -8
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/workspace-api.d.ts +180 -0
  36. package/dist/workspace-api.d.ts.map +1 -0
  37. package/dist/workspace-api.js +395 -0
  38. package/dist/workspace-api.js.map +1 -0
  39. package/package.json +2 -2
  40. package/src/chrome-size.ts +99 -0
  41. package/src/close.ts +6 -1
  42. package/src/corners.ts +2 -1
  43. package/src/index.ts +25 -4
  44. package/src/panes.ts +82 -55
  45. package/src/registry.ts +6 -4
  46. package/src/renderer.ts +141 -48
  47. package/src/resize.ts +2 -1
  48. package/src/styles.css +49 -0
  49. package/src/types.ts +24 -9
  50. 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.2.4",
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.2.4"
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
- if (!panes.length || panes.some((pane) => !options.store.can(pane.id, 'close'))) return false;
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 = bounds(current, layout);
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 { mountLayout } from './renderer.js';
2
- export type * from './types.js';
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 { LayoutStore, LayoutError, createLayout, parseLayout, validate } from 'quilt-core';
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
- if (renderer)
34
- view = renderer({
35
- element,
36
- reportError,
37
- document: doc,
38
- window: win,
39
- pane,
40
- state: options.getPaneState?.(pane.id),
41
- location,
42
- });
43
- else {
44
- element.append(
45
- el(
46
- doc,
47
- 'div',
48
- 'layouts-placeholder',
49
- message(options, 'Unknown pane type: {type}. Register a renderer to display this pane.', {
50
- type: pane.type,
51
- }),
52
- ),
53
- );
54
- view = { dispose() {} };
55
- }
56
- const observer = new ResizeObserver((entries) => {
57
- const rect = entries[0]?.contentRect;
58
- if (rect) {
59
- try {
60
- view.resize?.(rect.width, rect.height);
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
- observer.observe(element);
67
- let disposed = false;
68
- result = {
69
- element,
70
- view,
71
- pane,
72
- signature: JSON.stringify(pane),
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
- return result;
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 { allocate, bounds, DIVIDER, findNode, groups, paneIds } from 'quilt-core';
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
- // 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>;
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
- function mountLayoutInternal(host: HTMLElement, input: ResolvedLayoutOptions): MountedLayout {
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 scope = new Scope(),
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 requestClose = closeRequests(root, options, scope, error);
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(geometryLayout(), node.id, (child, axis) => {
207
- const rect = regions.get(child.id)!.element.getBoundingClientRect();
208
- return axis === 'horizontal' ? rect.width : rect.height;
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(geometryLayout(), node.id, (child, axis) => {
262
- const rect = regions.get(child.id)!.element.getBoundingClientRect();
263
- return axis === 'horizontal' ? rect.width : rect.height;
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
- const observer = new ResizeObserver(measure);
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(() => observer.disconnect());
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
  }