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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 niko-dellic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# quilt-core
|
|
2
|
+
|
|
3
|
+
Framework-independent layout JSON, validation, commands, and subscriptions. No DOM or React dependency. MIT licensed; ships ESM, TypeScript declarations, and sources for editor navigation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install quilt-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Use ESM imports. For local development archives, see [packaging instructions](https://github.com/niko-dellic/quilt/blob/main/docs/packaging.md).
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createLayout, LayoutStore, validate } from 'quilt-core';
|
|
17
|
+
|
|
18
|
+
const store = new LayoutStore(
|
|
19
|
+
createLayout({
|
|
20
|
+
pane: { id: 'notes', type: 'notes', title: 'Notes' },
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
const unsubscribe = store.subscribe(({ action, layout }) => {
|
|
24
|
+
console.log(action, JSON.stringify(layout));
|
|
25
|
+
});
|
|
26
|
+
store.split('main', 'horizontal', { id: 'preview', type: 'preview', title: 'Preview' });
|
|
27
|
+
console.log(validate(store.export())); // []
|
|
28
|
+
unsubscribe();
|
|
29
|
+
store.dispose();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`createLayout()` returns a fresh empty layout. `createLayout({ pane, groupId })` returns a validated clone, activates the supplied pane, and defaults `groupId` to `main`. It preserves IDs and throws `LayoutError` for invalid input. `parseLayout` validates complete JSON; `validate` returns issues without throwing for invalid layout input.
|
|
33
|
+
|
|
34
|
+
Use ESM imports. Development and verification use Node 24; browser use requires modern JavaScript support including `structuredClone`. This package needs no stylesheet or host element. The `quilt-dom` DOM adapter and `quilt-react` binding provide views. Application data and persistence remain application-owned.
|
|
35
|
+
|
|
36
|
+
See [API documentation](https://github.com/niko-dellic/quilt/blob/main/docs/api.md) and [migration notes](https://github.com/niko-dellic/quilt/blob/main/docs/migration.md).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AutoCollapse, LayoutStoreOptions, JoinOptions, Axis, Capability, Change, CommandOptions, Group, Layout, Pane, WindowPlacement } from './types.js';
|
|
2
|
+
export declare class LayoutStore {
|
|
3
|
+
private state;
|
|
4
|
+
private initial;
|
|
5
|
+
private listeners;
|
|
6
|
+
private errors;
|
|
7
|
+
private disposed;
|
|
8
|
+
private serial;
|
|
9
|
+
private closedTabs;
|
|
10
|
+
private notifying;
|
|
11
|
+
private pending;
|
|
12
|
+
private autoCollapse;
|
|
13
|
+
constructor(input: unknown, options?: LayoutStoreOptions);
|
|
14
|
+
/** Stable immutable snapshot, suitable for useSyncExternalStore. */
|
|
15
|
+
getSnapshot: () => Layout;
|
|
16
|
+
export(): Layout;
|
|
17
|
+
subscribe: (listener: (event: Change) => void) => (() => void);
|
|
18
|
+
onError(listener: (error: unknown) => void): () => void;
|
|
19
|
+
private alive;
|
|
20
|
+
private report;
|
|
21
|
+
private commit;
|
|
22
|
+
private group;
|
|
23
|
+
private pane;
|
|
24
|
+
private permit;
|
|
25
|
+
private id;
|
|
26
|
+
private replace;
|
|
27
|
+
private detach;
|
|
28
|
+
getAutoCollapse(): AutoCollapse;
|
|
29
|
+
setAutoCollapse(mode: AutoCollapse): void;
|
|
30
|
+
private tidy;
|
|
31
|
+
can(paneId: string, capability: Capability): boolean;
|
|
32
|
+
load(input: unknown): void;
|
|
33
|
+
reset(): void;
|
|
34
|
+
setTabDisplay(groupId: string, display: Group['tabDisplay']): void;
|
|
35
|
+
setTabPlacement(groupId: string, placement: Group['tabPlacement']): void;
|
|
36
|
+
activate(groupId: string, paneId: string): void;
|
|
37
|
+
resize(splitId: string, ratio: number, options?: CommandOptions): void;
|
|
38
|
+
/** Atomically update coupled split ratios without intermediate layouts. */
|
|
39
|
+
resizeMany(ratios: Record<string, number>, options?: CommandOptions): void;
|
|
40
|
+
maximize(groupId: string | null): void;
|
|
41
|
+
add(pane: Pane, groupId: string, options?: CommandOptions): void;
|
|
42
|
+
updatePane(pane: Pane): void;
|
|
43
|
+
split(groupId: string, axis: Axis, pane: Pane | null, options?: CommandOptions & {
|
|
44
|
+
before?: boolean;
|
|
45
|
+
ratio?: number;
|
|
46
|
+
}): string;
|
|
47
|
+
/** Cancel only an unfilled region; never roll back edits made elsewhere. */
|
|
48
|
+
removeEmptyGroup(groupId: string): void;
|
|
49
|
+
/** Move to a tab group or to a new split at an edge. */
|
|
50
|
+
move(paneId: string, groupId: string, position?: 'tab' | 'left' | 'right' | 'top' | 'bottom', index?: number, options?: CommandOptions): void;
|
|
51
|
+
/** Join the sibling region at this split, retaining its content as tabs. */
|
|
52
|
+
join(groupId: string, options?: CommandOptions): void;
|
|
53
|
+
/** Close all docked tabs and remove their region in one atomic command. */
|
|
54
|
+
closeGroup(groupId: string, options?: CommandOptions): void;
|
|
55
|
+
/** Join a contiguous row/column range into the receiver, including both endpoints. */
|
|
56
|
+
joinRegions(receiverId: string, otherId: string, options?: JoinOptions): void;
|
|
57
|
+
close(paneId: string, options?: CommandOptions): void;
|
|
58
|
+
canRestoreClosedTab(): boolean;
|
|
59
|
+
/** Restore the latest closed tab without undoing unrelated layout changes. */
|
|
60
|
+
restoreClosedTab(): void;
|
|
61
|
+
private dockPane;
|
|
62
|
+
popout(paneId: string, placement?: WindowPlacement, options?: CommandOptions): void;
|
|
63
|
+
returnPane(paneId: string): void;
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { joinRange } from './join.js';
|
|
2
|
+
import { findNode, findParent, groups, paneIds, parseLayout, validate } from './model.js';
|
|
3
|
+
import { LayoutError } from './types.js';
|
|
4
|
+
const freeze = (value) => {
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
Object.freeze(value);
|
|
7
|
+
Object.values(value).forEach(freeze);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
function problem(message) {
|
|
12
|
+
throw new LayoutError([{ path: 'command', message }]);
|
|
13
|
+
}
|
|
14
|
+
export class LayoutStore {
|
|
15
|
+
state;
|
|
16
|
+
initial;
|
|
17
|
+
listeners = new Set();
|
|
18
|
+
errors = new Set();
|
|
19
|
+
disposed = false;
|
|
20
|
+
serial = 0;
|
|
21
|
+
closedTabs = [];
|
|
22
|
+
notifying = false;
|
|
23
|
+
pending = [];
|
|
24
|
+
autoCollapse = 'disabled';
|
|
25
|
+
constructor(input, options = {}) {
|
|
26
|
+
this.setAutoCollapse(options.autoCollapse ?? 'disabled');
|
|
27
|
+
this.state = freeze(parseLayout(input));
|
|
28
|
+
this.initial = this.export();
|
|
29
|
+
}
|
|
30
|
+
/** Stable immutable snapshot, suitable for useSyncExternalStore. */
|
|
31
|
+
getSnapshot = () => this.state;
|
|
32
|
+
export() {
|
|
33
|
+
return structuredClone(this.state);
|
|
34
|
+
}
|
|
35
|
+
subscribe = (listener) => {
|
|
36
|
+
this.alive();
|
|
37
|
+
this.listeners.add(listener);
|
|
38
|
+
return () => {
|
|
39
|
+
this.listeners.delete(listener);
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
onError(listener) {
|
|
43
|
+
this.errors.add(listener);
|
|
44
|
+
return () => {
|
|
45
|
+
this.errors.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
alive() {
|
|
49
|
+
if (this.disposed)
|
|
50
|
+
problem('Store has been disposed');
|
|
51
|
+
}
|
|
52
|
+
report(error) {
|
|
53
|
+
for (const listener of this.errors) {
|
|
54
|
+
try {
|
|
55
|
+
listener(error);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* A reporting callback cannot invalidate a committed layout. */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
commit(action, mutate) {
|
|
63
|
+
this.alive();
|
|
64
|
+
let next;
|
|
65
|
+
try {
|
|
66
|
+
next = this.export();
|
|
67
|
+
mutate(next);
|
|
68
|
+
next = parseLayout(next);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
this.report(error);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
// Update session history only after validation, before notifying subscribers.
|
|
75
|
+
if (action === 'load')
|
|
76
|
+
this.closedTabs = [];
|
|
77
|
+
if (action === 'close' || action === 'closeGroup') {
|
|
78
|
+
for (const pane of Object.values(this.state.panes)) {
|
|
79
|
+
if (Object.hasOwn(next.panes, pane.id))
|
|
80
|
+
continue;
|
|
81
|
+
const group = groups(this.state.root).find((g) => g.panes.includes(pane.id));
|
|
82
|
+
const detached = this.state.popouts.find((p) => p.paneId === pane.id);
|
|
83
|
+
this.closedTabs.push({
|
|
84
|
+
pane: structuredClone(pane),
|
|
85
|
+
groupId: group?.id ?? detached.groupId,
|
|
86
|
+
index: group ? group.panes.indexOf(pane.id) : detached.index,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
this.closedTabs = this.closedTabs.slice(-50);
|
|
90
|
+
}
|
|
91
|
+
if (action === 'restoreClosedTab') {
|
|
92
|
+
this.closedTabs = this.closedTabs.filter((entry) => !Object.hasOwn(next.panes, entry.pane.id));
|
|
93
|
+
}
|
|
94
|
+
this.state = freeze(next);
|
|
95
|
+
this.pending.push({ action, layout: this.state });
|
|
96
|
+
if (this.notifying)
|
|
97
|
+
return;
|
|
98
|
+
this.notifying = true;
|
|
99
|
+
try {
|
|
100
|
+
while (this.pending.length) {
|
|
101
|
+
const event = this.pending.shift();
|
|
102
|
+
for (const listener of [...this.listeners]) {
|
|
103
|
+
if (!this.listeners.has(listener))
|
|
104
|
+
continue;
|
|
105
|
+
try {
|
|
106
|
+
listener(event);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
this.report(error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
this.notifying = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
group(draft, id) {
|
|
119
|
+
const n = findNode(draft.root, id);
|
|
120
|
+
if (n?.kind !== 'group')
|
|
121
|
+
problem('Group not found: ' + id);
|
|
122
|
+
return n;
|
|
123
|
+
}
|
|
124
|
+
pane(draft, id) {
|
|
125
|
+
if (!Object.hasOwn(draft.panes, id))
|
|
126
|
+
problem('Pane not found: ' + id);
|
|
127
|
+
return draft.panes[id];
|
|
128
|
+
}
|
|
129
|
+
permit(draft, ids, cap, options) {
|
|
130
|
+
for (const id of ids)
|
|
131
|
+
if (options.source === 'user' && this.pane(draft, id).capabilities?.[cap] === false)
|
|
132
|
+
problem(`${cap} is disabled for ${id}`);
|
|
133
|
+
}
|
|
134
|
+
id(draft) {
|
|
135
|
+
let id;
|
|
136
|
+
do {
|
|
137
|
+
id = `layout-${++this.serial}`;
|
|
138
|
+
} while (findNode(draft.root, id));
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
replace(draft, old, next) {
|
|
142
|
+
const parent = findParent(draft.root, old.id);
|
|
143
|
+
if (parent)
|
|
144
|
+
parent.children[parent.children[0].id === old.id ? 0 : 1] = next;
|
|
145
|
+
else
|
|
146
|
+
draft.root = next;
|
|
147
|
+
}
|
|
148
|
+
detach(draft, id) {
|
|
149
|
+
const g = groups(draft.root).find((n) => n.panes.includes(id));
|
|
150
|
+
if (!g)
|
|
151
|
+
problem('Pane is not docked: ' + id);
|
|
152
|
+
const at = g.panes.indexOf(id);
|
|
153
|
+
g.panes.splice(at, 1);
|
|
154
|
+
if (g.active === id)
|
|
155
|
+
g.active = g.panes[Math.min(at, g.panes.length - 1)] ?? null;
|
|
156
|
+
return g;
|
|
157
|
+
}
|
|
158
|
+
getAutoCollapse() {
|
|
159
|
+
return this.autoCollapse;
|
|
160
|
+
}
|
|
161
|
+
setAutoCollapse(mode) {
|
|
162
|
+
this.alive();
|
|
163
|
+
if (!['enabled', 'protected', 'disabled'].includes(mode))
|
|
164
|
+
problem('Invalid autoCollapse mode');
|
|
165
|
+
this.autoCollapse = mode;
|
|
166
|
+
}
|
|
167
|
+
tidy(draft, source, popout = false) {
|
|
168
|
+
if (source &&
|
|
169
|
+
!source.panes.length &&
|
|
170
|
+
this.autoCollapse !== 'disabled' &&
|
|
171
|
+
(!popout || this.autoCollapse === 'enabled')) {
|
|
172
|
+
const parent = findParent(draft.root, source.id);
|
|
173
|
+
if (parent)
|
|
174
|
+
this.replace(draft, parent, parent.children[parent.children[0].id === source.id ? 1 : 0]);
|
|
175
|
+
}
|
|
176
|
+
if (draft.maximized && !findNode(draft.root, draft.maximized))
|
|
177
|
+
draft.maximized = null;
|
|
178
|
+
}
|
|
179
|
+
can(paneId, capability) {
|
|
180
|
+
return (Boolean(this.state.panes[paneId]) &&
|
|
181
|
+
this.state.panes[paneId].capabilities?.[capability] !== false);
|
|
182
|
+
}
|
|
183
|
+
load(input) {
|
|
184
|
+
this.commit('load', (draft) => {
|
|
185
|
+
const next = parseLayout(input);
|
|
186
|
+
for (const key of Object.keys(draft))
|
|
187
|
+
Reflect.deleteProperty(draft, key);
|
|
188
|
+
Object.assign(draft, next);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
reset() {
|
|
192
|
+
this.load(this.initial);
|
|
193
|
+
}
|
|
194
|
+
setTabDisplay(groupId, display) {
|
|
195
|
+
this.commit('setTabDisplay', (draft) => {
|
|
196
|
+
const group = findNode(draft.root, groupId);
|
|
197
|
+
if (!group || group.kind !== 'group')
|
|
198
|
+
problem('Expected a group id');
|
|
199
|
+
if (display === undefined)
|
|
200
|
+
delete group.tabDisplay;
|
|
201
|
+
else
|
|
202
|
+
group.tabDisplay = display;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
setTabPlacement(groupId, placement) {
|
|
206
|
+
this.commit('setTabPlacement', (draft) => {
|
|
207
|
+
const group = findNode(draft.root, groupId);
|
|
208
|
+
if (!group || group.kind !== 'group')
|
|
209
|
+
problem('Expected a group id');
|
|
210
|
+
if (placement === undefined)
|
|
211
|
+
delete group.tabPlacement;
|
|
212
|
+
else
|
|
213
|
+
group.tabPlacement = placement;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
activate(groupId, paneId) {
|
|
217
|
+
this.commit('activate', (d) => {
|
|
218
|
+
const g = this.group(d, groupId);
|
|
219
|
+
if (!g.panes.includes(paneId))
|
|
220
|
+
problem('Tab does not belong to group');
|
|
221
|
+
g.active = paneId;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
resize(splitId, ratio, options = {}) {
|
|
225
|
+
this.commit('resize', (d) => {
|
|
226
|
+
const n = findNode(d.root, splitId);
|
|
227
|
+
if (n?.kind !== 'split')
|
|
228
|
+
problem('Split not found');
|
|
229
|
+
this.permit(d, paneIds(n), 'resize', options);
|
|
230
|
+
n.ratio = ratio;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** Atomically update coupled split ratios without intermediate layouts. */
|
|
234
|
+
resizeMany(ratios, options = {}) {
|
|
235
|
+
this.commit('resize', (d) => {
|
|
236
|
+
for (const [id, ratio] of Object.entries(ratios)) {
|
|
237
|
+
const node = findNode(d.root, id);
|
|
238
|
+
if (node?.kind !== 'split')
|
|
239
|
+
problem('Split not found');
|
|
240
|
+
this.permit(d, paneIds(node), 'resize', options);
|
|
241
|
+
node.ratio = ratio;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
maximize(groupId) {
|
|
246
|
+
this.commit('maximize', (d) => {
|
|
247
|
+
if (groupId)
|
|
248
|
+
this.group(d, groupId);
|
|
249
|
+
d.maximized = groupId;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
add(pane, groupId, options = {}) {
|
|
253
|
+
this.commit('add', (d) => {
|
|
254
|
+
const g = this.group(d, groupId);
|
|
255
|
+
this.permit(d, g.panes, 'move', options);
|
|
256
|
+
if (Object.hasOwn(d.panes, pane.id))
|
|
257
|
+
problem('Pane id already exists');
|
|
258
|
+
Object.defineProperty(d.panes, pane.id, {
|
|
259
|
+
value: structuredClone(pane),
|
|
260
|
+
enumerable: true,
|
|
261
|
+
writable: true,
|
|
262
|
+
configurable: true,
|
|
263
|
+
});
|
|
264
|
+
g.panes.push(pane.id);
|
|
265
|
+
g.active = pane.id;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
updatePane(pane) {
|
|
269
|
+
this.commit('updatePane', (d) => {
|
|
270
|
+
this.pane(d, pane.id);
|
|
271
|
+
d.panes[pane.id] = structuredClone(pane);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
split(groupId, axis, pane, options = {}) {
|
|
275
|
+
let created = '';
|
|
276
|
+
this.commit('split', (d) => {
|
|
277
|
+
const g = this.group(d, groupId);
|
|
278
|
+
this.permit(d, g.panes, 'split', options);
|
|
279
|
+
if (pane) {
|
|
280
|
+
if (Object.hasOwn(d.panes, pane.id))
|
|
281
|
+
problem('Pane id already exists');
|
|
282
|
+
Object.defineProperty(d.panes, pane.id, {
|
|
283
|
+
value: structuredClone(pane),
|
|
284
|
+
enumerable: true,
|
|
285
|
+
writable: true,
|
|
286
|
+
configurable: true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const group = {
|
|
290
|
+
kind: 'group',
|
|
291
|
+
id: this.id(d),
|
|
292
|
+
panes: pane ? [pane.id] : [],
|
|
293
|
+
active: pane?.id ?? null,
|
|
294
|
+
};
|
|
295
|
+
created = group.id;
|
|
296
|
+
this.replace(d, g, {
|
|
297
|
+
kind: 'split',
|
|
298
|
+
id: this.id(d),
|
|
299
|
+
axis,
|
|
300
|
+
ratio: options.ratio ?? 0.5,
|
|
301
|
+
children: options.before ? [group, g] : [g, group],
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
return created;
|
|
305
|
+
}
|
|
306
|
+
/** Cancel only an unfilled region; never roll back edits made elsewhere. */
|
|
307
|
+
removeEmptyGroup(groupId) {
|
|
308
|
+
const existing = findNode(this.state.root, groupId);
|
|
309
|
+
if (existing?.kind !== 'group' ||
|
|
310
|
+
existing.panes.length ||
|
|
311
|
+
!findParent(this.state.root, groupId))
|
|
312
|
+
return;
|
|
313
|
+
this.commit('removeEmptyGroup', (d) => {
|
|
314
|
+
if (d.maximized === groupId)
|
|
315
|
+
d.maximized = null;
|
|
316
|
+
const parent = findParent(d.root, groupId);
|
|
317
|
+
this.replace(d, parent, parent.children[parent.children[0].id === groupId ? 1 : 0]);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Move to a tab group or to a new split at an edge. */
|
|
321
|
+
move(paneId, groupId, position = 'tab', index, options = {}) {
|
|
322
|
+
this.commit('move', (d) => {
|
|
323
|
+
const target = this.group(d, groupId);
|
|
324
|
+
const source = groups(d.root).find((g) => g.panes.includes(paneId));
|
|
325
|
+
if (!source)
|
|
326
|
+
problem('Return a popped-out pane before moving it');
|
|
327
|
+
this.permit(d, [paneId, ...target.panes], 'move', options);
|
|
328
|
+
if (position !== 'tab')
|
|
329
|
+
this.permit(d, target.panes, 'split', options);
|
|
330
|
+
if (source === target && target.panes.length === 1)
|
|
331
|
+
return;
|
|
332
|
+
this.detach(d, paneId);
|
|
333
|
+
if (position === 'tab') {
|
|
334
|
+
target.panes.splice(Math.max(0, Math.min(index ?? target.panes.length, target.panes.length)), 0, paneId);
|
|
335
|
+
target.active = paneId;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const g = { kind: 'group', id: this.id(d), panes: [paneId], active: paneId };
|
|
339
|
+
const before = position === 'left' || position === 'top';
|
|
340
|
+
this.replace(d, target, {
|
|
341
|
+
kind: 'split',
|
|
342
|
+
id: this.id(d),
|
|
343
|
+
axis: position === 'left' || position === 'right' ? 'horizontal' : 'vertical',
|
|
344
|
+
ratio: 0.5,
|
|
345
|
+
children: before ? [g, target] : [target, g],
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
this.tidy(d, source);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Join the sibling region at this split, retaining its content as tabs. */
|
|
352
|
+
join(groupId, options = {}) {
|
|
353
|
+
this.commit('join', (d) => {
|
|
354
|
+
const g = this.group(d, groupId), parent = findParent(d.root, groupId);
|
|
355
|
+
if (!parent)
|
|
356
|
+
return;
|
|
357
|
+
this.permit(d, paneIds(parent), 'join', options);
|
|
358
|
+
g.panes = paneIds(parent);
|
|
359
|
+
g.active ??= g.panes[0] ?? null;
|
|
360
|
+
this.replace(d, parent, g);
|
|
361
|
+
this.tidy(d);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/** Close all docked tabs and remove their region in one atomic command. */
|
|
365
|
+
closeGroup(groupId, options = {}) {
|
|
366
|
+
this.commit('closeGroup', (d) => {
|
|
367
|
+
const group = this.group(d, groupId);
|
|
368
|
+
this.permit(d, group.panes, 'close', options);
|
|
369
|
+
for (const id of group.panes)
|
|
370
|
+
delete d.panes[id];
|
|
371
|
+
group.panes = [];
|
|
372
|
+
group.active = null;
|
|
373
|
+
if (d.maximized === groupId)
|
|
374
|
+
d.maximized = null;
|
|
375
|
+
const parent = findParent(d.root, groupId);
|
|
376
|
+
if (parent)
|
|
377
|
+
this.replace(d, parent, parent.children[parent.children[0].id === groupId ? 1 : 0]);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** Join a contiguous row/column range into the receiver, including both endpoints. */
|
|
381
|
+
joinRegions(receiverId, otherId, options = {}) {
|
|
382
|
+
this.commit('join', (d) => {
|
|
383
|
+
const receiver = this.group(d, receiverId);
|
|
384
|
+
const range = joinRange(d.root, receiverId, otherId);
|
|
385
|
+
if (!range)
|
|
386
|
+
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) => {
|
|
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)
|
|
403
|
+
problem('Join extents must describe a non-overlapping row');
|
|
404
|
+
return Math.max(0, gap);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
receiver.panes = panes;
|
|
408
|
+
receiver.active ??= panes[0] ?? null;
|
|
409
|
+
const combined = 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))
|
|
429
|
+
d.maximized = receiver.id;
|
|
430
|
+
this.tidy(d);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
close(paneId, options = {}) {
|
|
434
|
+
this.commit('close', (d) => {
|
|
435
|
+
this.permit(d, [paneId], 'close', options);
|
|
436
|
+
this.pane(d, paneId);
|
|
437
|
+
let source;
|
|
438
|
+
if (d.popouts.some((p) => p.paneId === paneId))
|
|
439
|
+
d.popouts = d.popouts.filter((p) => p.paneId !== paneId);
|
|
440
|
+
else
|
|
441
|
+
source = this.detach(d, paneId);
|
|
442
|
+
delete d.panes[paneId];
|
|
443
|
+
this.tidy(d, source);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
canRestoreClosedTab() {
|
|
447
|
+
return this.closedTabs.some((entry) => !Object.hasOwn(this.state.panes, entry.pane.id));
|
|
448
|
+
}
|
|
449
|
+
/** Restore the latest closed tab without undoing unrelated layout changes. */
|
|
450
|
+
restoreClosedTab() {
|
|
451
|
+
this.alive();
|
|
452
|
+
const entry = [...this.closedTabs]
|
|
453
|
+
.reverse()
|
|
454
|
+
.find((item) => !Object.hasOwn(this.state.panes, item.pane.id));
|
|
455
|
+
if (!entry)
|
|
456
|
+
return;
|
|
457
|
+
this.commit('restoreClosedTab', (d) => {
|
|
458
|
+
Object.defineProperty(d.panes, entry.pane.id, {
|
|
459
|
+
value: structuredClone(entry.pane),
|
|
460
|
+
enumerable: true,
|
|
461
|
+
writable: true,
|
|
462
|
+
configurable: true,
|
|
463
|
+
});
|
|
464
|
+
this.dockPane(d, entry.pane.id, entry.groupId, entry.index);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
dockPane(d, paneId, groupId, index) {
|
|
468
|
+
const preferred = findNode(d.root, groupId);
|
|
469
|
+
d.maximized = null;
|
|
470
|
+
const candidates = [
|
|
471
|
+
...(preferred?.kind === 'group' ? [preferred] : []),
|
|
472
|
+
...groups(d.root).filter((g) => g.id !== groupId && g.panes.every((id) => d.panes[id].header !== false)),
|
|
473
|
+
];
|
|
474
|
+
for (const target of candidates) {
|
|
475
|
+
const previousActive = target.active;
|
|
476
|
+
target.panes.splice(Math.min(index, target.panes.length), 0, paneId);
|
|
477
|
+
target.active = paneId;
|
|
478
|
+
if (!validate(d).length)
|
|
479
|
+
return;
|
|
480
|
+
target.panes.splice(target.panes.indexOf(paneId), 1);
|
|
481
|
+
target.active = previousActive;
|
|
482
|
+
}
|
|
483
|
+
const g = { kind: 'group', id: this.id(d), panes: [paneId], active: paneId };
|
|
484
|
+
d.root = {
|
|
485
|
+
kind: 'split',
|
|
486
|
+
id: this.id(d),
|
|
487
|
+
axis: 'horizontal',
|
|
488
|
+
ratio: 0.7,
|
|
489
|
+
children: [d.root, g],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
popout(paneId, placement, options = {}) {
|
|
493
|
+
this.commit('popout', (d) => {
|
|
494
|
+
this.permit(d, [paneId], 'popout', options);
|
|
495
|
+
const group = groups(d.root).find((g) => g.panes.includes(paneId));
|
|
496
|
+
if (!group)
|
|
497
|
+
problem('Pane is not docked');
|
|
498
|
+
const entry = {
|
|
499
|
+
paneId,
|
|
500
|
+
groupId: group.id,
|
|
501
|
+
index: group.panes.indexOf(paneId),
|
|
502
|
+
...(placement ? { placement } : {}),
|
|
503
|
+
};
|
|
504
|
+
this.detach(d, paneId);
|
|
505
|
+
d.popouts.push(entry);
|
|
506
|
+
this.tidy(d, group, true);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
returnPane(paneId) {
|
|
510
|
+
this.commit('return', (d) => {
|
|
511
|
+
const entry = d.popouts.find((p) => p.paneId === paneId);
|
|
512
|
+
if (!entry)
|
|
513
|
+
return;
|
|
514
|
+
d.popouts = d.popouts.filter((p) => p.paneId !== paneId);
|
|
515
|
+
this.dockPane(d, paneId, entry.groupId, entry.index);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
dispose() {
|
|
519
|
+
this.closedTabs = [];
|
|
520
|
+
this.listeners.clear();
|
|
521
|
+
this.errors.clear();
|
|
522
|
+
this.pending = [];
|
|
523
|
+
this.disposed = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
//# sourceMappingURL=controller.js.map
|