quilt-vanilla 0.1.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/action-icons.d.ts +18 -0
  4. package/dist/action-icons.d.ts.map +1 -0
  5. package/dist/action-icons.js +36 -0
  6. package/dist/action-icons.js.map +1 -0
  7. package/dist/chrome-icons.d.ts +3 -0
  8. package/dist/chrome-icons.d.ts.map +1 -0
  9. package/dist/chrome-icons.js +32 -0
  10. package/dist/chrome-icons.js.map +1 -0
  11. package/dist/corners.d.ts +8 -0
  12. package/dist/corners.d.ts.map +1 -0
  13. package/dist/corners.js +205 -0
  14. package/dist/corners.js.map +1 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +5 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/lifetime.d.ts +10 -0
  20. package/dist/lifetime.d.ts.map +1 -0
  21. package/dist/lifetime.js +44 -0
  22. package/dist/lifetime.js.map +1 -0
  23. package/dist/menu.d.ts +10 -0
  24. package/dist/menu.d.ts.map +1 -0
  25. package/dist/menu.js +356 -0
  26. package/dist/menu.js.map +1 -0
  27. package/dist/panes.d.ts +13 -0
  28. package/dist/panes.d.ts.map +1 -0
  29. package/dist/panes.js +58 -0
  30. package/dist/panes.js.map +1 -0
  31. package/dist/picker.d.ts +6 -0
  32. package/dist/picker.d.ts.map +1 -0
  33. package/dist/picker.js +112 -0
  34. package/dist/picker.js.map +1 -0
  35. package/dist/registry.d.ts +25 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +39 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/renderer.d.ts +3 -0
  40. package/dist/renderer.d.ts.map +1 -0
  41. package/dist/renderer.js +570 -0
  42. package/dist/renderer.js.map +1 -0
  43. package/dist/resize.d.ts +7 -0
  44. package/dist/resize.d.ts.map +1 -0
  45. package/dist/resize.js +56 -0
  46. package/dist/resize.js.map +1 -0
  47. package/dist/shortcuts.d.ts +6 -0
  48. package/dist/shortcuts.d.ts.map +1 -0
  49. package/dist/shortcuts.js +64 -0
  50. package/dist/shortcuts.js.map +1 -0
  51. package/dist/styles.css +842 -0
  52. package/dist/tab-bar.d.ts +6 -0
  53. package/dist/tab-bar.d.ts.map +1 -0
  54. package/dist/tab-bar.js +336 -0
  55. package/dist/tab-bar.js.map +1 -0
  56. package/dist/tab-tooltip.d.ts +4 -0
  57. package/dist/tab-tooltip.d.ts.map +1 -0
  58. package/dist/tab-tooltip.js +95 -0
  59. package/dist/tab-tooltip.js.map +1 -0
  60. package/dist/tabs.d.ts +15 -0
  61. package/dist/tabs.d.ts.map +1 -0
  62. package/dist/tabs.js +126 -0
  63. package/dist/tabs.js.map +1 -0
  64. package/dist/theme.d.ts +206 -0
  65. package/dist/theme.d.ts.map +1 -0
  66. package/dist/theme.js +166 -0
  67. package/dist/theme.js.map +1 -0
  68. package/dist/types.d.ts +66 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/dist/types.js +2 -0
  71. package/dist/types.js.map +1 -0
  72. package/dist/windows.d.ts +23 -0
  73. package/dist/windows.d.ts.map +1 -0
  74. package/dist/windows.js +247 -0
  75. package/dist/windows.js.map +1 -0
  76. package/package.json +50 -0
  77. package/src/action-icons.ts +40 -0
  78. package/src/chrome-icons.ts +30 -0
  79. package/src/corners.ts +259 -0
  80. package/src/index.ts +27 -0
  81. package/src/lifetime.ts +46 -0
  82. package/src/menu.ts +432 -0
  83. package/src/panes.ts +76 -0
  84. package/src/picker.ts +114 -0
  85. package/src/registry.ts +46 -0
  86. package/src/renderer.ts +654 -0
  87. package/src/resize.ts +65 -0
  88. package/src/shortcuts.ts +90 -0
  89. package/src/styles.css +842 -0
  90. package/src/tab-bar.ts +382 -0
  91. package/src/tab-tooltip.ts +96 -0
  92. package/src/tabs.ts +129 -0
  93. package/src/theme.ts +211 -0
  94. package/src/types.ts +67 -0
  95. package/src/windows.ts +244 -0
