quilt-core 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 +36 -0
- package/dist/controller.d.ts +66 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +526 -0
- package/dist/controller.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/join.d.ts +12 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +37 -0
- package/dist/join.js.map +1 -0
- package/dist/model.d.ts +17 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +251 -0
- package/dist/model.js.map +1 -0
- package/dist/types.d.ts +95 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
- package/src/controller.ts +521 -0
- package/src/index.ts +34 -0
- package/src/join.ts +35 -0
- package/src/model.ts +260 -0
- package/src/types.ts +90 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA+DA,MAAM,OAAO,WAAY,SAAQ,KAAK;IACR;IAA5B,YAA4B,MAAe;QACzC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QADrC,WAAM,GAAN,MAAM,CAAS;QAEzC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF","sourcesContent":["export type AutoCollapse = 'enabled' | 'protected' | 'disabled';\nexport interface LayoutStoreOptions {\n /** Session policy; omitted defaults to disabled. Not serialized in Layout JSON. */\n autoCollapse?: AutoCollapse;\n}\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json };\nexport type Capability = 'resize' | 'move' | 'split' | 'join' | 'close' | 'popout';\nexport type Axis = 'horizontal' | 'vertical';\nexport interface Pane {\n id: string;\n type: string;\n title: string;\n /** Application-defined icon key, resolved by the view adapter. */\n icon?: string;\n params?: Json;\n capabilities?: Partial<Record<Capability, boolean>>;\n size?: { minWidth?: number; maxWidth?: number; minHeight?: number; maxHeight?: number };\n /** Hide chrome only for a one-tab group. Host commands can still operate on it. */\n header?: boolean;\n}\nexport interface Group {\n kind: 'group';\n id: string;\n panes: string[];\n active: string | null;\n /** Persisted tab orientation for this region; omitted inherits renderer settings. */\n tabPlacement?: 'top' | 'left';\n /** Omitted inherits the workspace tab display setting. */\n tabDisplay?: 'automatic' | 'compact';\n}\nexport interface Split {\n kind: 'split';\n id: string;\n axis: Axis;\n ratio: number;\n /** Divider space in pixels; zero removes fixed-bar gaps. Default 4. */\n gap?: number;\n children: [Node, Node];\n}\nexport type Node = Group | Split;\nexport interface WindowPlacement {\n width?: number;\n height?: number;\n left?: number;\n top?: number;\n}\nexport interface Popout {\n paneId: string;\n groupId: string;\n index: number;\n placement?: WindowPlacement;\n}\nexport interface Layout {\n version: 1;\n root: Node;\n panes: Record<string, Pane>;\n popouts: Popout[];\n maximized: string | null;\n}\nexport interface Issue {\n path: string;\n message: string;\n}\nexport class LayoutError extends Error {\n constructor(public readonly issues: Issue[]) {\n super(issues.map((i) => `${i.path}: ${i.message}`).join('; '));\n this.name = 'LayoutError';\n }\n}\nexport interface CommandOptions {\n /** Treat capability flags as user-interaction restrictions. */ source?: 'user' | 'api';\n}\nexport interface Change {\n action: string;\n layout: Layout;\n}\nexport interface Bounds {\n minWidth: number;\n maxWidth: number;\n minHeight: number;\n maxHeight: number;\n}\n\nexport interface JoinOptions extends CommandOptions {\n /** Optional rendered extents along the join axis, keyed by node ID, including splits.\n * Supply the complete row to retain actual sizes under constraints and custom dividers.\n * Without measurements, joins retain proportional shares of the row/column.\n */\n extents?: Record<string, number>;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quilt-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-independent pane layout model, validation, commands, and subscriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/niko-dellic/quilt.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node ../../scripts/build-package.mjs core",
|
|
26
|
+
"prepack": "node ../../scripts/build-package.mjs core --dependencies"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"homepage": "https://github.com/niko-dellic/quilt/tree/main/packages/core#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/niko-dellic/quilt/issues"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"layout",
|
|
36
|
+
"split-pane",
|
|
37
|
+
"tabs",
|
|
38
|
+
"workspace",
|
|
39
|
+
"typescript"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"registry": "https://registry.npmjs.org/"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { joinRange } from './join.js';
|
|
2
|
+
import { findNode, findParent, groups, paneIds, parseLayout, validate } from './model.js';
|
|
3
|
+
import { LayoutError } from './types.js';
|
|
4
|
+
import type {
|
|
5
|
+
AutoCollapse,
|
|
6
|
+
LayoutStoreOptions,
|
|
7
|
+
JoinOptions,
|
|
8
|
+
Axis,
|
|
9
|
+
Capability,
|
|
10
|
+
Change,
|
|
11
|
+
CommandOptions,
|
|
12
|
+
Group,
|
|
13
|
+
Layout,
|
|
14
|
+
Node,
|
|
15
|
+
Pane,
|
|
16
|
+
WindowPlacement,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
const freeze = <T>(value: T): T => {
|
|
19
|
+
if (value && typeof value === 'object') {
|
|
20
|
+
Object.freeze(value);
|
|
21
|
+
Object.values(value).forEach(freeze);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
function problem(message: string): never {
|
|
26
|
+
throw new LayoutError([{ path: 'command', message }]);
|
|
27
|
+
}
|
|
28
|
+
export class LayoutStore {
|
|
29
|
+
private state: Layout;
|
|
30
|
+
private initial: Layout;
|
|
31
|
+
private listeners = new Set<(event: Change) => void>();
|
|
32
|
+
private errors = new Set<(error: unknown) => void>();
|
|
33
|
+
private disposed = false;
|
|
34
|
+
private serial = 0;
|
|
35
|
+
private closedTabs: { pane: Pane; groupId: string; index: number }[] = [];
|
|
36
|
+
private notifying = false;
|
|
37
|
+
private pending: Change[] = [];
|
|
38
|
+
private autoCollapse: AutoCollapse = 'disabled';
|
|
39
|
+
constructor(input: unknown, options: LayoutStoreOptions = {}) {
|
|
40
|
+
this.setAutoCollapse(options.autoCollapse ?? 'disabled');
|
|
41
|
+
this.state = freeze(parseLayout(input));
|
|
42
|
+
this.initial = this.export();
|
|
43
|
+
}
|
|
44
|
+
/** Stable immutable snapshot, suitable for useSyncExternalStore. */
|
|
45
|
+
getSnapshot = (): Layout => this.state;
|
|
46
|
+
export(): Layout {
|
|
47
|
+
return structuredClone(this.state);
|
|
48
|
+
}
|
|
49
|
+
subscribe = (listener: (event: Change) => void): (() => void) => {
|
|
50
|
+
this.alive();
|
|
51
|
+
this.listeners.add(listener);
|
|
52
|
+
return () => {
|
|
53
|
+
this.listeners.delete(listener);
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
onError(listener: (error: unknown) => void): () => void {
|
|
57
|
+
this.errors.add(listener);
|
|
58
|
+
return () => {
|
|
59
|
+
this.errors.delete(listener);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
private alive() {
|
|
63
|
+
if (this.disposed) problem('Store has been disposed');
|
|
64
|
+
}
|
|
65
|
+
private report(error: unknown) {
|
|
66
|
+
for (const listener of this.errors) {
|
|
67
|
+
try {
|
|
68
|
+
listener(error);
|
|
69
|
+
} catch {
|
|
70
|
+
/* A reporting callback cannot invalidate a committed layout. */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
private commit(action: string, mutate: (draft: Layout) => void) {
|
|
75
|
+
this.alive();
|
|
76
|
+
let next: Layout;
|
|
77
|
+
try {
|
|
78
|
+
next = this.export();
|
|
79
|
+
mutate(next);
|
|
80
|
+
next = parseLayout(next);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.report(error);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
// Update session history only after validation, before notifying subscribers.
|
|
86
|
+
if (action === 'load') this.closedTabs = [];
|
|
87
|
+
if (action === 'close' || action === 'closeGroup') {
|
|
88
|
+
for (const pane of Object.values(this.state.panes)) {
|
|
89
|
+
if (Object.hasOwn(next.panes, pane.id)) continue;
|
|
90
|
+
const group = groups(this.state.root).find((g) => g.panes.includes(pane.id));
|
|
91
|
+
const detached = this.state.popouts.find((p) => p.paneId === pane.id);
|
|
92
|
+
this.closedTabs.push({
|
|
93
|
+
pane: structuredClone(pane),
|
|
94
|
+
groupId: group?.id ?? detached!.groupId,
|
|
95
|
+
index: group ? group.panes.indexOf(pane.id) : detached!.index,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
this.closedTabs = this.closedTabs.slice(-50);
|
|
99
|
+
}
|
|
100
|
+
if (action === 'restoreClosedTab') {
|
|
101
|
+
this.closedTabs = this.closedTabs.filter(
|
|
102
|
+
(entry) => !Object.hasOwn(next.panes, entry.pane.id),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
this.state = freeze(next);
|
|
106
|
+
this.pending.push({ action, layout: this.state });
|
|
107
|
+
if (this.notifying) return;
|
|
108
|
+
this.notifying = true;
|
|
109
|
+
try {
|
|
110
|
+
while (this.pending.length) {
|
|
111
|
+
const event = this.pending.shift()!;
|
|
112
|
+
for (const listener of [...this.listeners]) {
|
|
113
|
+
if (!this.listeners.has(listener)) continue;
|
|
114
|
+
try {
|
|
115
|
+
listener(event);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
this.report(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
this.notifying = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private group(draft: Layout, id: string): Group {
|
|
127
|
+
const n = findNode(draft.root, id);
|
|
128
|
+
if (n?.kind !== 'group') problem('Group not found: ' + id);
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
private pane(draft: Layout, id: string): Pane {
|
|
132
|
+
if (!Object.hasOwn(draft.panes, id)) problem('Pane not found: ' + id);
|
|
133
|
+
return draft.panes[id]!;
|
|
134
|
+
}
|
|
135
|
+
private permit(draft: Layout, ids: string[], cap: Capability, options: CommandOptions) {
|
|
136
|
+
for (const id of ids)
|
|
137
|
+
if (options.source === 'user' && this.pane(draft, id).capabilities?.[cap] === false)
|
|
138
|
+
problem(`${cap} is disabled for ${id}`);
|
|
139
|
+
}
|
|
140
|
+
private id(draft: Layout): string {
|
|
141
|
+
let id: string;
|
|
142
|
+
do {
|
|
143
|
+
id = `layout-${++this.serial}`;
|
|
144
|
+
} while (findNode(draft.root, id));
|
|
145
|
+
return id;
|
|
146
|
+
}
|
|
147
|
+
private replace(draft: Layout, old: Node, next: Node) {
|
|
148
|
+
const parent = findParent(draft.root, old.id);
|
|
149
|
+
if (parent) parent.children[parent.children[0].id === old.id ? 0 : 1] = next;
|
|
150
|
+
else draft.root = next;
|
|
151
|
+
}
|
|
152
|
+
private detach(draft: Layout, id: string): Group {
|
|
153
|
+
const g = groups(draft.root).find((n) => n.panes.includes(id));
|
|
154
|
+
if (!g) problem('Pane is not docked: ' + id);
|
|
155
|
+
const at = g.panes.indexOf(id);
|
|
156
|
+
g.panes.splice(at, 1);
|
|
157
|
+
if (g.active === id) g.active = g.panes[Math.min(at, g.panes.length - 1)] ?? null;
|
|
158
|
+
return g;
|
|
159
|
+
}
|
|
160
|
+
getAutoCollapse(): AutoCollapse {
|
|
161
|
+
return this.autoCollapse;
|
|
162
|
+
}
|
|
163
|
+
setAutoCollapse(mode: AutoCollapse): void {
|
|
164
|
+
this.alive();
|
|
165
|
+
if (!['enabled', 'protected', 'disabled'].includes(mode)) problem('Invalid autoCollapse mode');
|
|
166
|
+
this.autoCollapse = mode;
|
|
167
|
+
}
|
|
168
|
+
private tidy(draft: Layout, source?: Group, popout = false) {
|
|
169
|
+
if (
|
|
170
|
+
source &&
|
|
171
|
+
!source.panes.length &&
|
|
172
|
+
this.autoCollapse !== 'disabled' &&
|
|
173
|
+
(!popout || this.autoCollapse === 'enabled')
|
|
174
|
+
) {
|
|
175
|
+
const parent = findParent(draft.root, source.id);
|
|
176
|
+
if (parent)
|
|
177
|
+
this.replace(draft, parent, parent.children[parent.children[0].id === source.id ? 1 : 0]);
|
|
178
|
+
}
|
|
179
|
+
if (draft.maximized && !findNode(draft.root, draft.maximized)) draft.maximized = null;
|
|
180
|
+
}
|
|
181
|
+
can(paneId: string, capability: Capability): boolean {
|
|
182
|
+
return (
|
|
183
|
+
Boolean(this.state.panes[paneId]) &&
|
|
184
|
+
this.state.panes[paneId]!.capabilities?.[capability] !== false
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
load(input: unknown) {
|
|
188
|
+
this.commit('load', (draft) => {
|
|
189
|
+
const next = parseLayout(input);
|
|
190
|
+
for (const key of Object.keys(draft)) Reflect.deleteProperty(draft, key);
|
|
191
|
+
Object.assign(draft, next);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
reset() {
|
|
195
|
+
this.load(this.initial);
|
|
196
|
+
}
|
|
197
|
+
setTabDisplay(groupId: string, display: Group['tabDisplay']) {
|
|
198
|
+
this.commit('setTabDisplay', (draft) => {
|
|
199
|
+
const group = findNode(draft.root, groupId);
|
|
200
|
+
if (!group || group.kind !== 'group') problem('Expected a group id');
|
|
201
|
+
if (display === undefined) delete group.tabDisplay;
|
|
202
|
+
else group.tabDisplay = display;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
setTabPlacement(groupId: string, placement: Group['tabPlacement']) {
|
|
206
|
+
this.commit('setTabPlacement', (draft) => {
|
|
207
|
+
const group = findNode(draft.root, groupId);
|
|
208
|
+
if (!group || group.kind !== 'group') problem('Expected a group id');
|
|
209
|
+
if (placement === undefined) delete group.tabPlacement;
|
|
210
|
+
else group.tabPlacement = placement;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
activate(groupId: string, paneId: string) {
|
|
214
|
+
this.commit('activate', (d) => {
|
|
215
|
+
const g = this.group(d, groupId);
|
|
216
|
+
if (!g.panes.includes(paneId)) problem('Tab does not belong to group');
|
|
217
|
+
g.active = paneId;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
resize(splitId: string, ratio: number, options: CommandOptions = {}) {
|
|
221
|
+
this.commit('resize', (d) => {
|
|
222
|
+
const n = findNode(d.root, splitId);
|
|
223
|
+
if (n?.kind !== 'split') problem('Split not found');
|
|
224
|
+
this.permit(d, paneIds(n), 'resize', options);
|
|
225
|
+
n.ratio = ratio;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Atomically update coupled split ratios without intermediate layouts. */
|
|
229
|
+
resizeMany(ratios: Record<string, number>, options: CommandOptions = {}) {
|
|
230
|
+
this.commit('resize', (d) => {
|
|
231
|
+
for (const [id, ratio] of Object.entries(ratios)) {
|
|
232
|
+
const node = findNode(d.root, id);
|
|
233
|
+
if (node?.kind !== 'split') problem('Split not found');
|
|
234
|
+
this.permit(d, paneIds(node), 'resize', options);
|
|
235
|
+
node.ratio = ratio;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
maximize(groupId: string | null) {
|
|
240
|
+
this.commit('maximize', (d) => {
|
|
241
|
+
if (groupId) this.group(d, groupId);
|
|
242
|
+
d.maximized = groupId;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
add(pane: Pane, groupId: string, options: CommandOptions = {}) {
|
|
246
|
+
this.commit('add', (d) => {
|
|
247
|
+
const g = this.group(d, groupId);
|
|
248
|
+
this.permit(d, g.panes, 'move', options);
|
|
249
|
+
if (Object.hasOwn(d.panes, pane.id)) problem('Pane id already exists');
|
|
250
|
+
Object.defineProperty(d.panes, pane.id, {
|
|
251
|
+
value: structuredClone(pane),
|
|
252
|
+
enumerable: true,
|
|
253
|
+
writable: true,
|
|
254
|
+
configurable: true,
|
|
255
|
+
});
|
|
256
|
+
g.panes.push(pane.id);
|
|
257
|
+
g.active = pane.id;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
updatePane(pane: Pane) {
|
|
261
|
+
this.commit('updatePane', (d) => {
|
|
262
|
+
this.pane(d, pane.id);
|
|
263
|
+
d.panes[pane.id] = structuredClone(pane);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
split(
|
|
267
|
+
groupId: string,
|
|
268
|
+
axis: Axis,
|
|
269
|
+
pane: Pane | null,
|
|
270
|
+
options: CommandOptions & { before?: boolean; ratio?: number } = {},
|
|
271
|
+
) {
|
|
272
|
+
let created = '';
|
|
273
|
+
this.commit('split', (d) => {
|
|
274
|
+
const g = this.group(d, groupId);
|
|
275
|
+
this.permit(d, g.panes, 'split', options);
|
|
276
|
+
if (pane) {
|
|
277
|
+
if (Object.hasOwn(d.panes, pane.id)) problem('Pane id already exists');
|
|
278
|
+
Object.defineProperty(d.panes, pane.id, {
|
|
279
|
+
value: structuredClone(pane),
|
|
280
|
+
enumerable: true,
|
|
281
|
+
writable: true,
|
|
282
|
+
configurable: true,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const group: Group = {
|
|
286
|
+
kind: 'group',
|
|
287
|
+
id: this.id(d),
|
|
288
|
+
panes: pane ? [pane.id] : [],
|
|
289
|
+
active: pane?.id ?? null,
|
|
290
|
+
};
|
|
291
|
+
created = group.id;
|
|
292
|
+
this.replace(d, g, {
|
|
293
|
+
kind: 'split',
|
|
294
|
+
id: this.id(d),
|
|
295
|
+
axis,
|
|
296
|
+
ratio: options.ratio ?? 0.5,
|
|
297
|
+
children: options.before ? [group, g] : [g, group],
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
return created;
|
|
301
|
+
}
|
|
302
|
+
/** Cancel only an unfilled region; never roll back edits made elsewhere. */
|
|
303
|
+
removeEmptyGroup(groupId: string) {
|
|
304
|
+
const existing = findNode(this.state.root, groupId);
|
|
305
|
+
if (
|
|
306
|
+
existing?.kind !== 'group' ||
|
|
307
|
+
existing.panes.length ||
|
|
308
|
+
!findParent(this.state.root, groupId)
|
|
309
|
+
)
|
|
310
|
+
return;
|
|
311
|
+
this.commit('removeEmptyGroup', (d) => {
|
|
312
|
+
if (d.maximized === groupId) d.maximized = null;
|
|
313
|
+
const parent = findParent(d.root, groupId)!;
|
|
314
|
+
this.replace(d, parent, parent.children[parent.children[0].id === groupId ? 1 : 0]);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Move to a tab group or to a new split at an edge. */
|
|
318
|
+
move(
|
|
319
|
+
paneId: string,
|
|
320
|
+
groupId: string,
|
|
321
|
+
position: 'tab' | 'left' | 'right' | 'top' | 'bottom' = 'tab',
|
|
322
|
+
index?: number,
|
|
323
|
+
options: CommandOptions = {},
|
|
324
|
+
) {
|
|
325
|
+
this.commit('move', (d) => {
|
|
326
|
+
const target = this.group(d, groupId);
|
|
327
|
+
const source = groups(d.root).find((g) => g.panes.includes(paneId));
|
|
328
|
+
if (!source) problem('Return a popped-out pane before moving it');
|
|
329
|
+
this.permit(d, [paneId, ...target.panes], 'move', options);
|
|
330
|
+
if (position !== 'tab') this.permit(d, target.panes, 'split', options);
|
|
331
|
+
if (source === target && target.panes.length === 1) return;
|
|
332
|
+
this.detach(d, paneId);
|
|
333
|
+
if (position === 'tab') {
|
|
334
|
+
target.panes.splice(
|
|
335
|
+
Math.max(0, Math.min(index ?? target.panes.length, target.panes.length)),
|
|
336
|
+
0,
|
|
337
|
+
paneId,
|
|
338
|
+
);
|
|
339
|
+
target.active = paneId;
|
|
340
|
+
} else {
|
|
341
|
+
const g: Group = { kind: 'group', id: this.id(d), panes: [paneId], active: paneId };
|
|
342
|
+
const before = position === 'left' || position === 'top';
|
|
343
|
+
this.replace(d, target, {
|
|
344
|
+
kind: 'split',
|
|
345
|
+
id: this.id(d),
|
|
346
|
+
axis: position === 'left' || position === 'right' ? 'horizontal' : 'vertical',
|
|
347
|
+
ratio: 0.5,
|
|
348
|
+
children: before ? [g, target] : [target, g],
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
this.tidy(d, source);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Join the sibling region at this split, retaining its content as tabs. */
|
|
355
|
+
join(groupId: string, options: CommandOptions = {}) {
|
|
356
|
+
this.commit('join', (d) => {
|
|
357
|
+
const g = this.group(d, groupId),
|
|
358
|
+
parent = findParent(d.root, groupId);
|
|
359
|
+
if (!parent) return;
|
|
360
|
+
this.permit(d, paneIds(parent), 'join', options);
|
|
361
|
+
g.panes = paneIds(parent);
|
|
362
|
+
g.active ??= g.panes[0] ?? null;
|
|
363
|
+
this.replace(d, parent, g);
|
|
364
|
+
this.tidy(d);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Close all docked tabs and remove their region in one atomic command. */
|
|
368
|
+
closeGroup(groupId: string, options: CommandOptions = {}) {
|
|
369
|
+
this.commit('closeGroup', (d) => {
|
|
370
|
+
const group = this.group(d, groupId);
|
|
371
|
+
this.permit(d, group.panes, 'close', options);
|
|
372
|
+
for (const id of group.panes) delete d.panes[id];
|
|
373
|
+
group.panes = [];
|
|
374
|
+
group.active = null;
|
|
375
|
+
if (d.maximized === groupId) d.maximized = null;
|
|
376
|
+
const parent = findParent(d.root, groupId);
|
|
377
|
+
if (parent)
|
|
378
|
+
this.replace(d, parent, parent.children[parent.children[0].id === groupId ? 1 : 0]);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/** Join a contiguous row/column range into the receiver, including both endpoints. */
|
|
382
|
+
joinRegions(receiverId: string, otherId: string, options: JoinOptions = {}) {
|
|
383
|
+
this.commit('join', (d) => {
|
|
384
|
+
const receiver = this.group(d, receiverId);
|
|
385
|
+
const range = joinRange(d.root, receiverId, otherId);
|
|
386
|
+
if (!range) problem('Join requires contiguous regions in one row or column');
|
|
387
|
+
const { row, regions, boundaries, start, end, selected } = range;
|
|
388
|
+
const panes = selected.flatMap((g) => g.panes);
|
|
389
|
+
this.permit(d, panes, 'join', options);
|
|
390
|
+
let sizes = range.weights;
|
|
391
|
+
let gaps = boundaries.map(() => 0);
|
|
392
|
+
if (options.extents) {
|
|
393
|
+
const extent = (node: Node): number => {
|
|
394
|
+
const value = options.extents![node.id];
|
|
395
|
+
if (value === undefined || !Number.isFinite(value) || value <= 0)
|
|
396
|
+
problem('Join extents must include a positive finite size for every node in the row');
|
|
397
|
+
return value;
|
|
398
|
+
};
|
|
399
|
+
sizes = regions.map(extent);
|
|
400
|
+
gaps = boundaries.map((split) => {
|
|
401
|
+
const gap = extent(split) - extent(split.children[0]) - extent(split.children[1]);
|
|
402
|
+
if (gap < -0.5) problem('Join extents must describe a non-overlapping row');
|
|
403
|
+
return Math.max(0, gap);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
receiver.panes = panes;
|
|
407
|
+
receiver.active ??= panes[0] ?? null;
|
|
408
|
+
const combined =
|
|
409
|
+
sizes.slice(start, end + 1).reduce((a, b) => a + b, 0) +
|
|
410
|
+
gaps.slice(start, end).reduce((a, b) => a + b, 0);
|
|
411
|
+
const remaining = [...regions.slice(0, start), receiver, ...regions.slice(end + 1)];
|
|
412
|
+
const lengths = [...sizes.slice(0, start), combined, ...sizes.slice(end + 1)];
|
|
413
|
+
const dividers = [...boundaries.slice(0, start), ...boundaries.slice(end)];
|
|
414
|
+
const remainingGaps = [...gaps.slice(0, start), ...gaps.slice(end)];
|
|
415
|
+
// Reuse the surviving boundary IDs and their settings. Removed boundaries alone disappear.
|
|
416
|
+
let replacement = remaining[remaining.length - 1]!;
|
|
417
|
+
let length = lengths[lengths.length - 1]!;
|
|
418
|
+
for (let i = remaining.length - 2; i >= 0; i--) {
|
|
419
|
+
const first = lengths[i]!;
|
|
420
|
+
replacement = {
|
|
421
|
+
...dividers[i]!,
|
|
422
|
+
ratio: first / (first + length),
|
|
423
|
+
children: [remaining[i]!, replacement],
|
|
424
|
+
};
|
|
425
|
+
length += first + remainingGaps[i]!;
|
|
426
|
+
}
|
|
427
|
+
this.replace(d, row, replacement);
|
|
428
|
+
if (selected.some((g) => g.id === d.maximized)) d.maximized = receiver.id;
|
|
429
|
+
this.tidy(d);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
close(paneId: string, options: CommandOptions = {}) {
|
|
433
|
+
this.commit('close', (d) => {
|
|
434
|
+
this.permit(d, [paneId], 'close', options);
|
|
435
|
+
this.pane(d, paneId);
|
|
436
|
+
let source: Group | undefined;
|
|
437
|
+
if (d.popouts.some((p) => p.paneId === paneId))
|
|
438
|
+
d.popouts = d.popouts.filter((p) => p.paneId !== paneId);
|
|
439
|
+
else source = this.detach(d, paneId);
|
|
440
|
+
delete d.panes[paneId];
|
|
441
|
+
this.tidy(d, source);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
canRestoreClosedTab(): boolean {
|
|
445
|
+
return this.closedTabs.some((entry) => !Object.hasOwn(this.state.panes, entry.pane.id));
|
|
446
|
+
}
|
|
447
|
+
/** Restore the latest closed tab without undoing unrelated layout changes. */
|
|
448
|
+
restoreClosedTab(): void {
|
|
449
|
+
this.alive();
|
|
450
|
+
const entry = [...this.closedTabs]
|
|
451
|
+
.reverse()
|
|
452
|
+
.find((item) => !Object.hasOwn(this.state.panes, item.pane.id));
|
|
453
|
+
if (!entry) return;
|
|
454
|
+
this.commit('restoreClosedTab', (d) => {
|
|
455
|
+
Object.defineProperty(d.panes, entry.pane.id, {
|
|
456
|
+
value: structuredClone(entry.pane),
|
|
457
|
+
enumerable: true,
|
|
458
|
+
writable: true,
|
|
459
|
+
configurable: true,
|
|
460
|
+
});
|
|
461
|
+
this.dockPane(d, entry.pane.id, entry.groupId, entry.index);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
private dockPane(d: Layout, paneId: string, groupId: string, index: number) {
|
|
465
|
+
const preferred = findNode(d.root, groupId);
|
|
466
|
+
d.maximized = null;
|
|
467
|
+
const candidates = [
|
|
468
|
+
...(preferred?.kind === 'group' ? [preferred] : []),
|
|
469
|
+
...groups(d.root).filter(
|
|
470
|
+
(g) => g.id !== groupId && g.panes.every((id) => d.panes[id]!.header !== false),
|
|
471
|
+
),
|
|
472
|
+
];
|
|
473
|
+
for (const target of candidates) {
|
|
474
|
+
const previousActive = target.active;
|
|
475
|
+
target.panes.splice(Math.min(index, target.panes.length), 0, paneId);
|
|
476
|
+
target.active = paneId;
|
|
477
|
+
if (!validate(d).length) return;
|
|
478
|
+
target.panes.splice(target.panes.indexOf(paneId), 1);
|
|
479
|
+
target.active = previousActive;
|
|
480
|
+
}
|
|
481
|
+
const g: Group = { kind: 'group', id: this.id(d), panes: [paneId], active: paneId };
|
|
482
|
+
d.root = {
|
|
483
|
+
kind: 'split',
|
|
484
|
+
id: this.id(d),
|
|
485
|
+
axis: 'horizontal',
|
|
486
|
+
ratio: 0.7,
|
|
487
|
+
children: [d.root, g],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
popout(paneId: string, placement?: WindowPlacement, options: CommandOptions = {}) {
|
|
491
|
+
this.commit('popout', (d) => {
|
|
492
|
+
this.permit(d, [paneId], 'popout', options);
|
|
493
|
+
const group = groups(d.root).find((g) => g.panes.includes(paneId));
|
|
494
|
+
if (!group) problem('Pane is not docked');
|
|
495
|
+
const entry = {
|
|
496
|
+
paneId,
|
|
497
|
+
groupId: group.id,
|
|
498
|
+
index: group.panes.indexOf(paneId),
|
|
499
|
+
...(placement ? { placement } : {}),
|
|
500
|
+
};
|
|
501
|
+
this.detach(d, paneId);
|
|
502
|
+
d.popouts.push(entry);
|
|
503
|
+
this.tidy(d, group, true);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
returnPane(paneId: string) {
|
|
507
|
+
this.commit('return', (d) => {
|
|
508
|
+
const entry = d.popouts.find((p) => p.paneId === paneId);
|
|
509
|
+
if (!entry) return;
|
|
510
|
+
d.popouts = d.popouts.filter((p) => p.paneId !== paneId);
|
|
511
|
+
this.dockPane(d, paneId, entry.groupId, entry.index);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
dispose() {
|
|
515
|
+
this.closedTabs = [];
|
|
516
|
+
this.listeners.clear();
|
|
517
|
+
this.errors.clear();
|
|
518
|
+
this.pending = [];
|
|
519
|
+
this.disposed = true;
|
|
520
|
+
}
|
|
521
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AutoCollapse,
|
|
3
|
+
LayoutStoreOptions,
|
|
4
|
+
Json,
|
|
5
|
+
Capability,
|
|
6
|
+
Axis,
|
|
7
|
+
Pane,
|
|
8
|
+
Group,
|
|
9
|
+
Split,
|
|
10
|
+
Node,
|
|
11
|
+
WindowPlacement,
|
|
12
|
+
Popout,
|
|
13
|
+
Layout,
|
|
14
|
+
Issue,
|
|
15
|
+
CommandOptions,
|
|
16
|
+
Change,
|
|
17
|
+
Bounds,
|
|
18
|
+
JoinOptions,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
export { LayoutError } from './types.js';
|
|
21
|
+
export { LayoutStore } from './controller.js';
|
|
22
|
+
export {
|
|
23
|
+
createLayout,
|
|
24
|
+
validate,
|
|
25
|
+
parseLayout,
|
|
26
|
+
findNode,
|
|
27
|
+
findParent,
|
|
28
|
+
groups,
|
|
29
|
+
paneIds,
|
|
30
|
+
DIVIDER,
|
|
31
|
+
bounds,
|
|
32
|
+
allocate,
|
|
33
|
+
} from './model.js';
|
|
34
|
+
export { joinRange } from './join.js';
|
package/src/join.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { findParent } from './model.js';
|
|
2
|
+
import type { Group, Node, Split } from './types.js';
|
|
3
|
+
|
|
4
|
+
/** A contiguous run within one row/column. Perpendicular splits are barriers. */
|
|
5
|
+
export function joinRange(root: Node, from: string, to: string) {
|
|
6
|
+
let row = findParent(root, from);
|
|
7
|
+
if (!row || from === to) return undefined;
|
|
8
|
+
let parent = findParent(root, row.id);
|
|
9
|
+
while (parent?.axis === row.axis) {
|
|
10
|
+
row = parent;
|
|
11
|
+
parent = findParent(root, row.id);
|
|
12
|
+
}
|
|
13
|
+
const regions: Node[] = [];
|
|
14
|
+
const weights: number[] = [];
|
|
15
|
+
const boundaries: Split[] = [];
|
|
16
|
+
const visit = (node: Node, weight: number) => {
|
|
17
|
+
if (node.kind === 'split' && node.axis === row.axis) {
|
|
18
|
+
visit(node.children[0], weight * node.ratio);
|
|
19
|
+
boundaries.push(node);
|
|
20
|
+
visit(node.children[1], weight * (1 - node.ratio));
|
|
21
|
+
} else {
|
|
22
|
+
regions.push(node);
|
|
23
|
+
weights.push(weight);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
visit(row, 1);
|
|
27
|
+
const a = regions.findIndex((node) => node.id === from);
|
|
28
|
+
const b = regions.findIndex((node) => node.id === to);
|
|
29
|
+
if (a < 0 || b < 0) return undefined;
|
|
30
|
+
const start = Math.min(a, b),
|
|
31
|
+
end = Math.max(a, b);
|
|
32
|
+
const selected = regions.slice(start, end + 1);
|
|
33
|
+
if (selected.some((node) => node.kind !== 'group')) return undefined;
|
|
34
|
+
return { row, regions, weights, boundaries, start, end, selected: selected as Group[] };
|
|
35
|
+
}
|