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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/action-icons.d.ts +18 -0
- package/dist/action-icons.d.ts.map +1 -0
- package/dist/action-icons.js +36 -0
- package/dist/action-icons.js.map +1 -0
- package/dist/chrome-icons.d.ts +3 -0
- package/dist/chrome-icons.d.ts.map +1 -0
- package/dist/chrome-icons.js +32 -0
- package/dist/chrome-icons.js.map +1 -0
- package/dist/corners.d.ts +8 -0
- package/dist/corners.d.ts.map +1 -0
- package/dist/corners.js +205 -0
- package/dist/corners.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lifetime.d.ts +10 -0
- package/dist/lifetime.d.ts.map +1 -0
- package/dist/lifetime.js +44 -0
- package/dist/lifetime.js.map +1 -0
- package/dist/menu.d.ts +10 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +356 -0
- package/dist/menu.js.map +1 -0
- package/dist/panes.d.ts +13 -0
- package/dist/panes.d.ts.map +1 -0
- package/dist/panes.js +58 -0
- package/dist/panes.js.map +1 -0
- package/dist/picker.d.ts +6 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +112 -0
- package/dist/picker.js.map +1 -0
- package/dist/registry.d.ts +25 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +39 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderer.d.ts +3 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +570 -0
- package/dist/renderer.js.map +1 -0
- package/dist/resize.d.ts +7 -0
- package/dist/resize.d.ts.map +1 -0
- package/dist/resize.js +56 -0
- package/dist/resize.js.map +1 -0
- package/dist/shortcuts.d.ts +6 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +64 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/styles.css +842 -0
- package/dist/tab-bar.d.ts +6 -0
- package/dist/tab-bar.d.ts.map +1 -0
- package/dist/tab-bar.js +336 -0
- package/dist/tab-bar.js.map +1 -0
- package/dist/tab-tooltip.d.ts +4 -0
- package/dist/tab-tooltip.d.ts.map +1 -0
- package/dist/tab-tooltip.js +95 -0
- package/dist/tab-tooltip.js.map +1 -0
- package/dist/tabs.d.ts +15 -0
- package/dist/tabs.d.ts.map +1 -0
- package/dist/tabs.js +126 -0
- package/dist/tabs.js.map +1 -0
- package/dist/theme.d.ts +206 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +166 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/windows.d.ts +23 -0
- package/dist/windows.d.ts.map +1 -0
- package/dist/windows.js +247 -0
- package/dist/windows.js.map +1 -0
- package/package.json +50 -0
- package/src/action-icons.ts +40 -0
- package/src/chrome-icons.ts +30 -0
- package/src/corners.ts +259 -0
- package/src/index.ts +27 -0
- package/src/lifetime.ts +46 -0
- package/src/menu.ts +432 -0
- package/src/panes.ts +76 -0
- package/src/picker.ts +114 -0
- package/src/registry.ts +46 -0
- package/src/renderer.ts +654 -0
- package/src/resize.ts +65 -0
- package/src/shortcuts.ts +90 -0
- package/src/styles.css +842 -0
- package/src/tab-bar.ts +382 -0
- package/src/tab-tooltip.ts +96 -0
- package/src/tabs.ts +129 -0
- package/src/theme.ts +211 -0
- package/src/types.ts +67 -0
- 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';
|
package/src/lifetime.ts
ADDED
|
@@ -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
|
+
}
|