package/src/corners.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { allocate, bounds, DIVIDER, findNode, joinRange } from 'quilt-core';
2
+ import type { Group, Pane } from 'quilt-core';
3
+ import type { LayoutOptions } from './types.js';
4
+ import { el, Scope } from './lifetime.js';
5
+ type Direction = 'left' | 'right' | 'top' | 'bottom';
6
+ /** Commit once on release; cancellation never changes the layout or pane ownership. */
7
+ export function bindCorners(
8
+ host: HTMLElement,
9
+ id: string,
10
+ options: LayoutOptions,
11
+ scope: Scope,
12
+ split: (pane: Pane | undefined, group: Group, direction: Direction, ratio: number) => void,
13
+ report: (error: unknown) => void,
14
+ ) {
15
+ const doc = host.ownerDocument;
16
+ let drag: Scope | undefined;
17
+ scope.add(() => drag?.dispose());
18
+ const initial = findNode(options.store.getSnapshot().root, id);
19
+ if (initial?.kind !== 'group' || !initial.panes.every((p) => options.store.can(p, 'split')))
20
+ return;
21
+ for (const corner of ['tl', 'tr', 'bl', 'br']) {
22
+ const handle = el(doc, 'button', 'layouts-corner');
23
+ handle.dataset.corner = corner;
24
+ handle.title = 'Drag inward to split; drag across adjacent regions to join. Escape cancels.';
25
+ handle.setAttribute('aria-label', 'Split or join region from ' + corner + ' corner');
26
+ handle.tabIndex = -1; // Equivalent keyboard operations live in the pane menu.
27
+ host.append(handle);
28
+ scope.add(() => handle.remove());
29
+ scope.listen(handle, 'pointerdown', (event) => {
30
+ const start = event as PointerEvent;
31
+ if (start.button !== 0) return;
32
+ const node = findNode(options.store.getSnapshot().root, id);
33
+ if (node?.kind !== 'group' || !node.panes.every((p) => options.store.can(p, 'split'))) return;
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+ drag?.dispose();
37
+ const local = new Scope();
38
+ drag = local;
39
+ local.add(options.store.subscribe(() => local.dispose()));
40
+ handle.setPointerCapture(start.pointerId);
41
+ let target:
42
+ | { kind: 'split'; direction: Direction; ratio: number }
43
+ | { kind: 'join'; id: string; extents: Record<string, number> }
44
+ | undefined;
45
+ const overlay = el(doc, 'div', 'layouts-corner-overlay');
46
+ overlay.setAttribute('aria-hidden', 'true');
47
+ host.closest('.layouts')!.append(overlay);
48
+ local.add(() => overlay.remove());
49
+ const mark = (
50
+ rect: { left: number; top: number; width: number; height: number },
51
+ kind: string,
52
+ label: string,
53
+ ) => {
54
+ const box = el(doc, 'div', 'layouts-corner-preview');
55
+ box.dataset.cornerPreview = kind;
56
+ Object.assign(box.style, {
57
+ left: `${rect.left}px`,
58
+ top: `${rect.top}px`,
59
+ width: `${rect.width}px`,
60
+ height: `${rect.height}px`,
61
+ });
62
+ box.append(el(doc, 'span', 'layouts-corner-label', label));
63
+ overlay.append(box);
64
+ };
65
+ const clear = () => {
66
+ overlay.replaceChildren();
67
+ target = undefined;
68
+ };
69
+ local.add(() => {
70
+ clear();
71
+ try {
72
+ handle.releasePointerCapture(start.pointerId);
73
+ } catch {}
74
+ });
75
+ local.listen(handle, 'pointermove', (e) => {
76
+ const move = e as PointerEvent;
77
+ clear();
78
+ if (Math.hypot(move.clientX - start.clientX, move.clientY - start.clientY) < 24) return;
79
+ const rect = host.getBoundingClientRect();
80
+ const inside =
81
+ move.clientX > rect.left &&
82
+ move.clientX < rect.right &&
83
+ move.clientY > rect.top &&
84
+ move.clientY < rect.bottom;
85
+ if (inside) {
86
+ const horizontal =
87
+ Math.abs(move.clientX - start.clientX) > Math.abs(move.clientY - start.clientY);
88
+ const direction: Direction = horizontal
89
+ ? corner.endsWith('l')
90
+ ? 'left'
91
+ : 'right'
92
+ : corner.startsWith('t')
93
+ ? 'top'
94
+ : 'bottom';
95
+ target = {
96
+ kind: 'split',
97
+ direction,
98
+ ratio: Math.max(
99
+ 0.1,
100
+ Math.min(
101
+ 0.9,
102
+ horizontal
103
+ ? (move.clientX - rect.left) / rect.width
104
+ : (move.clientY - rect.top) / rect.height,
105
+ ),
106
+ ),
107
+ };
108
+ const before = direction === 'left' || direction === 'top';
109
+ const layout = options.store.getSnapshot();
110
+ const current = findNode(layout.root, id);
111
+ if (!current) return;
112
+ const constraints = bounds(current, layout);
113
+ const min = horizontal ? constraints.minWidth : constraints.minHeight;
114
+ const max = horizontal ? constraints.maxWidth : constraints.maxHeight;
115
+ const total = horizontal ? rect.width : rect.height;
116
+ const disabled =
117
+ current.kind === 'group' &&
118
+ (!current.panes.every((p) => options.store.can(p, 'resize')) || min === max);
119
+ const configuredGap = parseFloat(
120
+ doc
121
+ .defaultView!.getComputedStyle(host.closest('.layouts')!)
122
+ .getPropertyValue(
123
+ disabled
124
+ ? '--layouts-disabled-resize-handle-width'
125
+ : '--layouts-resize-handle-width',
126
+ ),
127
+ );
128
+ const gap =
129
+ Number.isFinite(configuredGap) && configuredGap >= 0
130
+ ? configuredGap
131
+ : disabled
132
+ ? 0
133
+ : DIVIDER;
134
+ const [first, second] = allocate(
135
+ total,
136
+ target.ratio,
137
+ before ? 0 : min,
138
+ before ? Infinity : max,
139
+ before ? min : 0,
140
+ before ? max : Infinity,
141
+ gap,
142
+ );
143
+ // Store uses content extents (excluding the divider), matching this preview exactly.
144
+ const newRect = horizontal
145
+ ? {
146
+ left: rect.left + (before ? 0 : first + gap),
147
+ top: rect.top,
148
+ width: before ? first : second,
149
+ height: rect.height,
150
+ }
151
+ : {
152
+ left: rect.left,
153
+ top: rect.top + (before ? 0 : first + gap),
154
+ width: rect.width,
155
+ height: before ? first : second,
156
+ };
157
+ mark(newRect, 'split', 'New pane');
158
+ } else {
159
+ const layout = options.store.getSnapshot();
160
+ const elements = Array.from(
161
+ host.closest('.layouts')!.querySelectorAll<HTMLElement>('[data-node-id]'),
162
+ ).filter((element) => element.closest('.layouts') === host.closest('.layouts'));
163
+ const boxes = new Map(
164
+ elements.map((element) => [element.dataset.nodeId!, element.getBoundingClientRect()]),
165
+ );
166
+ const hovered = elements.find((element) => {
167
+ const candidate = findNode(layout.root, element.dataset.nodeId!);
168
+ const box = boxes.get(element.dataset.nodeId!)!;
169
+ return (
170
+ candidate?.kind === 'group' &&
171
+ move.clientX >= box.left &&
172
+ move.clientX <= box.right &&
173
+ move.clientY >= box.top &&
174
+ move.clientY <= box.bottom
175
+ );
176
+ });
177
+ if (!hovered) return;
178
+ const receiverId = hovered.dataset.nodeId!;
179
+ const range = joinRange(layout.root, id, receiverId);
180
+ if (
181
+ !range ||
182
+ !range.selected.every((g) => g.panes.every((p) => options.store.can(p, 'join')))
183
+ )
184
+ return;
185
+ const receiver = findNode(layout.root, receiverId);
186
+ if (receiver?.kind !== 'group') return;
187
+ const selectedBoxes = range.selected.map((g) => boxes.get(g.id));
188
+ if (selectedBoxes.some((box) => !box)) return;
189
+ // Require full-edge alignment in the rendered layout too (constraints can leave slack).
190
+ const horizontal = range.row.axis === 'horizontal';
191
+ if (
192
+ selectedBoxes.some((box) =>
193
+ horizontal
194
+ ? Math.abs(box!.top - rect.top) > 1 || Math.abs(box!.bottom - rect.bottom) > 1
195
+ : Math.abs(box!.left - rect.left) > 1 || Math.abs(box!.right - rect.right) > 1,
196
+ )
197
+ )
198
+ return;
199
+ const extents = Object.fromEntries(
200
+ [...boxes].map(([nodeId, box]) => [nodeId, horizontal ? box.width : box.height]),
201
+ );
202
+ target = { kind: 'join', id: receiverId, extents };
203
+ const targetTitle = layout.panes[receiver.active ?? '']?.title ?? 'Empty region';
204
+ const sources = range.selected.filter((g) => g.id !== receiverId);
205
+ const sourceLabel = sources.every((g) => g.panes.length === 0)
206
+ ? `${sources.length} empty ${sources.length === 1 ? 'region' : 'regions'}`
207
+ : sources.map((g) => layout.panes[g.active ?? '']?.title ?? 'Empty region').join(', ');
208
+ for (const group of range.selected) {
209
+ const receiving = group.id === receiverId;
210
+ mark(
211
+ boxes.get(group.id)!,
212
+ receiving ? 'join-target' : 'join-source',
213
+ receiving ? `${targetTitle} absorbs ${sourceLabel}` : `Joins ${targetTitle}`,
214
+ );
215
+ }
216
+ const left = Math.min(...selectedBoxes.map((box) => box!.left));
217
+ const top = Math.min(...selectedBoxes.map((box) => box!.top));
218
+ mark(
219
+ {
220
+ left,
221
+ top,
222
+ width: Math.max(...selectedBoxes.map((box) => box!.right)) - left,
223
+ height: Math.max(...selectedBoxes.map((box) => box!.bottom)) - top,
224
+ },
225
+ 'join',
226
+ '',
227
+ );
228
+ }
229
+ });
230
+ local.listen(handle, 'pointerup', () => {
231
+ const action = target;
232
+ local.dispose();
233
+ if (!action) return;
234
+ try {
235
+ if (action.kind === 'join')
236
+ options.store.joinRegions(action.id, id, { source: 'user', extents: action.extents });
237
+ else {
238
+ const layout = options.store.getSnapshot(),
239
+ group = findNode(layout.root, id);
240
+ if (group?.kind === 'group')
241
+ split(
242
+ group.active ? layout.panes[group.active] : undefined,
243
+ group,
244
+ action.direction,
245
+ action.ratio,
246
+ );
247
+ }
248
+ } catch (error) {
249
+ report(error);
250
+ }
251
+ });
252
+ local.listen(handle, 'pointercancel', () => local.dispose());
253
+ local.listen(handle, 'lostpointercapture', () => local.dispose());
254
+ local.listen(doc, 'keydown', (e) => {
255
+ if ((e as KeyboardEvent).key === 'Escape') local.dispose();
256
+ });
257
+ });
258
+ }
259
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ export { mountLayout } from './renderer.js';
2
+ export type * from './types.js';
3
+
4
+ export { TabRegistry } from './registry.js';
5
+ export type { TabRegistration } from './registry.js';
6
+ export { themes, themeFamilies } from './theme.js';
7
+ export type { LayoutTheme } from './theme.js';
8
+ export { LayoutStore, LayoutError, createLayout, parseLayout, validate } from 'quilt-core';
9
+ export type {
10
+ AutoCollapse,
11
+ LayoutStoreOptions,
12
+ Json,
13
+ Capability,
14
+ Axis,
15
+ Pane,
16
+ Group,
17
+ Split,
18
+ Node,
19
+ WindowPlacement,
20
+ Popout,
21
+ Layout as LayoutSnapshot,
22
+ Issue,
23
+ CommandOptions,
24
+ Change,
25
+ Bounds,
26
+ JoinOptions,
27
+ } from 'quilt-core';
@@ -0,0 +1,46 @@
1
+ export class Scope {
2
+ private tasks: (() => void)[] = [];
3
+ private ended = false;
4
+ add(cleanup: () => void) {
5
+ if (this.ended) cleanup();
6
+ else this.tasks.push(cleanup);
7
+ }
8
+ listen(
9
+ target: EventTarget,
10
+ type: string,
11
+ listener: EventListener,
12
+ options?: AddEventListenerOptions,
13
+ ) {
14
+ target.addEventListener(type, listener, options);
15
+ this.add(() => target.removeEventListener(type, listener, options));
16
+ }
17
+ dispose() {
18
+ if (this.ended) return;
19
+ this.ended = true;
20
+ for (const task of this.tasks.splice(0).reverse()) {
21
+ try {
22
+ task();
23
+ } catch {
24
+ /* Complete the remaining cleanup even if a consumer fails. */
25
+ }
26
+ }
27
+ }
28
+ }
29
+ export function syncChildren(parent: HTMLElement, children: HTMLElement[]) {
30
+ children.forEach((child, i) => {
31
+ if (parent.children[i] !== child) parent.insertBefore(child, parent.children[i] ?? null);
32
+ });
33
+ for (const child of Array.from(parent.children))
34
+ if (!children.includes(child as HTMLElement)) child.remove();
35
+ }
36
+ export function el<K extends keyof HTMLElementTagNameMap>(
37
+ doc: Document,
38
+ tag: K,
39
+ className: string,
40
+ text?: string,
41
+ ): HTMLElementTagNameMap[K] {
42
+ const node = doc.createElement(tag);
43
+ node.className = className;
44
+ if (text !== undefined) node.textContent = text;
45
+ return node;
46
+ }