@deepseek-ai/dsh-client-ui-dockkit 0.1.5-alpha.1
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.i18n.yaml +6 -0
- package/README.md +107 -0
- package/README.zh.md +107 -0
- package/lib/components/dockkit.module.css +423 -0
- package/lib/index.js +2924 -0
- package/lib/types/components/DockSurface.d.ts +52 -0
- package/lib/types/components/FloatLayer.d.ts +15 -0
- package/lib/types/components/PaneTree.d.ts +18 -0
- package/lib/types/components/TabMenu.d.ts +16 -0
- package/lib/types/components/TabPanel.d.ts +12 -0
- package/lib/types/components/measure.d.ts +36 -0
- package/lib/types/components/pointer.d.ts +47 -0
- package/lib/types/components/render.d.ts +45 -0
- package/lib/types/contract/adapter.d.ts +90 -0
- package/lib/types/contract/types.d.ts +255 -0
- package/lib/types/engine/constraints.d.ts +61 -0
- package/lib/types/engine/controller.d.ts +191 -0
- package/lib/types/engine/geometry.d.ts +149 -0
- package/lib/types/engine/initial.d.ts +36 -0
- package/lib/types/engine/operations.d.ts +26 -0
- package/lib/types/engine/planner.d.ts +184 -0
- package/lib/types/engine/sequence.d.ts +131 -0
- package/lib/types/engine/tree.d.ts +180 -0
- package/lib/types/index.d.ts +38 -0
- package/package.json +46 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2924 @@
|
|
|
1
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment as Fragment$1, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import css from "./components/dockkit.module.css";
|
|
6
|
+
//#region lib/types/engine/tree.js
|
|
7
|
+
/**
|
|
8
|
+
* Reject an unhandled discriminant at the end of a closed switch.
|
|
9
|
+
* @param value - the discriminant the switch did not handle.
|
|
10
|
+
* @param what - the union being switched on, for the message.
|
|
11
|
+
* @returns never; it throws.
|
|
12
|
+
*/
|
|
13
|
+
function assertNever(value, what) {
|
|
14
|
+
throw new Error(`${what}: unhandled ${JSON.stringify(value)}`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read any node.
|
|
18
|
+
* @param state - current layout.
|
|
19
|
+
* @param id - the node.
|
|
20
|
+
* @returns the split or pane.
|
|
21
|
+
* @throws when `id` is not in the tree.
|
|
22
|
+
*/
|
|
23
|
+
function getNode(state, id) {
|
|
24
|
+
const node = state.nodes[id];
|
|
25
|
+
if (node === void 0) throw new Error(`layout: unknown node ${id}`);
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read a pane.
|
|
30
|
+
* @param state - current layout.
|
|
31
|
+
* @param id - the pane.
|
|
32
|
+
* @returns the pane node.
|
|
33
|
+
* @throws when `id` is missing or names a split.
|
|
34
|
+
*/
|
|
35
|
+
function getPane(state, id) {
|
|
36
|
+
const node = getNode(state, id);
|
|
37
|
+
if (node.kind !== "pane") throw new Error(`layout: ${id} is not a pane`);
|
|
38
|
+
return node;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read a split.
|
|
42
|
+
* @param state - current layout.
|
|
43
|
+
* @param id - the split.
|
|
44
|
+
* @returns the split node.
|
|
45
|
+
* @throws when `id` is missing or names a pane.
|
|
46
|
+
*/
|
|
47
|
+
function getSplit(state, id) {
|
|
48
|
+
const node = getNode(state, id);
|
|
49
|
+
if (node.kind !== "split") throw new Error(`layout: ${id} is not a split`);
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read a tab record.
|
|
54
|
+
* @param state - current layout.
|
|
55
|
+
* @param id - the tab.
|
|
56
|
+
* @returns the record.
|
|
57
|
+
* @throws when `id` is not open.
|
|
58
|
+
*/
|
|
59
|
+
function getTab(state, id) {
|
|
60
|
+
const tab = state.tabs[id];
|
|
61
|
+
if (tab === void 0) throw new Error(`layout: unknown tab ${id}`);
|
|
62
|
+
return tab;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A floating pane's rectangle.
|
|
66
|
+
* @param pane - the pane.
|
|
67
|
+
* @returns its viewport rectangle.
|
|
68
|
+
* @throws when `pane` is docked.
|
|
69
|
+
*/
|
|
70
|
+
function floatRect(pane) {
|
|
71
|
+
if (pane.host !== "float" || pane.rect === void 0) throw new Error(`layout: ${pane.id} is not floating`);
|
|
72
|
+
return pane.rect;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A floating pane's position in the z order.
|
|
76
|
+
* @param state - current layout.
|
|
77
|
+
* @param id - the floating pane.
|
|
78
|
+
* @returns its index in `floats`, bottom first.
|
|
79
|
+
* @throws when `id` is not listed in `floats`.
|
|
80
|
+
*/
|
|
81
|
+
function floatIndex(state, id) {
|
|
82
|
+
const index = state.floats.indexOf(id);
|
|
83
|
+
if (index < 0) throw new Error(`layout: floating pane ${id} is not in the z order`);
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The one tab a pane holds.
|
|
88
|
+
* @param pane - the pane.
|
|
89
|
+
* @returns its tab's id.
|
|
90
|
+
* @throws when `pane` holds any other number of tabs.
|
|
91
|
+
*/
|
|
92
|
+
function onlyTabId(pane) {
|
|
93
|
+
const tabId = pane.tabs[0];
|
|
94
|
+
if (tabId === void 0 || pane.tabs.length !== 1) throw new Error(`layout: ${pane.id} does not hold exactly one tab`);
|
|
95
|
+
return tabId;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The split holding a node.
|
|
99
|
+
* @param state - current layout.
|
|
100
|
+
* @param id - the node.
|
|
101
|
+
* @returns its parent split, or `undefined` for the docked root and floating panes.
|
|
102
|
+
*/
|
|
103
|
+
function findParent(state, id) {
|
|
104
|
+
for (const node of Object.values(state.nodes)) if (node.kind === "split" && node.children.includes(id)) return node;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The pane holding a tab.
|
|
108
|
+
* @param state - current layout.
|
|
109
|
+
* @param tabId - the tab.
|
|
110
|
+
* @returns the pane whose strip lists it.
|
|
111
|
+
* @throws when no pane lists it.
|
|
112
|
+
*/
|
|
113
|
+
function findTabPane(state, tabId) {
|
|
114
|
+
for (const node of Object.values(state.nodes)) if (node.kind === "pane" && node.tabs.includes(tabId)) return node;
|
|
115
|
+
throw new Error(`layout: tab ${tabId} has no pane`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Docked pane ids in visual order (depth-first through the split tree).
|
|
119
|
+
* @param state - current layout.
|
|
120
|
+
* @returns every docked pane's id; floating panes are absent.
|
|
121
|
+
*/
|
|
122
|
+
function dockPaneIds(state) {
|
|
123
|
+
const out = [];
|
|
124
|
+
const walk = (id) => {
|
|
125
|
+
const node = getNode(state, id);
|
|
126
|
+
if (node.kind === "pane") {
|
|
127
|
+
out.push(node.id);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const child of node.children) walk(child);
|
|
131
|
+
};
|
|
132
|
+
walk(state.rootId);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Scale `sizes` so they sum to 1. Input that already sums to 1 is copied
|
|
137
|
+
* unchanged, so restoring recorded sizes never drifts.
|
|
138
|
+
* @param sizes - fractions or any positive weights.
|
|
139
|
+
* @returns the fractions, summing to 1.
|
|
140
|
+
* @throws when the input cannot be normalized.
|
|
141
|
+
*/
|
|
142
|
+
function normalizeSizes(sizes) {
|
|
143
|
+
const total = sizes.reduce((sum, size) => sum + size, 0);
|
|
144
|
+
if (!(total > 0)) throw new Error("layout: sizes must sum above zero");
|
|
145
|
+
if (Math.abs(total - 1) < 1e-12) return [...sizes];
|
|
146
|
+
return sizes.map((size) => size / total);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `Object.entries` keeping the record's own key type: the keys were written from
|
|
150
|
+
* ids, so reading them back as ids is exact.
|
|
151
|
+
* @param record - an id-keyed record.
|
|
152
|
+
* @returns its entries with typed keys.
|
|
153
|
+
*/
|
|
154
|
+
function entriesOf(record) {
|
|
155
|
+
return Object.entries(record);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `Object.keys` keeping the record's own key type; see {@link entriesOf}.
|
|
159
|
+
* @param record - an id-keyed record.
|
|
160
|
+
* @returns its keys, typed.
|
|
161
|
+
*/
|
|
162
|
+
function keysOf(record) {
|
|
163
|
+
return Object.keys(record);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Replace or delete nodes.
|
|
167
|
+
* @param state - current layout.
|
|
168
|
+
* @param updates - nodes by id; a `null` update deletes that id.
|
|
169
|
+
* @returns the layout with those nodes replaced; untouched nodes keep their identity.
|
|
170
|
+
*/
|
|
171
|
+
function withNodes(state, updates) {
|
|
172
|
+
const nodes = {};
|
|
173
|
+
for (const [id, node] of entriesOf(state.nodes)) if (!(id in updates)) nodes[id] = node;
|
|
174
|
+
for (const [id, node] of entriesOf(updates)) if (node !== null) nodes[id] = node;
|
|
175
|
+
return {
|
|
176
|
+
...state,
|
|
177
|
+
nodes
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Replace or delete tab records.
|
|
182
|
+
* @param state - current layout.
|
|
183
|
+
* @param updates - records by id; a `null` update deletes that id.
|
|
184
|
+
* @returns the layout with those records replaced; untouched records keep their identity.
|
|
185
|
+
*/
|
|
186
|
+
function withTabs(state, updates) {
|
|
187
|
+
const tabs = {};
|
|
188
|
+
for (const [id, tab] of entriesOf(state.tabs)) if (!(id in updates)) tabs[id] = tab;
|
|
189
|
+
for (const [id, tab] of entriesOf(updates)) if (tab !== null) tabs[id] = tab;
|
|
190
|
+
return {
|
|
191
|
+
...state,
|
|
192
|
+
tabs
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Insert a value into a list.
|
|
197
|
+
* @param items - the list.
|
|
198
|
+
* @param index - the slot, clamped to the list's bounds.
|
|
199
|
+
* @param value - what to insert.
|
|
200
|
+
* @returns a new list with the value at the slot.
|
|
201
|
+
*/
|
|
202
|
+
function insertAt(items, index, value) {
|
|
203
|
+
const at = Math.max(0, Math.min(index, items.length));
|
|
204
|
+
return [
|
|
205
|
+
...items.slice(0, at),
|
|
206
|
+
value,
|
|
207
|
+
...items.slice(at)
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Remove one entry from a list.
|
|
212
|
+
* @param items - the list.
|
|
213
|
+
* @param index - the entry to drop.
|
|
214
|
+
* @returns a new list without it.
|
|
215
|
+
*/
|
|
216
|
+
function removeAt(items, index) {
|
|
217
|
+
return [...items.slice(0, index), ...items.slice(index + 1)];
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Which tab a pane focuses after one leaves it.
|
|
221
|
+
* @param tabs - the strip before the removal.
|
|
222
|
+
* @param removedIndex - the leaving tab's slot.
|
|
223
|
+
* @returns the previous neighbour when one exists, otherwise the next, otherwise `undefined`.
|
|
224
|
+
*/
|
|
225
|
+
function neighbourTabId(tabs, removedIndex) {
|
|
226
|
+
const remaining = removeAt(tabs, removedIndex);
|
|
227
|
+
if (remaining.length === 0) return void 0;
|
|
228
|
+
return remaining[Math.max(0, removedIndex - 1)];
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Copy a pane with a new tab list.
|
|
232
|
+
* @param pane - the pane.
|
|
233
|
+
* @param tabs - its new strip.
|
|
234
|
+
* @param activeTabId - the active tab, which the caller keeps consistent with `tabs`.
|
|
235
|
+
* @returns the copied pane.
|
|
236
|
+
*/
|
|
237
|
+
function paneWithTabs(pane, tabs, activeTabId) {
|
|
238
|
+
return {
|
|
239
|
+
...pane,
|
|
240
|
+
tabs,
|
|
241
|
+
activeTabId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Swap a node for another in its parent's slot, or make the replacement the docked root.
|
|
246
|
+
* @param state - current layout.
|
|
247
|
+
* @param targetId - the node to swap out.
|
|
248
|
+
* @param replacementId - the node taking its slot.
|
|
249
|
+
* @returns the layout with the slot rewritten.
|
|
250
|
+
* @throws when `targetId` is neither rooted nor parented.
|
|
251
|
+
*/
|
|
252
|
+
function replaceInParent(state, targetId, replacementId) {
|
|
253
|
+
const parent = findParent(state, targetId);
|
|
254
|
+
if (parent === void 0) {
|
|
255
|
+
if (state.rootId !== targetId) throw new Error(`layout: ${targetId} is neither rooted nor parented`);
|
|
256
|
+
return {
|
|
257
|
+
...state,
|
|
258
|
+
rootId: replacementId
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const children = parent.children.map((child) => child === targetId ? replacementId : child);
|
|
262
|
+
return withNodes(state, { [parent.id]: {
|
|
263
|
+
...parent,
|
|
264
|
+
children
|
|
265
|
+
} });
|
|
266
|
+
}
|
|
267
|
+
/** Walk from the docked root to a pane, taking the child `choose` names at every split. */
|
|
268
|
+
function descend(state, choose) {
|
|
269
|
+
let node = getNode(state, state.rootId);
|
|
270
|
+
while (node.kind === "split") {
|
|
271
|
+
const next = choose(node);
|
|
272
|
+
/* v8 ignore next -- a split holds at least two children, so every choice names one. */
|
|
273
|
+
if (next === void 0) throw new Error(`layout: split ${node.id} has no children`);
|
|
274
|
+
node = getNode(state, next);
|
|
275
|
+
}
|
|
276
|
+
return node.id;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The first docked pane in visual order: the docked root, or the first leaf
|
|
280
|
+
* under it. Focus falls back here when the focused pane is removed, and a new
|
|
281
|
+
* tab lands here when the focused pane floats.
|
|
282
|
+
* @param state - current layout.
|
|
283
|
+
* @returns the first docked pane's id.
|
|
284
|
+
*/
|
|
285
|
+
function firstDockPaneId(state) {
|
|
286
|
+
return descend(state, (split) => split.children[0]);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The docked pane in the top-right corner: from the root, the last child of
|
|
290
|
+
* every row split and the first child of every column split. Its tab strip is
|
|
291
|
+
* where an embedder's surface-wide controls sit, so they read as the surface's
|
|
292
|
+
* own top-right corner however the tree is divided.
|
|
293
|
+
* @param state - current layout.
|
|
294
|
+
* @returns the top-right docked pane's id.
|
|
295
|
+
*/
|
|
296
|
+
function topRightPaneId(state) {
|
|
297
|
+
return descend(state, (split) => split.axis === "row" ? split.children.at(-1) : split.children[0]);
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region lib/types/engine/operations.js
|
|
301
|
+
/** Capture the focus facts of `paneIds` plus global focus, as the operation that restores them. */
|
|
302
|
+
function focusSnapshot(state, paneIds) {
|
|
303
|
+
const paneActiveTabs = {};
|
|
304
|
+
for (const id of paneIds) paneActiveTabs[id] = getPane(state, id).activeTabId;
|
|
305
|
+
return {
|
|
306
|
+
type: "restoreFocus",
|
|
307
|
+
activePaneId: state.activePaneId,
|
|
308
|
+
floats: state.floats,
|
|
309
|
+
paneActiveTabs
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/** Move `paneId` to the top of the floating z order. */
|
|
313
|
+
function raise(floats, paneId) {
|
|
314
|
+
return [...floats.filter((id) => id !== paneId), paneId];
|
|
315
|
+
}
|
|
316
|
+
/** Keep `activePaneId` on a live pane after `state` lost the focused one. */
|
|
317
|
+
function reseatFocus(state, removedPaneId) {
|
|
318
|
+
if (state.activePaneId !== removedPaneId) return state;
|
|
319
|
+
return {
|
|
320
|
+
...state,
|
|
321
|
+
activePaneId: firstDockPaneId(state)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** A fresh empty docked pane. */
|
|
325
|
+
function emptyDockPane(id) {
|
|
326
|
+
return {
|
|
327
|
+
kind: "pane",
|
|
328
|
+
id,
|
|
329
|
+
host: "dock",
|
|
330
|
+
tabs: [],
|
|
331
|
+
activeTabId: void 0,
|
|
332
|
+
rect: void 0
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Reject an id that a creating operation expects to be free. */
|
|
336
|
+
function assertFreeNode(state, id) {
|
|
337
|
+
if (state.nodes[id] !== void 0) throw new Error(`layout: node ${id} already exists`);
|
|
338
|
+
}
|
|
339
|
+
/** Reject a tab id that an opening operation expects to be free. */
|
|
340
|
+
function assertFreeTab(state, id) {
|
|
341
|
+
if (state.tabs[id] !== void 0) throw new Error(`layout: tab ${id} already exists`);
|
|
342
|
+
}
|
|
343
|
+
/** Give `paneId` an empty sibling along `axis`. */
|
|
344
|
+
function applySplit(state, op) {
|
|
345
|
+
if (getPane(state, op.paneId).host !== "dock") throw new Error("layout: split requires a docked pane");
|
|
346
|
+
assertFreeNode(state, op.newPaneId);
|
|
347
|
+
const newPane = emptyDockPane(op.newPaneId);
|
|
348
|
+
const parent = findParent(state, op.paneId);
|
|
349
|
+
if (parent !== void 0 && parent.axis === op.axis) {
|
|
350
|
+
const index = parent.children.indexOf(op.paneId);
|
|
351
|
+
const at = op.direction === "after" ? index + 1 : index;
|
|
352
|
+
const children = insertAt(parent.children, at, op.newPaneId);
|
|
353
|
+
const sizes = parent.sizes.flatMap((size, i) => i === index ? [size / 2, size / 2] : [size]);
|
|
354
|
+
return {
|
|
355
|
+
state: withNodes(state, {
|
|
356
|
+
[op.newPaneId]: newPane,
|
|
357
|
+
[parent.id]: {
|
|
358
|
+
...parent,
|
|
359
|
+
children,
|
|
360
|
+
sizes
|
|
361
|
+
}
|
|
362
|
+
}),
|
|
363
|
+
inverse: [{
|
|
364
|
+
type: "merge",
|
|
365
|
+
paneId: op.newPaneId
|
|
366
|
+
}, {
|
|
367
|
+
type: "resize",
|
|
368
|
+
splitId: parent.id,
|
|
369
|
+
sizes: parent.sizes
|
|
370
|
+
}]
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
assertFreeNode(state, op.newSplitId);
|
|
374
|
+
const rehomed = replaceInParent(state, op.paneId, op.newSplitId);
|
|
375
|
+
const children = op.direction === "after" ? [op.paneId, op.newPaneId] : [op.newPaneId, op.paneId];
|
|
376
|
+
return {
|
|
377
|
+
state: withNodes(rehomed, {
|
|
378
|
+
[op.newPaneId]: newPane,
|
|
379
|
+
[op.newSplitId]: {
|
|
380
|
+
kind: "split",
|
|
381
|
+
id: op.newSplitId,
|
|
382
|
+
axis: op.axis,
|
|
383
|
+
children,
|
|
384
|
+
sizes: [.5, .5]
|
|
385
|
+
}
|
|
386
|
+
}),
|
|
387
|
+
inverse: [{
|
|
388
|
+
type: "merge",
|
|
389
|
+
paneId: op.newPaneId
|
|
390
|
+
}]
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** Drop an empty pane; a two-child split collapses into its surviving child. */
|
|
394
|
+
function applyMerge(state, op) {
|
|
395
|
+
const pane = getPane(state, op.paneId);
|
|
396
|
+
if (pane.tabs.length > 0) throw new Error("layout: merge requires an empty pane");
|
|
397
|
+
const focus = focusSnapshot(state, []);
|
|
398
|
+
if (pane.host === "float") {
|
|
399
|
+
const index = floatIndex(state, op.paneId);
|
|
400
|
+
return {
|
|
401
|
+
state: reseatFocus(withNodes({
|
|
402
|
+
...state,
|
|
403
|
+
floats: removeAt(state.floats, index)
|
|
404
|
+
}, { [op.paneId]: null }), op.paneId),
|
|
405
|
+
inverse: [{
|
|
406
|
+
type: "insertPane",
|
|
407
|
+
pane,
|
|
408
|
+
tabs: [],
|
|
409
|
+
attach: {
|
|
410
|
+
mode: "float",
|
|
411
|
+
index
|
|
412
|
+
}
|
|
413
|
+
}, focus]
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const parent = findParent(state, op.paneId);
|
|
417
|
+
if (parent === void 0) throw new Error("layout: the docked root pane cannot be merged");
|
|
418
|
+
const index = parent.children.indexOf(op.paneId);
|
|
419
|
+
if (parent.children.length > 2) {
|
|
420
|
+
const children = removeAt(parent.children, index);
|
|
421
|
+
const sizes = normalizeSizes(removeAt(parent.sizes, index));
|
|
422
|
+
return {
|
|
423
|
+
state: reseatFocus(withNodes(state, {
|
|
424
|
+
[op.paneId]: null,
|
|
425
|
+
[parent.id]: {
|
|
426
|
+
...parent,
|
|
427
|
+
children,
|
|
428
|
+
sizes
|
|
429
|
+
}
|
|
430
|
+
}), op.paneId),
|
|
431
|
+
inverse: [{
|
|
432
|
+
type: "insertPane",
|
|
433
|
+
pane,
|
|
434
|
+
tabs: [],
|
|
435
|
+
attach: {
|
|
436
|
+
mode: "child",
|
|
437
|
+
parentId: parent.id,
|
|
438
|
+
index,
|
|
439
|
+
sizes: parent.sizes
|
|
440
|
+
}
|
|
441
|
+
}, focus]
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const siblingId = parent.children[1 - index];
|
|
445
|
+
/* v8 ignore next -- a split holds at least two children, so one survives the merged pane. */
|
|
446
|
+
if (siblingId === void 0) throw new Error("layout: merge found a split without a sibling");
|
|
447
|
+
return {
|
|
448
|
+
state: reseatFocus(withNodes(replaceInParent(state, parent.id, siblingId), {
|
|
449
|
+
[op.paneId]: null,
|
|
450
|
+
[parent.id]: null
|
|
451
|
+
}), op.paneId),
|
|
452
|
+
inverse: [{
|
|
453
|
+
type: "insertPane",
|
|
454
|
+
pane,
|
|
455
|
+
tabs: [],
|
|
456
|
+
attach: {
|
|
457
|
+
mode: "wrap",
|
|
458
|
+
targetId: siblingId,
|
|
459
|
+
split: parent
|
|
460
|
+
}
|
|
461
|
+
}, focus]
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** Add a new tab to a docked pane and focus it. */
|
|
465
|
+
function applyOpenTab(state, op) {
|
|
466
|
+
const pane = getPane(state, op.paneId);
|
|
467
|
+
if (pane.host !== "dock") throw new Error("layout: openTab requires a docked pane");
|
|
468
|
+
assertFreeTab(state, op.tab.id);
|
|
469
|
+
const focus = focusSnapshot(state, [pane.id]);
|
|
470
|
+
return {
|
|
471
|
+
state: {
|
|
472
|
+
...withNodes(withTabs(state, { [op.tab.id]: op.tab }), { [pane.id]: paneWithTabs(pane, insertAt(pane.tabs, op.index, op.tab.id), op.tab.id) }),
|
|
473
|
+
activePaneId: pane.id
|
|
474
|
+
},
|
|
475
|
+
inverse: [{
|
|
476
|
+
type: "closeTab",
|
|
477
|
+
tabId: op.tab.id
|
|
478
|
+
}, focus]
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/** Put one tab record back where it was, without stealing focus. */
|
|
482
|
+
function applyInsertTab(state, op) {
|
|
483
|
+
const pane = getPane(state, op.paneId);
|
|
484
|
+
if (pane.host !== "dock") throw new Error("layout: insertTab requires a docked pane");
|
|
485
|
+
assertFreeTab(state, op.tab.id);
|
|
486
|
+
const focus = focusSnapshot(state, [pane.id]);
|
|
487
|
+
const tabs = insertAt(pane.tabs, op.index, op.tab.id);
|
|
488
|
+
return {
|
|
489
|
+
state: withNodes(withTabs(state, { [op.tab.id]: op.tab }), { [pane.id]: paneWithTabs(pane, tabs, pane.activeTabId ?? op.tab.id) }),
|
|
490
|
+
inverse: [{
|
|
491
|
+
type: "closeTab",
|
|
492
|
+
tabId: op.tab.id
|
|
493
|
+
}, focus]
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/** Destroy a tab and its content state; a floating host pane goes with its only tab. */
|
|
497
|
+
function applyCloseTab(state, op) {
|
|
498
|
+
const tab = getTab(state, op.tabId);
|
|
499
|
+
const pane = findTabPane(state, op.tabId);
|
|
500
|
+
const index = pane.tabs.indexOf(op.tabId);
|
|
501
|
+
const focus = focusSnapshot(state, [pane.id]);
|
|
502
|
+
if (pane.host === "float") {
|
|
503
|
+
const index = floatIndex(state, pane.id);
|
|
504
|
+
return {
|
|
505
|
+
state: reseatFocus(withTabs(withNodes({
|
|
506
|
+
...state,
|
|
507
|
+
floats: removeAt(state.floats, index)
|
|
508
|
+
}, { [pane.id]: null }), { [op.tabId]: null }), pane.id),
|
|
509
|
+
inverse: [{
|
|
510
|
+
type: "insertPane",
|
|
511
|
+
pane,
|
|
512
|
+
tabs: [tab],
|
|
513
|
+
attach: {
|
|
514
|
+
mode: "float",
|
|
515
|
+
index
|
|
516
|
+
}
|
|
517
|
+
}, focus]
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const activeTabId = pane.activeTabId === op.tabId ? neighbourTabId(pane.tabs, index) : pane.activeTabId;
|
|
521
|
+
return {
|
|
522
|
+
state: withTabs(withNodes(state, { [pane.id]: paneWithTabs(pane, removeAt(pane.tabs, index), activeTabId) }), { [op.tabId]: null }),
|
|
523
|
+
inverse: [{
|
|
524
|
+
type: "insertTab",
|
|
525
|
+
paneId: pane.id,
|
|
526
|
+
tab,
|
|
527
|
+
index
|
|
528
|
+
}, focus]
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Put a pane back, with the tab records it owned. A docked pane returns empty
|
|
533
|
+
* (its tabs return through `insertTab`, as `closeTab` records them); a floating
|
|
534
|
+
* pane returns with its one tab, or empty.
|
|
535
|
+
*/
|
|
536
|
+
function applyInsertPane(state, op) {
|
|
537
|
+
assertFreeNode(state, op.pane.id);
|
|
538
|
+
if (op.pane.tabs.length !== op.tabs.length) throw new Error("layout: insertPane tab records do not match the pane");
|
|
539
|
+
if (op.pane.host === "dock" && op.tabs.length > 0) throw new Error("layout: insertPane returns a docked pane empty");
|
|
540
|
+
const tabUpdates = {};
|
|
541
|
+
for (const tab of op.tabs) {
|
|
542
|
+
assertFreeTab(state, tab.id);
|
|
543
|
+
tabUpdates[tab.id] = tab;
|
|
544
|
+
}
|
|
545
|
+
const restoredTab = op.tabs[0];
|
|
546
|
+
const inverse = restoredTab === void 0 ? [{
|
|
547
|
+
type: "merge",
|
|
548
|
+
paneId: op.pane.id
|
|
549
|
+
}] : [{
|
|
550
|
+
type: "closeTab",
|
|
551
|
+
tabId: restoredTab.id
|
|
552
|
+
}, focusSnapshot(state, [])];
|
|
553
|
+
const attach = op.attach;
|
|
554
|
+
switch (attach.mode) {
|
|
555
|
+
case "child": {
|
|
556
|
+
const parent = getSplit(state, attach.parentId);
|
|
557
|
+
const children = insertAt(parent.children, attach.index, op.pane.id);
|
|
558
|
+
if (attach.sizes.length !== children.length) throw new Error("layout: insertPane sizes do not match the split");
|
|
559
|
+
inverse.push({
|
|
560
|
+
type: "resize",
|
|
561
|
+
splitId: parent.id,
|
|
562
|
+
sizes: parent.sizes
|
|
563
|
+
});
|
|
564
|
+
return {
|
|
565
|
+
state: withNodes(withTabs(state, tabUpdates), {
|
|
566
|
+
[op.pane.id]: op.pane,
|
|
567
|
+
[parent.id]: {
|
|
568
|
+
...parent,
|
|
569
|
+
children,
|
|
570
|
+
sizes: attach.sizes
|
|
571
|
+
}
|
|
572
|
+
}),
|
|
573
|
+
inverse
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
case "wrap":
|
|
577
|
+
if (!attach.split.children.includes(op.pane.id)) throw new Error("layout: insertPane wrap split does not list the pane");
|
|
578
|
+
return {
|
|
579
|
+
state: withNodes(withTabs(replaceInParent(state, attach.targetId, attach.split.id), tabUpdates), {
|
|
580
|
+
[op.pane.id]: op.pane,
|
|
581
|
+
[attach.split.id]: attach.split
|
|
582
|
+
}),
|
|
583
|
+
inverse
|
|
584
|
+
};
|
|
585
|
+
case "float": {
|
|
586
|
+
if (op.pane.host !== "float") throw new Error("layout: float attachment requires a floating pane");
|
|
587
|
+
const floats = insertAt(state.floats, attach.index, op.pane.id);
|
|
588
|
+
return {
|
|
589
|
+
state: withNodes(withTabs({
|
|
590
|
+
...state,
|
|
591
|
+
floats
|
|
592
|
+
}, tabUpdates), { [op.pane.id]: op.pane }),
|
|
593
|
+
inverse
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/* v8 ignore next 2 -- closed-union backstop; the compiler rejects a new attachment mode here. */
|
|
597
|
+
default: return assertNever(attach, "layout: insertPane attachment");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** Move a tab to a different docked pane and focus it there. */
|
|
601
|
+
function applyMoveTab(state, op) {
|
|
602
|
+
const from = findTabPane(state, op.tabId);
|
|
603
|
+
if (from.host !== "dock") throw new Error("layout: moveTab source must be docked; use unfloat");
|
|
604
|
+
const to = getPane(state, op.toPaneId);
|
|
605
|
+
if (to.host !== "dock") throw new Error("layout: moveTab target must be docked");
|
|
606
|
+
if (to.id === from.id) throw new Error("layout: moveTab across one pane; use reorderTab");
|
|
607
|
+
const index = from.tabs.indexOf(op.tabId);
|
|
608
|
+
const focus = focusSnapshot(state, [from.id, to.id]);
|
|
609
|
+
const activeTabId = from.activeTabId === op.tabId ? neighbourTabId(from.tabs, index) : from.activeTabId;
|
|
610
|
+
return {
|
|
611
|
+
state: {
|
|
612
|
+
...withNodes(state, {
|
|
613
|
+
[from.id]: paneWithTabs(from, removeAt(from.tabs, index), activeTabId),
|
|
614
|
+
[to.id]: paneWithTabs(to, insertAt(to.tabs, op.index, op.tabId), op.tabId)
|
|
615
|
+
}),
|
|
616
|
+
activePaneId: to.id
|
|
617
|
+
},
|
|
618
|
+
inverse: [{
|
|
619
|
+
type: "moveTab",
|
|
620
|
+
tabId: op.tabId,
|
|
621
|
+
toPaneId: from.id,
|
|
622
|
+
index
|
|
623
|
+
}, focus]
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** Move a tab within its own pane. */
|
|
627
|
+
function applyReorderTab(state, op) {
|
|
628
|
+
const pane = findTabPane(state, op.tabId);
|
|
629
|
+
const from = pane.tabs.indexOf(op.tabId);
|
|
630
|
+
const tabs = insertAt(removeAt(pane.tabs, from), op.index, op.tabId);
|
|
631
|
+
return {
|
|
632
|
+
state: withNodes(state, { [pane.id]: {
|
|
633
|
+
...pane,
|
|
634
|
+
tabs
|
|
635
|
+
} }),
|
|
636
|
+
inverse: [{
|
|
637
|
+
type: "reorderTab",
|
|
638
|
+
tabId: op.tabId,
|
|
639
|
+
index: from
|
|
640
|
+
}]
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/** Focus a tab, its pane, and raise that pane when floating. */
|
|
644
|
+
function applyFocusTab(state, op) {
|
|
645
|
+
const pane = findTabPane(state, op.tabId);
|
|
646
|
+
const focus = focusSnapshot(state, [pane.id]);
|
|
647
|
+
const focused = withNodes(state, { [pane.id]: {
|
|
648
|
+
...pane,
|
|
649
|
+
activeTabId: op.tabId
|
|
650
|
+
} });
|
|
651
|
+
const floats = pane.host === "float" ? raise(focused.floats, pane.id) : focused.floats;
|
|
652
|
+
return {
|
|
653
|
+
state: {
|
|
654
|
+
...focused,
|
|
655
|
+
activePaneId: pane.id,
|
|
656
|
+
floats
|
|
657
|
+
},
|
|
658
|
+
inverse: [focus]
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/** Focus a pane and raise it when floating. */
|
|
662
|
+
function applyFocusPane(state, op) {
|
|
663
|
+
const pane = getPane(state, op.paneId);
|
|
664
|
+
const focus = focusSnapshot(state, []);
|
|
665
|
+
const floats = pane.host === "float" ? raise(state.floats, pane.id) : state.floats;
|
|
666
|
+
return {
|
|
667
|
+
state: {
|
|
668
|
+
...state,
|
|
669
|
+
activePaneId: pane.id,
|
|
670
|
+
floats
|
|
671
|
+
},
|
|
672
|
+
inverse: [focus]
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/** Record the net result of a divider drag. */
|
|
676
|
+
function applyResize(state, op) {
|
|
677
|
+
const split = getSplit(state, op.splitId);
|
|
678
|
+
if (op.sizes.length !== split.children.length) throw new Error("layout: resize sizes do not match the split");
|
|
679
|
+
if (op.sizes.some((size) => !(size > 0))) throw new Error("layout: resize sizes must all be above zero");
|
|
680
|
+
return {
|
|
681
|
+
state: withNodes(state, { [split.id]: {
|
|
682
|
+
...split,
|
|
683
|
+
sizes: normalizeSizes(op.sizes)
|
|
684
|
+
} }),
|
|
685
|
+
inverse: [{
|
|
686
|
+
type: "resize",
|
|
687
|
+
splitId: split.id,
|
|
688
|
+
sizes: split.sizes
|
|
689
|
+
}]
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Take a tab out of the docked tree into a new floating pane on top. */
|
|
693
|
+
function applyFloat(state, op) {
|
|
694
|
+
getTab(state, op.tabId);
|
|
695
|
+
const from = findTabPane(state, op.tabId);
|
|
696
|
+
if (from.host !== "dock") throw new Error("layout: float requires a docked tab");
|
|
697
|
+
assertFreeNode(state, op.newPaneId);
|
|
698
|
+
const index = from.tabs.indexOf(op.tabId);
|
|
699
|
+
const focus = focusSnapshot(state, [from.id]);
|
|
700
|
+
const activeTabId = from.activeTabId === op.tabId ? neighbourTabId(from.tabs, index) : from.activeTabId;
|
|
701
|
+
const floated = withNodes(state, {
|
|
702
|
+
[from.id]: paneWithTabs(from, removeAt(from.tabs, index), activeTabId),
|
|
703
|
+
[op.newPaneId]: {
|
|
704
|
+
kind: "pane",
|
|
705
|
+
id: op.newPaneId,
|
|
706
|
+
host: "float",
|
|
707
|
+
tabs: [op.tabId],
|
|
708
|
+
activeTabId: op.tabId,
|
|
709
|
+
rect: op.rect
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
return {
|
|
713
|
+
state: {
|
|
714
|
+
...floated,
|
|
715
|
+
floats: [...floated.floats, op.newPaneId],
|
|
716
|
+
activePaneId: op.newPaneId
|
|
717
|
+
},
|
|
718
|
+
inverse: [{
|
|
719
|
+
type: "unfloat",
|
|
720
|
+
paneId: op.newPaneId,
|
|
721
|
+
toPaneId: from.id,
|
|
722
|
+
index
|
|
723
|
+
}, focus]
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/** Return a floating pane's only tab to a docked pane and destroy the floating pane. */
|
|
727
|
+
function applyUnfloat(state, op) {
|
|
728
|
+
const pane = getPane(state, op.paneId);
|
|
729
|
+
const rect = floatRect(pane);
|
|
730
|
+
const tabId = onlyTabId(pane);
|
|
731
|
+
const to = getPane(state, op.toPaneId);
|
|
732
|
+
if (to.host !== "dock") throw new Error("layout: unfloat target must be docked");
|
|
733
|
+
const focus = focusSnapshot(state, [to.id]);
|
|
734
|
+
return {
|
|
735
|
+
state: {
|
|
736
|
+
...withNodes({
|
|
737
|
+
...state,
|
|
738
|
+
floats: removeAt(state.floats, floatIndex(state, op.paneId))
|
|
739
|
+
}, {
|
|
740
|
+
[op.paneId]: null,
|
|
741
|
+
[to.id]: paneWithTabs(to, insertAt(to.tabs, op.index, tabId), tabId)
|
|
742
|
+
}),
|
|
743
|
+
activePaneId: to.id
|
|
744
|
+
},
|
|
745
|
+
inverse: [{
|
|
746
|
+
type: "float",
|
|
747
|
+
tabId,
|
|
748
|
+
newPaneId: op.paneId,
|
|
749
|
+
rect
|
|
750
|
+
}, focus]
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/** Give a floating pane a new rectangle, focus it, and raise it: the one operation a drag of it records. */
|
|
754
|
+
function reshapeFloat(state, pane, rect) {
|
|
755
|
+
const reshaped = withNodes(state, { [pane.id]: {
|
|
756
|
+
...pane,
|
|
757
|
+
rect
|
|
758
|
+
} });
|
|
759
|
+
return {
|
|
760
|
+
...reshaped,
|
|
761
|
+
activePaneId: pane.id,
|
|
762
|
+
floats: raise(reshaped.floats, pane.id)
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/** Record the net result of dragging a floating pane, which also focuses and raises it. */
|
|
766
|
+
function applyMoveFloat(state, op) {
|
|
767
|
+
const pane = getPane(state, op.paneId);
|
|
768
|
+
const rect = floatRect(pane);
|
|
769
|
+
return {
|
|
770
|
+
state: reshapeFloat(state, pane, {
|
|
771
|
+
...rect,
|
|
772
|
+
x: op.x,
|
|
773
|
+
y: op.y
|
|
774
|
+
}),
|
|
775
|
+
inverse: [{
|
|
776
|
+
type: "moveFloat",
|
|
777
|
+
paneId: op.paneId,
|
|
778
|
+
x: rect.x,
|
|
779
|
+
y: rect.y
|
|
780
|
+
}, focusSnapshot(state, [])]
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/** Record the net result of resizing a floating pane, which also focuses and raises it. */
|
|
784
|
+
function applyResizeFloat(state, op) {
|
|
785
|
+
const pane = getPane(state, op.paneId);
|
|
786
|
+
const rect = floatRect(pane);
|
|
787
|
+
if (!(op.rect.width > 0) || !(op.rect.height > 0)) throw new Error("layout: float size must be above zero");
|
|
788
|
+
return {
|
|
789
|
+
state: reshapeFloat(state, pane, op.rect),
|
|
790
|
+
inverse: [{
|
|
791
|
+
type: "resizeFloat",
|
|
792
|
+
paneId: op.paneId,
|
|
793
|
+
rect
|
|
794
|
+
}, focusSnapshot(state, [])]
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/** Restore focus facts a previous operation displaced. */
|
|
798
|
+
function applyRestoreFocus(state, op) {
|
|
799
|
+
const inverse = focusSnapshot(state, keysOf(op.paneActiveTabs));
|
|
800
|
+
for (const paneId of op.floats) if (getPane(state, paneId).host !== "float") throw new Error(`layout: restoreFocus lists docked pane ${paneId} as floating`);
|
|
801
|
+
let next = state;
|
|
802
|
+
for (const [paneId, activeTabId] of entriesOf(op.paneActiveTabs)) {
|
|
803
|
+
const pane = getPane(next, paneId);
|
|
804
|
+
next = withNodes(next, { [paneId]: {
|
|
805
|
+
...pane,
|
|
806
|
+
activeTabId
|
|
807
|
+
} });
|
|
808
|
+
}
|
|
809
|
+
getPane(next, op.activePaneId);
|
|
810
|
+
return {
|
|
811
|
+
state: {
|
|
812
|
+
...next,
|
|
813
|
+
activePaneId: op.activePaneId,
|
|
814
|
+
floats: op.floats
|
|
815
|
+
},
|
|
816
|
+
inverse: [inverse]
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Apply one operation.
|
|
821
|
+
* @param state - state the operation reads; never mutated.
|
|
822
|
+
* @param op - the operation, carrying every id it creates.
|
|
823
|
+
* @returns the next state and the operations that undo it, applied in order.
|
|
824
|
+
* @throws when the operation addresses missing nodes or breaks a model rule.
|
|
825
|
+
*/
|
|
826
|
+
function applyOp(state, op) {
|
|
827
|
+
switch (op.type) {
|
|
828
|
+
case "split": return applySplit(state, op);
|
|
829
|
+
case "merge": return applyMerge(state, op);
|
|
830
|
+
case "openTab": return applyOpenTab(state, op);
|
|
831
|
+
case "insertTab": return applyInsertTab(state, op);
|
|
832
|
+
case "closeTab": return applyCloseTab(state, op);
|
|
833
|
+
case "insertPane": return applyInsertPane(state, op);
|
|
834
|
+
case "moveTab": return applyMoveTab(state, op);
|
|
835
|
+
case "reorderTab": return applyReorderTab(state, op);
|
|
836
|
+
case "focusTab": return applyFocusTab(state, op);
|
|
837
|
+
case "focusPane": return applyFocusPane(state, op);
|
|
838
|
+
case "resize": return applyResize(state, op);
|
|
839
|
+
case "float": return applyFloat(state, op);
|
|
840
|
+
case "unfloat": return applyUnfloat(state, op);
|
|
841
|
+
case "moveFloat": return applyMoveFloat(state, op);
|
|
842
|
+
case "resizeFloat": return applyResizeFloat(state, op);
|
|
843
|
+
case "setExpanded": return {
|
|
844
|
+
state: {
|
|
845
|
+
...state,
|
|
846
|
+
expanded: op.expanded
|
|
847
|
+
},
|
|
848
|
+
inverse: [{
|
|
849
|
+
type: "setExpanded",
|
|
850
|
+
expanded: state.expanded
|
|
851
|
+
}]
|
|
852
|
+
};
|
|
853
|
+
case "setMode": return {
|
|
854
|
+
state: {
|
|
855
|
+
...state,
|
|
856
|
+
mode: op.mode
|
|
857
|
+
},
|
|
858
|
+
inverse: [{
|
|
859
|
+
type: "setMode",
|
|
860
|
+
mode: state.mode
|
|
861
|
+
}]
|
|
862
|
+
};
|
|
863
|
+
case "restoreFocus": return applyRestoreFocus(state, op);
|
|
864
|
+
/* v8 ignore next -- closed-union backstop; the compiler rejects a new operation type here. */
|
|
865
|
+
default: return assertNever(op, "layout: operation");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Fold operations forward, discarding inverses.
|
|
870
|
+
* @param state - starting state.
|
|
871
|
+
* @param ops - operations in recorded order.
|
|
872
|
+
* @returns the state after every operation.
|
|
873
|
+
*/
|
|
874
|
+
function replay(state, ops) {
|
|
875
|
+
return ops.reduce((current, op) => applyOp(current, op).state, state);
|
|
876
|
+
}
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region lib/types/engine/sequence.js
|
|
879
|
+
/** A sequence that has recorded nothing. */
|
|
880
|
+
const EMPTY_HISTORY = {
|
|
881
|
+
entries: [],
|
|
882
|
+
cursor: 0
|
|
883
|
+
};
|
|
884
|
+
/** Operation kinds that only move focus. */
|
|
885
|
+
const FOCUS_OP_TYPES = new Set([
|
|
886
|
+
"focusTab",
|
|
887
|
+
"focusPane",
|
|
888
|
+
"restoreFocus"
|
|
889
|
+
]);
|
|
890
|
+
/**
|
|
891
|
+
* Whether an operation only moves focus, and so merges into its neighbours' undo step.
|
|
892
|
+
* @param op - the operation.
|
|
893
|
+
* @returns whether its type is a `FocusOpType`.
|
|
894
|
+
*/
|
|
895
|
+
function isFocusOp(op) {
|
|
896
|
+
return FOCUS_OP_TYPES.has(op.type);
|
|
897
|
+
}
|
|
898
|
+
/** Whether the entry at `index` only moves focus. */
|
|
899
|
+
function isFocusEntry(history, index) {
|
|
900
|
+
const entry = history.entries[index];
|
|
901
|
+
return entry !== void 0 && entry.ops.every(isFocusOp);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Whether a step back exists.
|
|
905
|
+
* @param history - the sequence so far.
|
|
906
|
+
* @returns whether any entry is applied.
|
|
907
|
+
*/
|
|
908
|
+
function canStepBack(history) {
|
|
909
|
+
return history.cursor > 0;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Whether a step forward exists.
|
|
913
|
+
* @param history - the sequence so far.
|
|
914
|
+
* @returns whether a redo branch remains.
|
|
915
|
+
*/
|
|
916
|
+
function canStepForward(history) {
|
|
917
|
+
return history.cursor < history.entries.length;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* The operations a sequence has recorded, redo branch included.
|
|
921
|
+
* @param history - the sequence so far.
|
|
922
|
+
* @returns every entry's operations, in recorded order.
|
|
923
|
+
*/
|
|
924
|
+
function recordedOps(history) {
|
|
925
|
+
return history.entries.flatMap((entry) => entry.ops);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Apply one intent's operations and record them as one entry, dropping any redo
|
|
929
|
+
* branch first. An intent with no operations records nothing.
|
|
930
|
+
* @param history - the sequence so far.
|
|
931
|
+
* @param state - the state the operations apply to.
|
|
932
|
+
* @param ops - the intent's operations, in application order.
|
|
933
|
+
* @returns the extended history and the state after the operations.
|
|
934
|
+
* @throws when an operation is invalid against the state it reaches; nothing is
|
|
935
|
+
* recorded.
|
|
936
|
+
*/
|
|
937
|
+
function record(history, state, ops) {
|
|
938
|
+
if (ops.length === 0) return {
|
|
939
|
+
history,
|
|
940
|
+
state
|
|
941
|
+
};
|
|
942
|
+
let next = state;
|
|
943
|
+
const inverse = [];
|
|
944
|
+
for (const op of ops) {
|
|
945
|
+
const result = applyOp(next, op);
|
|
946
|
+
next = result.state;
|
|
947
|
+
inverse.unshift(...result.inverse);
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
history: {
|
|
951
|
+
entries: [...history.cursor === history.entries.length ? history.entries : history.entries.slice(0, history.cursor), {
|
|
952
|
+
ops,
|
|
953
|
+
inverse
|
|
954
|
+
}],
|
|
955
|
+
cursor: history.cursor + 1
|
|
956
|
+
},
|
|
957
|
+
state: next
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Step back one intent, or one whole run of consecutive focus-only intents.
|
|
962
|
+
* @param history - the sequence so far.
|
|
963
|
+
* @param state - the current state.
|
|
964
|
+
* @returns the stepped-back pair, or `undefined` when nothing can be undone.
|
|
965
|
+
*/
|
|
966
|
+
function stepBack(history, state) {
|
|
967
|
+
if (!canStepBack(history)) return void 0;
|
|
968
|
+
let count = 1;
|
|
969
|
+
if (isFocusEntry(history, history.cursor - 1)) while (isFocusEntry(history, history.cursor - 1 - count)) count += 1;
|
|
970
|
+
let next = state;
|
|
971
|
+
for (const entry of history.entries.slice(history.cursor - count, history.cursor).reverse()) for (const op of entry.inverse) next = applyOp(next, op).state;
|
|
972
|
+
return {
|
|
973
|
+
history: {
|
|
974
|
+
entries: history.entries,
|
|
975
|
+
cursor: history.cursor - count
|
|
976
|
+
},
|
|
977
|
+
state: next
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Step forward over the intents the matching step back undid.
|
|
982
|
+
* @param history - the sequence so far.
|
|
983
|
+
* @param state - the current state.
|
|
984
|
+
* @returns the stepped-forward pair, or `undefined` when nothing can be redone.
|
|
985
|
+
*/
|
|
986
|
+
function stepForward(history, state) {
|
|
987
|
+
if (!canStepForward(history)) return void 0;
|
|
988
|
+
let count = 1;
|
|
989
|
+
if (isFocusEntry(history, history.cursor)) while (isFocusEntry(history, history.cursor + count)) count += 1;
|
|
990
|
+
let next = state;
|
|
991
|
+
for (const entry of history.entries.slice(history.cursor, history.cursor + count)) for (const op of entry.ops) next = applyOp(next, op).state;
|
|
992
|
+
return {
|
|
993
|
+
history: {
|
|
994
|
+
entries: history.entries,
|
|
995
|
+
cursor: history.cursor + count
|
|
996
|
+
},
|
|
997
|
+
state: next
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
/** Layout state plus its history cursor, held here instead of by the embedder. */
|
|
1001
|
+
var Sequencer = class {
|
|
1002
|
+
current;
|
|
1003
|
+
recorded = EMPTY_HISTORY;
|
|
1004
|
+
/** @param initial - state the sequence replays from; never mutated. */
|
|
1005
|
+
constructor(initial) {
|
|
1006
|
+
this.current = initial;
|
|
1007
|
+
}
|
|
1008
|
+
/** Current state. */
|
|
1009
|
+
get state() {
|
|
1010
|
+
return this.current;
|
|
1011
|
+
}
|
|
1012
|
+
/** The recorded sequence as plain data. */
|
|
1013
|
+
get history() {
|
|
1014
|
+
return this.recorded;
|
|
1015
|
+
}
|
|
1016
|
+
/** The whole recorded sequence, including a redo branch that is not applied. */
|
|
1017
|
+
get ops() {
|
|
1018
|
+
return recordedOps(this.recorded);
|
|
1019
|
+
}
|
|
1020
|
+
/** How many recorded operations are currently applied. */
|
|
1021
|
+
get cursor() {
|
|
1022
|
+
return this.recorded.cursor;
|
|
1023
|
+
}
|
|
1024
|
+
/** Whether a step back exists. */
|
|
1025
|
+
get canUndo() {
|
|
1026
|
+
return canStepBack(this.recorded);
|
|
1027
|
+
}
|
|
1028
|
+
/** Whether a step forward exists. */
|
|
1029
|
+
get canRedo() {
|
|
1030
|
+
return canStepForward(this.recorded);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Apply and record one operation as its own entry, dropping any redo branch first.
|
|
1034
|
+
* @param op - the operation to record.
|
|
1035
|
+
* @returns the state after it.
|
|
1036
|
+
* @throws when the operation is invalid against the current state; the
|
|
1037
|
+
* sequence is left untouched.
|
|
1038
|
+
*/
|
|
1039
|
+
dispatch(op) {
|
|
1040
|
+
return this.dispatchAll([op]);
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Apply and record one intent's operations as one entry, dropping any redo
|
|
1044
|
+
* branch first.
|
|
1045
|
+
* @param ops - the intent's operations; none records nothing.
|
|
1046
|
+
* @returns the state after them.
|
|
1047
|
+
* @throws when an operation is invalid; the sequence is left untouched.
|
|
1048
|
+
*/
|
|
1049
|
+
dispatchAll(ops) {
|
|
1050
|
+
const stepped = record(this.recorded, this.current, ops);
|
|
1051
|
+
this.recorded = stepped.history;
|
|
1052
|
+
this.current = stepped.state;
|
|
1053
|
+
return this.current;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Step back one intent, or one whole run of consecutive focus-only intents.
|
|
1057
|
+
* @returns false when there is nothing to undo.
|
|
1058
|
+
*/
|
|
1059
|
+
undo() {
|
|
1060
|
+
const stepped = stepBack(this.recorded, this.current);
|
|
1061
|
+
if (stepped === void 0) return false;
|
|
1062
|
+
this.recorded = stepped.history;
|
|
1063
|
+
this.current = stepped.state;
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Step forward over the intents the matching undo stepped back.
|
|
1068
|
+
* @returns false when there is nothing to redo.
|
|
1069
|
+
*/
|
|
1070
|
+
redo() {
|
|
1071
|
+
const stepped = stepForward(this.recorded, this.current);
|
|
1072
|
+
if (stepped === void 0) return false;
|
|
1073
|
+
this.recorded = stepped.history;
|
|
1074
|
+
this.current = stepped.state;
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region lib/types/engine/constraints.js
|
|
1080
|
+
/** V1 caps the docked grid at four panes; floating panes do not count. */
|
|
1081
|
+
const MAX_DOCK_PANES = 4;
|
|
1082
|
+
/** Smallest fraction a divider drag may leave a pane, as a share of its split. */
|
|
1083
|
+
const MIN_PANE_FRACTION = .12;
|
|
1084
|
+
/** Size a tab takes when it first floats, in CSS pixels. */
|
|
1085
|
+
const FLOAT_DEFAULT_SIZE = {
|
|
1086
|
+
width: 380,
|
|
1087
|
+
height: 300
|
|
1088
|
+
};
|
|
1089
|
+
/** Smallest size a floating panel may be resized to, in CSS pixels. */
|
|
1090
|
+
const FLOAT_MIN_SIZE = {
|
|
1091
|
+
width: 220,
|
|
1092
|
+
height: 140
|
|
1093
|
+
};
|
|
1094
|
+
/** Fraction of a pane's width or height that counts as its dock edge. */
|
|
1095
|
+
const DOCK_EDGE_FRACTION = .25;
|
|
1096
|
+
/**
|
|
1097
|
+
* Number of docked panes.
|
|
1098
|
+
* @param state - current layout.
|
|
1099
|
+
* @returns how many panes the docked tree holds; floating panes do not count.
|
|
1100
|
+
*/
|
|
1101
|
+
function dockPaneCount(state) {
|
|
1102
|
+
return dockPaneIds(state).length;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Whether another docked pane is allowed.
|
|
1106
|
+
* @param state - current layout.
|
|
1107
|
+
* @returns whether the docked tree is under `MAX_DOCK_PANES`.
|
|
1108
|
+
*/
|
|
1109
|
+
function canSplit(state) {
|
|
1110
|
+
return dockPaneCount(state) < 4;
|
|
1111
|
+
}
|
|
1112
|
+
/** The five dock regions a tab can be dropped on. */
|
|
1113
|
+
const DOCK_ZONES = [
|
|
1114
|
+
"center",
|
|
1115
|
+
"top",
|
|
1116
|
+
"right",
|
|
1117
|
+
"bottom",
|
|
1118
|
+
"left"
|
|
1119
|
+
];
|
|
1120
|
+
/**
|
|
1121
|
+
* Which dock region a pointer sits in.
|
|
1122
|
+
* @param x - pointer x as a fraction of pane width.
|
|
1123
|
+
* @param y - pointer y as a fraction of pane height.
|
|
1124
|
+
* @param edge - edge band width as a fraction; defaults to `DOCK_EDGE_FRACTION`.
|
|
1125
|
+
* @returns the closest edge when the pointer is inside its band, else `'center'`.
|
|
1126
|
+
*/
|
|
1127
|
+
function zoneAt(x, y, edge = DOCK_EDGE_FRACTION) {
|
|
1128
|
+
let zone = "left";
|
|
1129
|
+
let distance = x;
|
|
1130
|
+
if (1 - x < distance) {
|
|
1131
|
+
zone = "right";
|
|
1132
|
+
distance = 1 - x;
|
|
1133
|
+
}
|
|
1134
|
+
if (y < distance) {
|
|
1135
|
+
zone = "top";
|
|
1136
|
+
distance = y;
|
|
1137
|
+
}
|
|
1138
|
+
if (1 - y < distance) {
|
|
1139
|
+
zone = "bottom";
|
|
1140
|
+
distance = 1 - y;
|
|
1141
|
+
}
|
|
1142
|
+
return distance < edge ? zone : "center";
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* How a dock region splits the pane it targets.
|
|
1146
|
+
* @param zone - the region the pointer released in.
|
|
1147
|
+
* @returns the split's axis and direction, or `undefined` for `'center'`, which moves the tab into the pane instead.
|
|
1148
|
+
*/
|
|
1149
|
+
function zoneSplit(zone) {
|
|
1150
|
+
switch (zone) {
|
|
1151
|
+
case "center": return;
|
|
1152
|
+
case "left": return {
|
|
1153
|
+
axis: "row",
|
|
1154
|
+
direction: "before"
|
|
1155
|
+
};
|
|
1156
|
+
case "right": return {
|
|
1157
|
+
axis: "row",
|
|
1158
|
+
direction: "after"
|
|
1159
|
+
};
|
|
1160
|
+
case "top": return {
|
|
1161
|
+
axis: "column",
|
|
1162
|
+
direction: "before"
|
|
1163
|
+
};
|
|
1164
|
+
case "bottom": return {
|
|
1165
|
+
axis: "column",
|
|
1166
|
+
direction: "after"
|
|
1167
|
+
};
|
|
1168
|
+
/* v8 ignore next -- closed-union backstop; the compiler rejects a new zone here. */
|
|
1169
|
+
default: return assertNever(zone, "layout: dock zone");
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Clamp divider sizes so no pane falls under `MIN_PANE_FRACTION`.
|
|
1174
|
+
* @param sizes - candidate fractions from the drag preview.
|
|
1175
|
+
* @param minimum - smallest allowed share; defaults to the kit's pane fraction.
|
|
1176
|
+
* @returns fractions summing to 1 with every entry at or above the minimum.
|
|
1177
|
+
*/
|
|
1178
|
+
function clampSizes(sizes, minimum = MIN_PANE_FRACTION) {
|
|
1179
|
+
if (sizes.length === 0) return [];
|
|
1180
|
+
const floor = Math.min(minimum, 1 / sizes.length);
|
|
1181
|
+
const positive = sizes.map((size) => size > 0 ? size : 0);
|
|
1182
|
+
const total = positive.reduce((sum, size) => sum + size, 0);
|
|
1183
|
+
let shares = total > 0 ? positive.map((size) => size / total) : positive.map(() => 1 / sizes.length);
|
|
1184
|
+
const pinned = /* @__PURE__ */ new Set();
|
|
1185
|
+
for (;;) {
|
|
1186
|
+
const under = shares.flatMap((share, index) => !pinned.has(index) && share < floor ? [index] : []);
|
|
1187
|
+
if (under.length === 0) return shares;
|
|
1188
|
+
for (const index of under) pinned.add(index);
|
|
1189
|
+
const remainder = 1 - pinned.size * floor;
|
|
1190
|
+
const freeTotal = shares.reduce((sum, share, index) => pinned.has(index) ? sum : sum + share, 0);
|
|
1191
|
+
shares = shares.map((share, index) => pinned.has(index) ? floor : share / freeTotal * remainder);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
//#endregion
|
|
1195
|
+
//#region lib/types/engine/geometry.js
|
|
1196
|
+
/**
|
|
1197
|
+
* Whether a point is inside a rectangle, edges included.
|
|
1198
|
+
* @param rect - the rectangle.
|
|
1199
|
+
* @param x - point x in the same coordinates.
|
|
1200
|
+
* @param y - point y in the same coordinates.
|
|
1201
|
+
* @returns whether the point lies on or inside the rectangle.
|
|
1202
|
+
*/
|
|
1203
|
+
function containsPoint(rect, x, y) {
|
|
1204
|
+
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Dock region a point falls in, relative to one pane's rectangle.
|
|
1208
|
+
* @param rect - the pane's measured box.
|
|
1209
|
+
* @param x - pointer x in the same coordinates.
|
|
1210
|
+
* @param y - pointer y in the same coordinates.
|
|
1211
|
+
* @param edge - edge band as a fraction; defaults to the model's value.
|
|
1212
|
+
* @returns the region; `'center'` when the point is not in an edge band.
|
|
1213
|
+
*/
|
|
1214
|
+
function zoneInRect(rect, x, y, edge = DOCK_EDGE_FRACTION) {
|
|
1215
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return "center";
|
|
1216
|
+
return zoneAt((x - rect.x) / rect.width, (y - rect.y) / rect.height, edge);
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Slot a tab would take in a strip, by comparing the pointer with each tab's midpoint.
|
|
1220
|
+
* @param tabRects - the strip's tab boxes in strip order.
|
|
1221
|
+
* @param x - pointer x.
|
|
1222
|
+
* @returns the insertion index, from 0 to `tabRects.length`.
|
|
1223
|
+
*/
|
|
1224
|
+
function insertionIndex(tabRects, x) {
|
|
1225
|
+
let index = 0;
|
|
1226
|
+
for (const rect of tabRects) {
|
|
1227
|
+
if (x < rect.x + rect.width / 2) break;
|
|
1228
|
+
index += 1;
|
|
1229
|
+
}
|
|
1230
|
+
return index;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* The minimums where no computed style can be read, mirroring
|
|
1234
|
+
* `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of
|
|
1235
|
+
* content plus 10px + 5px of padding (content-box), 59px; the body's 12px
|
|
1236
|
+
* padding above and below one 13px secondary line at 1.6 line-height is 45px,
|
|
1237
|
+
* held to 48px.
|
|
1238
|
+
*/
|
|
1239
|
+
const SPLIT_MINIMUMS = {
|
|
1240
|
+
divider: 4,
|
|
1241
|
+
chip: 59,
|
|
1242
|
+
body: 48
|
|
1243
|
+
};
|
|
1244
|
+
/**
|
|
1245
|
+
* The room rule. After an equal split each half must hold what cannot shrink:
|
|
1246
|
+
* horizontally the strip's fixed part — its width minus the chip box and the
|
|
1247
|
+
* fill, which is the padding, the gaps, and every control that pane draws —
|
|
1248
|
+
* plus one chip at its minimum; vertically the strip plus a minimum body. The
|
|
1249
|
+
* borders are what the pane's box exceeds the strip's by. An unmeasured pane
|
|
1250
|
+
* (no layout, as under jsdom) fits: the rule only blocks on a positive reading.
|
|
1251
|
+
* @param measure - the pane's rectangles.
|
|
1252
|
+
* @param minimums - the pixel minimums; defaults to the stylesheet's.
|
|
1253
|
+
* @returns whether a row and a column split each leave two working halves.
|
|
1254
|
+
*/
|
|
1255
|
+
function halvesFit(measure, minimums = SPLIT_MINIMUMS) {
|
|
1256
|
+
const { pane, strip } = measure;
|
|
1257
|
+
if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return {
|
|
1258
|
+
row: true,
|
|
1259
|
+
column: true
|
|
1260
|
+
};
|
|
1261
|
+
const borders = Math.max(0, pane.width - strip.width);
|
|
1262
|
+
const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth);
|
|
1263
|
+
const halfWidth = (pane.width - minimums.divider) / 2 - borders;
|
|
1264
|
+
const halfHeight = (pane.height - minimums.divider) / 2 - borders;
|
|
1265
|
+
return {
|
|
1266
|
+
row: halfWidth >= fixed + minimums.chip,
|
|
1267
|
+
column: halfHeight >= strip.height + minimums.body
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
/** How far a pointer must travel before a press becomes a drag, in pixels. */
|
|
1271
|
+
const DRAG_THRESHOLD = 4;
|
|
1272
|
+
/**
|
|
1273
|
+
* Whether a press has travelled far enough to be a drag.
|
|
1274
|
+
* @param startX - press x.
|
|
1275
|
+
* @param startY - press y.
|
|
1276
|
+
* @param x - current pointer x.
|
|
1277
|
+
* @param y - current pointer y.
|
|
1278
|
+
* @returns whether either axis moved at least `DRAG_THRESHOLD`.
|
|
1279
|
+
*/
|
|
1280
|
+
function passedThreshold(startX, startY, x, y) {
|
|
1281
|
+
return Math.abs(x - startX) >= 4 || Math.abs(y - startY) >= 4;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Split fractions after a divider drag.
|
|
1285
|
+
* @param sizes - the split's current fractions.
|
|
1286
|
+
* @param index - divider position: the boundary between `index` and `index + 1`.
|
|
1287
|
+
* @param delta - pointer travel along the split axis, as a fraction of the split's extent.
|
|
1288
|
+
* @returns new fractions; the two neighbours absorb the whole change.
|
|
1289
|
+
*/
|
|
1290
|
+
function dividerSizes(sizes, index, delta) {
|
|
1291
|
+
const before = sizes[index];
|
|
1292
|
+
const after = sizes[index + 1];
|
|
1293
|
+
if (before === void 0 || after === void 0) return [...sizes];
|
|
1294
|
+
const next = [...sizes];
|
|
1295
|
+
next[index] = before + delta;
|
|
1296
|
+
next[index + 1] = after - delta;
|
|
1297
|
+
return next;
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* A floating panel's rectangle after a drag.
|
|
1301
|
+
* @param rect - the rectangle the gesture started from.
|
|
1302
|
+
* @param dx - pointer travel on x.
|
|
1303
|
+
* @param dy - pointer travel on y.
|
|
1304
|
+
* @returns the moved rectangle; the size is unchanged.
|
|
1305
|
+
*/
|
|
1306
|
+
function movedRect(rect, dx, dy) {
|
|
1307
|
+
return {
|
|
1308
|
+
...rect,
|
|
1309
|
+
x: rect.x + dx,
|
|
1310
|
+
y: rect.y + dy
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* A floating panel's rectangle after a bottom-right resize.
|
|
1315
|
+
* @param rect - the rectangle the gesture started from.
|
|
1316
|
+
* @param dx - pointer travel on x.
|
|
1317
|
+
* @param dy - pointer travel on y.
|
|
1318
|
+
* @param min - smallest size the panel may take.
|
|
1319
|
+
* @returns the resized rectangle; the origin is unchanged.
|
|
1320
|
+
*/
|
|
1321
|
+
function resizedRect(rect, dx, dy, min) {
|
|
1322
|
+
return {
|
|
1323
|
+
...rect,
|
|
1324
|
+
width: Math.max(min.width, rect.width + dx),
|
|
1325
|
+
height: Math.max(min.height, rect.height + dy)
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Where a panel should appear when a tab is dropped outside the docked area.
|
|
1330
|
+
* @param x - drop point x.
|
|
1331
|
+
* @param y - drop point y.
|
|
1332
|
+
* @param size - the panel's size.
|
|
1333
|
+
* @returns a rectangle whose header sits under the drop point.
|
|
1334
|
+
*/
|
|
1335
|
+
function floatRectAt(x, y, size) {
|
|
1336
|
+
return {
|
|
1337
|
+
x: Math.max(0, x - GRAB_OFFSET.x),
|
|
1338
|
+
y: Math.max(0, y - GRAB_OFFSET.y),
|
|
1339
|
+
...size
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/** How far the new panel's origin sits above and left of the drop point. */
|
|
1343
|
+
const GRAB_OFFSET = {
|
|
1344
|
+
x: 60,
|
|
1345
|
+
y: 14
|
|
1346
|
+
};
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region lib/types/engine/planner.js
|
|
1349
|
+
/** Distance each newly floated panel steps down and right from the last. */
|
|
1350
|
+
const FLOAT_CASCADE_STEP = 24;
|
|
1351
|
+
/** Where the first floating panel appears, in viewport pixels. */
|
|
1352
|
+
const FLOAT_ORIGIN = {
|
|
1353
|
+
x: 160,
|
|
1354
|
+
y: 120
|
|
1355
|
+
};
|
|
1356
|
+
/** No operations: the intent is a no-op against this state. */
|
|
1357
|
+
const NOTHING = [];
|
|
1358
|
+
/**
|
|
1359
|
+
* First tab in one pane carrying `contentId`, in strip order.
|
|
1360
|
+
* @param state - current layout.
|
|
1361
|
+
* @param paneId - the pane to search, docked or floating.
|
|
1362
|
+
* @param contentId - the content identity.
|
|
1363
|
+
* @param kind - restrict to tabs of this kind; omit to match any kind.
|
|
1364
|
+
* @returns the tab, or `undefined` when that pane shows no such content.
|
|
1365
|
+
*/
|
|
1366
|
+
function findPaneContentTab(state, paneId, contentId, kind) {
|
|
1367
|
+
for (const tabId of getPane(state, paneId).tabs) {
|
|
1368
|
+
const tab = state.tabs[tabId];
|
|
1369
|
+
if (tab?.contentId === contentId && (kind === void 0 || tab.kind === kind)) return tabId;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* First tab carrying `contentId`, searched docked panes first, in visual order.
|
|
1374
|
+
* @param state - current layout.
|
|
1375
|
+
* @param contentId - the content identity.
|
|
1376
|
+
* @param kind - restrict to tabs of this kind; omit to match any kind.
|
|
1377
|
+
* @returns the tab, or `undefined` when nothing shows the content.
|
|
1378
|
+
*/
|
|
1379
|
+
function findContentTab(state, contentId, kind) {
|
|
1380
|
+
for (const paneId of [...dockPaneIds(state), ...state.floats]) {
|
|
1381
|
+
const found = findPaneContentTab(state, paneId, contentId, kind);
|
|
1382
|
+
if (found !== void 0) return found;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* The pane a new tab lands in.
|
|
1387
|
+
* @param state - current layout.
|
|
1388
|
+
* @returns the active pane when docked, else the first docked pane.
|
|
1389
|
+
*/
|
|
1390
|
+
function activeDockPaneId(state) {
|
|
1391
|
+
const active = getPane(state, state.activePaneId);
|
|
1392
|
+
return active.host === "dock" ? active.id : firstDockPaneId(state);
|
|
1393
|
+
}
|
|
1394
|
+
/** Send a tab into a docked pane, choosing the operation its current host needs. */
|
|
1395
|
+
function tabInto(source, tabId, toPaneId, index) {
|
|
1396
|
+
return source.host === "float" ? {
|
|
1397
|
+
type: "unfloat",
|
|
1398
|
+
paneId: source.id,
|
|
1399
|
+
toPaneId,
|
|
1400
|
+
index
|
|
1401
|
+
} : {
|
|
1402
|
+
type: "moveTab",
|
|
1403
|
+
tabId,
|
|
1404
|
+
toPaneId,
|
|
1405
|
+
index
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Expand or collapse the docked area.
|
|
1410
|
+
* @param state - current layout.
|
|
1411
|
+
* @param expanded - whether the docked area is shown.
|
|
1412
|
+
* @returns the operation, or none when the value is already current.
|
|
1413
|
+
*/
|
|
1414
|
+
function planSetExpanded(state, expanded) {
|
|
1415
|
+
return state.expanded === expanded ? NOTHING : [{
|
|
1416
|
+
type: "setExpanded",
|
|
1417
|
+
expanded
|
|
1418
|
+
}];
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Switch the presentation.
|
|
1422
|
+
* @param state - current layout.
|
|
1423
|
+
* @param mode - the presentation to record.
|
|
1424
|
+
* @returns the operation, or none when the value is already current.
|
|
1425
|
+
*/
|
|
1426
|
+
function planSetMode(state, mode) {
|
|
1427
|
+
return state.mode === mode ? NOTHING : [{
|
|
1428
|
+
type: "setMode",
|
|
1429
|
+
mode
|
|
1430
|
+
}];
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Split a pane to its right and seed the new pane.
|
|
1434
|
+
* @param state - current layout.
|
|
1435
|
+
* @param mint - id source for the pane, split, and seeded tab.
|
|
1436
|
+
* @param paneId - pane to split; defaults to the active docked pane.
|
|
1437
|
+
* @param makePaneTab - builds the seeded tab; omit to leave the new pane empty.
|
|
1438
|
+
* @returns the operations, or none when the pane budget is spent.
|
|
1439
|
+
*/
|
|
1440
|
+
function planSplitPane(state, mint, paneId, makePaneTab) {
|
|
1441
|
+
if (!canSplit(state)) return NOTHING;
|
|
1442
|
+
const target = paneId ?? activeDockPaneId(state);
|
|
1443
|
+
if (getPane(state, target).host !== "dock") return NOTHING;
|
|
1444
|
+
const newPaneId = mint("pane");
|
|
1445
|
+
const ops = [{
|
|
1446
|
+
type: "split",
|
|
1447
|
+
paneId: target,
|
|
1448
|
+
axis: "row",
|
|
1449
|
+
direction: "after",
|
|
1450
|
+
newPaneId,
|
|
1451
|
+
newSplitId: mint("split")
|
|
1452
|
+
}];
|
|
1453
|
+
const seed = makePaneTab?.(mint("tab"));
|
|
1454
|
+
if (seed !== void 0) ops.push({
|
|
1455
|
+
type: "openTab",
|
|
1456
|
+
paneId: newPaneId,
|
|
1457
|
+
tab: seed,
|
|
1458
|
+
index: 0
|
|
1459
|
+
});
|
|
1460
|
+
return ops;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Seat the embedder's seeded tab at the end of a docked pane's strip.
|
|
1464
|
+
* @param state - current layout.
|
|
1465
|
+
* @param mint - id source for the new tab.
|
|
1466
|
+
* @param paneId - the pane whose strip asked; must be docked.
|
|
1467
|
+
* @param makeTab - builds the seeded tab; omit to plan nothing.
|
|
1468
|
+
* @returns the operations, or none when there is nothing to seat.
|
|
1469
|
+
*/
|
|
1470
|
+
function planAddTab(state, mint, paneId, makeTab) {
|
|
1471
|
+
if (makeTab === void 0) return NOTHING;
|
|
1472
|
+
const pane = getPane(state, paneId);
|
|
1473
|
+
if (pane.host !== "dock") return NOTHING;
|
|
1474
|
+
return [{
|
|
1475
|
+
type: "openTab",
|
|
1476
|
+
paneId,
|
|
1477
|
+
tab: makeTab(mint("tab")),
|
|
1478
|
+
index: pane.tabs.length
|
|
1479
|
+
}];
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Open content, or focus the tab already showing it.
|
|
1483
|
+
* @param state - current layout.
|
|
1484
|
+
* @param mint - id source for a newly opened tab.
|
|
1485
|
+
* @param input - identity, copy, and optional placement.
|
|
1486
|
+
* @returns the operations plus the tab they settle on.
|
|
1487
|
+
*/
|
|
1488
|
+
function planOpenContent(state, mint, input) {
|
|
1489
|
+
const existing = input.revealIfOpened === false ? void 0 : findContentTab(state, input.contentId, input.kind);
|
|
1490
|
+
if (existing !== void 0) return {
|
|
1491
|
+
ops: [{
|
|
1492
|
+
type: "focusTab",
|
|
1493
|
+
tabId: existing
|
|
1494
|
+
}],
|
|
1495
|
+
tabId: existing
|
|
1496
|
+
};
|
|
1497
|
+
const paneId = input.paneId ?? activeDockPaneId(state);
|
|
1498
|
+
const tab = {
|
|
1499
|
+
id: mint("tab"),
|
|
1500
|
+
kind: input.kind,
|
|
1501
|
+
contentId: input.contentId,
|
|
1502
|
+
title: input.title
|
|
1503
|
+
};
|
|
1504
|
+
return {
|
|
1505
|
+
ops: [{
|
|
1506
|
+
type: "openTab",
|
|
1507
|
+
paneId,
|
|
1508
|
+
tab,
|
|
1509
|
+
index: input.index ?? getPane(state, paneId).tabs.length
|
|
1510
|
+
}],
|
|
1511
|
+
tabId: tab.id
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Open a second, independent tab on the same content, beside the original.
|
|
1516
|
+
* @param state - current layout.
|
|
1517
|
+
* @param mint - id source for the copy.
|
|
1518
|
+
* @param tabId - tab to copy.
|
|
1519
|
+
* @returns the operations plus the new tab's id.
|
|
1520
|
+
*/
|
|
1521
|
+
function planDuplicateTab(state, mint, tabId) {
|
|
1522
|
+
const source = getTab(state, tabId);
|
|
1523
|
+
const pane = findTabPane(state, tabId);
|
|
1524
|
+
const host = pane.host === "dock" ? pane.id : activeDockPaneId(state);
|
|
1525
|
+
const index = pane.host === "dock" ? pane.tabs.indexOf(tabId) + 1 : getPane(state, host).tabs.length;
|
|
1526
|
+
const tab = {
|
|
1527
|
+
...source,
|
|
1528
|
+
id: mint("tab")
|
|
1529
|
+
};
|
|
1530
|
+
return {
|
|
1531
|
+
ops: [{
|
|
1532
|
+
type: "openTab",
|
|
1533
|
+
paneId: host,
|
|
1534
|
+
tab,
|
|
1535
|
+
index
|
|
1536
|
+
}],
|
|
1537
|
+
tabId: tab.id
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Put a tab at an explicit strip slot: a reorder inside its own pane, otherwise a
|
|
1542
|
+
* move, or a return when it currently floats.
|
|
1543
|
+
* @param state - current layout.
|
|
1544
|
+
* @param tabId - the tab being placed.
|
|
1545
|
+
* @param toPaneId - destination docked pane.
|
|
1546
|
+
* @param index - caret slot in the destination strip, counted over the chips as
|
|
1547
|
+
* drawn — the dragged chip included when the destination is its own pane, so
|
|
1548
|
+
* the slot just before or just after it is where it already sits.
|
|
1549
|
+
* @returns the operations, or none when the placement changes nothing.
|
|
1550
|
+
*/
|
|
1551
|
+
function planPlaceTab(state, tabId, toPaneId, index) {
|
|
1552
|
+
const source = findTabPane(state, tabId);
|
|
1553
|
+
if (getPane(state, toPaneId).host !== "dock") return NOTHING;
|
|
1554
|
+
if (source.id === toPaneId) {
|
|
1555
|
+
const from = source.tabs.indexOf(tabId);
|
|
1556
|
+
const to = index > from ? index - 1 : index;
|
|
1557
|
+
return to === from ? NOTHING : [{
|
|
1558
|
+
type: "reorderTab",
|
|
1559
|
+
tabId,
|
|
1560
|
+
index: to
|
|
1561
|
+
}];
|
|
1562
|
+
}
|
|
1563
|
+
return [tabInto(source, tabId, toPaneId, index)];
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Resolve a tab release on a pane body: the centre moves the tab in, an edge
|
|
1567
|
+
* splits the pane and seats the tab in the new half. A pane's only tab released
|
|
1568
|
+
* on that pane changes nothing in either zone: the split would empty the pane
|
|
1569
|
+
* and seat the tab beside where it already was.
|
|
1570
|
+
* @param state - current layout.
|
|
1571
|
+
* @param mint - id source for a pane an edge release creates.
|
|
1572
|
+
* @param tabId - the dragged tab.
|
|
1573
|
+
* @param targetPaneId - pane under the pointer.
|
|
1574
|
+
* @param zone - dock region the pointer released in.
|
|
1575
|
+
* @returns the operations, or none when the release changes nothing.
|
|
1576
|
+
*/
|
|
1577
|
+
function planDropTab(state, mint, tabId, targetPaneId, zone) {
|
|
1578
|
+
const source = findTabPane(state, tabId);
|
|
1579
|
+
const target = getPane(state, targetPaneId);
|
|
1580
|
+
if (target.host !== "dock") return NOTHING;
|
|
1581
|
+
const split = zoneSplit(zone);
|
|
1582
|
+
if (split === void 0) {
|
|
1583
|
+
if (source.id === targetPaneId) return NOTHING;
|
|
1584
|
+
return [tabInto(source, tabId, targetPaneId, target.tabs.length)];
|
|
1585
|
+
}
|
|
1586
|
+
if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING;
|
|
1587
|
+
if (!canSplit(state)) return NOTHING;
|
|
1588
|
+
const newPaneId = mint("pane");
|
|
1589
|
+
return [{
|
|
1590
|
+
type: "split",
|
|
1591
|
+
paneId: targetPaneId,
|
|
1592
|
+
axis: split.axis,
|
|
1593
|
+
direction: split.direction,
|
|
1594
|
+
newPaneId,
|
|
1595
|
+
newSplitId: mint("split")
|
|
1596
|
+
}, tabInto(source, tabId, newPaneId, 0)];
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Take a tab out into a floating panel.
|
|
1600
|
+
* @param state - current layout.
|
|
1601
|
+
* @param mint - id source for the floating pane.
|
|
1602
|
+
* @param tabId - tab to float.
|
|
1603
|
+
* @param rect - explicit rectangle; defaults to a cascade from the last panel.
|
|
1604
|
+
* @returns the operations plus the floating pane's id.
|
|
1605
|
+
*/
|
|
1606
|
+
function planFloatTab(state, mint, tabId, rect) {
|
|
1607
|
+
const step = state.floats.length * FLOAT_CASCADE_STEP;
|
|
1608
|
+
const newPaneId = mint("float");
|
|
1609
|
+
return {
|
|
1610
|
+
ops: [{
|
|
1611
|
+
type: "float",
|
|
1612
|
+
tabId,
|
|
1613
|
+
newPaneId,
|
|
1614
|
+
rect: rect ?? {
|
|
1615
|
+
x: FLOAT_ORIGIN.x + step,
|
|
1616
|
+
y: FLOAT_ORIGIN.y + step,
|
|
1617
|
+
width: FLOAT_DEFAULT_SIZE.width,
|
|
1618
|
+
height: FLOAT_DEFAULT_SIZE.height
|
|
1619
|
+
}
|
|
1620
|
+
}],
|
|
1621
|
+
paneId: newPaneId
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Send a floating panel's tab back into the docked tree.
|
|
1626
|
+
* @param state - current layout.
|
|
1627
|
+
* @param paneId - the floating pane.
|
|
1628
|
+
* @param toPaneId - destination docked pane; defaults to the active one.
|
|
1629
|
+
* @returns the operations.
|
|
1630
|
+
*/
|
|
1631
|
+
function planUnfloatPane(state, paneId, toPaneId) {
|
|
1632
|
+
const destination = toPaneId ?? activeDockPaneId(state);
|
|
1633
|
+
return [{
|
|
1634
|
+
type: "unfloat",
|
|
1635
|
+
paneId,
|
|
1636
|
+
toPaneId: destination,
|
|
1637
|
+
index: getPane(state, destination).tabs.length
|
|
1638
|
+
}];
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Record the net sizes of a divider drag, clamped to the pane minimum.
|
|
1642
|
+
* @param splitId - the split whose divider moved.
|
|
1643
|
+
* @param sizes - the fractions the drag reached.
|
|
1644
|
+
* @param minimum - smallest pane share; defaults to the kit's fraction.
|
|
1645
|
+
* @returns the resize operation.
|
|
1646
|
+
*/
|
|
1647
|
+
function planResizeSplit(splitId, sizes, minimum) {
|
|
1648
|
+
return [{
|
|
1649
|
+
type: "resize",
|
|
1650
|
+
splitId,
|
|
1651
|
+
sizes: clampSizes(sizes, minimum)
|
|
1652
|
+
}];
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Keep the docked area populated after an intent: drop every docked pane the
|
|
1656
|
+
* intent left empty, and when the surviving root pane is itself empty, seed it.
|
|
1657
|
+
*
|
|
1658
|
+
* A pane empties when its last tab is closed, moved out, or floated; each such
|
|
1659
|
+
* pane is merged away, innermost first, until none remains. The root pane cannot
|
|
1660
|
+
* be merged, so it is reseeded instead — with the factory's tab, or left empty
|
|
1661
|
+
* when the embedder supplies none. The returned operations continue the intent
|
|
1662
|
+
* they follow, so a caller records both as one entry.
|
|
1663
|
+
* @param state - the layout after the intent's own operations.
|
|
1664
|
+
* @param mint - id source for the reseeded tab.
|
|
1665
|
+
* @param makeTab - builds the tab an emptied root pane is reseeded with.
|
|
1666
|
+
* @returns the follow-up operations, or none when every docked pane holds a tab.
|
|
1667
|
+
*/
|
|
1668
|
+
function planSettle(state, mint, makeTab) {
|
|
1669
|
+
const ops = [];
|
|
1670
|
+
let current = state;
|
|
1671
|
+
for (;;) {
|
|
1672
|
+
const emptied = dockPaneIds(current).find((id) => id !== current.rootId && getPane(current, id).tabs.length === 0);
|
|
1673
|
+
if (emptied === void 0) break;
|
|
1674
|
+
const merge = {
|
|
1675
|
+
type: "merge",
|
|
1676
|
+
paneId: emptied
|
|
1677
|
+
};
|
|
1678
|
+
ops.push(merge);
|
|
1679
|
+
current = applyOp(current, merge).state;
|
|
1680
|
+
}
|
|
1681
|
+
const root = getNode(current, current.rootId);
|
|
1682
|
+
if (root.kind === "pane" && root.tabs.length === 0 && makeTab !== void 0) ops.push({
|
|
1683
|
+
type: "openTab",
|
|
1684
|
+
paneId: root.id,
|
|
1685
|
+
tab: makeTab(mint("tab")),
|
|
1686
|
+
index: 0
|
|
1687
|
+
});
|
|
1688
|
+
return ops;
|
|
1689
|
+
}
|
|
1690
|
+
//#endregion
|
|
1691
|
+
//#region lib/types/engine/initial.js
|
|
1692
|
+
/**
|
|
1693
|
+
* Create an id source.
|
|
1694
|
+
* @param seed - number the first id counts from; defaults to 0.
|
|
1695
|
+
* @returns a minter producing `<prefix><n>` ids.
|
|
1696
|
+
*/
|
|
1697
|
+
function createIdMinter(seed = 0) {
|
|
1698
|
+
let counter = seed;
|
|
1699
|
+
const next = ((prefix) => {
|
|
1700
|
+
counter += 1;
|
|
1701
|
+
return `${prefix}${counter}`;
|
|
1702
|
+
});
|
|
1703
|
+
return { next };
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The state a surface starts in: collapsed, one docked pane, and whatever tab
|
|
1707
|
+
* `makeInitialTab` supplies.
|
|
1708
|
+
*
|
|
1709
|
+
* The first tab belongs to the initial state rather than to an operation, so
|
|
1710
|
+
* expanding and collapsing never accumulates copies of it.
|
|
1711
|
+
* @param minter - id source this surface's sequence will keep using.
|
|
1712
|
+
* @param makeInitialTab - builds the starting tab; omit for an empty pane.
|
|
1713
|
+
* @param mode - starting presentation; the embedder's product default.
|
|
1714
|
+
* @returns the collapsed single-pane starting state.
|
|
1715
|
+
*/
|
|
1716
|
+
function createInitialState(minter, makeInitialTab, mode = "push") {
|
|
1717
|
+
const paneId = minter.next("pane");
|
|
1718
|
+
const initial = makeInitialTab?.(minter.next("tab"));
|
|
1719
|
+
return {
|
|
1720
|
+
nodes: { [paneId]: {
|
|
1721
|
+
kind: "pane",
|
|
1722
|
+
id: paneId,
|
|
1723
|
+
host: "dock",
|
|
1724
|
+
tabs: initial === void 0 ? [] : [initial.id],
|
|
1725
|
+
activeTabId: initial?.id,
|
|
1726
|
+
rect: void 0
|
|
1727
|
+
} },
|
|
1728
|
+
tabs: initial === void 0 ? {} : { [initial.id]: initial },
|
|
1729
|
+
rootId: paneId,
|
|
1730
|
+
floats: [],
|
|
1731
|
+
activePaneId: paneId,
|
|
1732
|
+
expanded: false,
|
|
1733
|
+
mode
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
//#endregion
|
|
1737
|
+
//#region lib/types/engine/controller.js
|
|
1738
|
+
/** One docking surface: history, interaction limits, and change notification. */
|
|
1739
|
+
var DockController = class {
|
|
1740
|
+
minter;
|
|
1741
|
+
sequencer;
|
|
1742
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1743
|
+
makePaneTab;
|
|
1744
|
+
snapshot;
|
|
1745
|
+
/** @param options - the tab factories this surface seeds panes with. */
|
|
1746
|
+
constructor(options = {}) {
|
|
1747
|
+
this.minter = createIdMinter();
|
|
1748
|
+
this.makePaneTab = options.makePaneTab;
|
|
1749
|
+
this.sequencer = new Sequencer(createInitialState(this.minter, options.makeInitialTab, options.mode));
|
|
1750
|
+
this.snapshot = this.buildSnapshot();
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Observe layout changes.
|
|
1754
|
+
* @param listener - called after every committed change.
|
|
1755
|
+
* @returns disposer removing the listener.
|
|
1756
|
+
*/
|
|
1757
|
+
subscribe = (listener) => {
|
|
1758
|
+
this.listeners.add(listener);
|
|
1759
|
+
return () => {
|
|
1760
|
+
this.listeners.delete(listener);
|
|
1761
|
+
};
|
|
1762
|
+
};
|
|
1763
|
+
/** Current snapshot; the same reference until the layout changes. */
|
|
1764
|
+
getSnapshot = () => this.snapshot;
|
|
1765
|
+
/** Recorded sequence, for tests and the operation readout. */
|
|
1766
|
+
get ops() {
|
|
1767
|
+
return this.sequencer.ops;
|
|
1768
|
+
}
|
|
1769
|
+
buildSnapshot() {
|
|
1770
|
+
const state = this.sequencer.state;
|
|
1771
|
+
return {
|
|
1772
|
+
state,
|
|
1773
|
+
canUndo: this.sequencer.canUndo,
|
|
1774
|
+
canRedo: this.sequencer.canRedo,
|
|
1775
|
+
canSplit: canSplit(state),
|
|
1776
|
+
opCount: this.sequencer.ops.length,
|
|
1777
|
+
cursor: this.sequencer.cursor
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
commit() {
|
|
1781
|
+
this.snapshot = this.buildSnapshot();
|
|
1782
|
+
for (const listener of [...this.listeners]) listener();
|
|
1783
|
+
}
|
|
1784
|
+
get state() {
|
|
1785
|
+
return this.sequencer.state;
|
|
1786
|
+
}
|
|
1787
|
+
get mint() {
|
|
1788
|
+
return this.minter.next;
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Record a planned intent as one history entry.
|
|
1792
|
+
* @param ops - the planner's operations; empty plans nothing.
|
|
1793
|
+
* @returns whether anything was recorded.
|
|
1794
|
+
*/
|
|
1795
|
+
run(ops) {
|
|
1796
|
+
if (ops.length === 0) return false;
|
|
1797
|
+
this.sequencer.dispatchAll(ops);
|
|
1798
|
+
this.commit();
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Expand or collapse the docked area. Floating panels are unaffected.
|
|
1803
|
+
* @param expanded - whether the docked area is shown.
|
|
1804
|
+
*/
|
|
1805
|
+
setExpanded(expanded) {
|
|
1806
|
+
this.run(planSetExpanded(this.state, expanded));
|
|
1807
|
+
}
|
|
1808
|
+
/** Flip the docked area between expanded and collapsed. */
|
|
1809
|
+
toggleExpanded() {
|
|
1810
|
+
this.setExpanded(!this.state.expanded);
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Switch how the docked area is presented.
|
|
1814
|
+
* @param mode - the presentation to record.
|
|
1815
|
+
*/
|
|
1816
|
+
setMode(mode) {
|
|
1817
|
+
this.run(planSetMode(this.state, mode));
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Split a pane to its right and seat the embedder's pane tab in the new pane.
|
|
1821
|
+
* @param paneId - pane to split; defaults to the active docked pane.
|
|
1822
|
+
* @returns false when the docked grid is already at `MAX_DOCK_PANES`.
|
|
1823
|
+
*/
|
|
1824
|
+
splitPane(paneId) {
|
|
1825
|
+
return this.run(planSplitPane(this.state, this.mint, paneId, this.makePaneTab));
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Seat the pane-tab factory's tab at the end of a pane's strip.
|
|
1829
|
+
* @param paneId - the docked pane whose strip asked.
|
|
1830
|
+
* @returns false when there is no factory or the pane is not docked.
|
|
1831
|
+
*/
|
|
1832
|
+
addTab(paneId) {
|
|
1833
|
+
return this.run(planAddTab(this.state, this.mint, paneId, this.makePaneTab));
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Open content, or focus the tab already showing it.
|
|
1837
|
+
* @param input - consistency id, copy, and optional target pane.
|
|
1838
|
+
* @returns the tab now focused.
|
|
1839
|
+
*/
|
|
1840
|
+
openContent(input) {
|
|
1841
|
+
const planned = planOpenContent(this.state, this.mint, input);
|
|
1842
|
+
this.run(planned.ops);
|
|
1843
|
+
return planned.tabId;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* Open a second, independent tab on the same content.
|
|
1847
|
+
* @param tabId - tab to copy.
|
|
1848
|
+
* @returns the new tab id.
|
|
1849
|
+
*/
|
|
1850
|
+
duplicateTab(tabId) {
|
|
1851
|
+
const planned = planDuplicateTab(this.state, this.mint, tabId);
|
|
1852
|
+
this.run(planned.ops);
|
|
1853
|
+
return planned.tabId;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Destroy a tab and its content state. A floating host panel goes with it.
|
|
1857
|
+
* @param tabId - the tab to close.
|
|
1858
|
+
*/
|
|
1859
|
+
closeTab(tabId) {
|
|
1860
|
+
this.run([{
|
|
1861
|
+
type: "closeTab",
|
|
1862
|
+
tabId
|
|
1863
|
+
}]);
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Focus a tab, its pane, and raise that pane when it floats.
|
|
1867
|
+
* @param tabId - the tab to focus.
|
|
1868
|
+
*/
|
|
1869
|
+
focusTab(tabId) {
|
|
1870
|
+
this.run([{
|
|
1871
|
+
type: "focusTab",
|
|
1872
|
+
tabId
|
|
1873
|
+
}]);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Focus a pane, raising it when it floats.
|
|
1877
|
+
* @param paneId - the pane to focus.
|
|
1878
|
+
*/
|
|
1879
|
+
focusPane(paneId) {
|
|
1880
|
+
this.run([{
|
|
1881
|
+
type: "focusPane",
|
|
1882
|
+
paneId
|
|
1883
|
+
}]);
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Move a tab inside its own pane.
|
|
1887
|
+
* @param tabId - the tab to move.
|
|
1888
|
+
* @param index - its position in the strip without it.
|
|
1889
|
+
*/
|
|
1890
|
+
reorderTab(tabId, index) {
|
|
1891
|
+
this.run([{
|
|
1892
|
+
type: "reorderTab",
|
|
1893
|
+
tabId,
|
|
1894
|
+
index
|
|
1895
|
+
}]);
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Put a tab at an explicit slot: a reorder inside its own pane, otherwise a
|
|
1899
|
+
* move (or a return, when it currently floats).
|
|
1900
|
+
* @param tabId - the tab being placed.
|
|
1901
|
+
* @param toPaneId - destination docked pane.
|
|
1902
|
+
* @param index - caret slot in the destination strip, counting the dragged chip when the strip is its own.
|
|
1903
|
+
* @returns false when the placement changes nothing.
|
|
1904
|
+
*/
|
|
1905
|
+
placeTab(tabId, toPaneId, index) {
|
|
1906
|
+
return this.run(planPlaceTab(this.state, tabId, toPaneId, index));
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Resolve a tab drop inside the docked area.
|
|
1910
|
+
* @param tabId - the dragged tab.
|
|
1911
|
+
* @param targetPaneId - pane under the pointer.
|
|
1912
|
+
* @param zone - dock region the pointer released in.
|
|
1913
|
+
* @returns false when the drop changes nothing or the grid is full.
|
|
1914
|
+
*/
|
|
1915
|
+
dropTab(tabId, targetPaneId, zone) {
|
|
1916
|
+
return this.run(planDropTab(this.state, this.mint, tabId, targetPaneId, zone));
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Take a tab out into a floating panel.
|
|
1920
|
+
* @param tabId - tab to float.
|
|
1921
|
+
* @param rect - explicit rectangle; defaults to a cascade from the last panel.
|
|
1922
|
+
* @returns the new floating pane id.
|
|
1923
|
+
*/
|
|
1924
|
+
floatTab(tabId, rect) {
|
|
1925
|
+
const planned = planFloatTab(this.state, this.mint, tabId, rect);
|
|
1926
|
+
this.run(planned.ops);
|
|
1927
|
+
return planned.paneId;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Send a floating panel's tab back into the docked tree.
|
|
1931
|
+
* @param paneId - the floating pane.
|
|
1932
|
+
* @param toPaneId - destination docked pane; defaults to the active one.
|
|
1933
|
+
*/
|
|
1934
|
+
unfloatPane(paneId, toPaneId) {
|
|
1935
|
+
this.run(planUnfloatPane(this.state, paneId, toPaneId));
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Record the net position of a floating-panel drag; the panel is focused and raised with it.
|
|
1939
|
+
* @param paneId - the floating pane.
|
|
1940
|
+
* @param x - its new left edge, in viewport pixels.
|
|
1941
|
+
* @param y - its new top edge, in viewport pixels.
|
|
1942
|
+
*/
|
|
1943
|
+
moveFloat(paneId, x, y) {
|
|
1944
|
+
this.run([{
|
|
1945
|
+
type: "moveFloat",
|
|
1946
|
+
paneId,
|
|
1947
|
+
x,
|
|
1948
|
+
y
|
|
1949
|
+
}]);
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Record the net rectangle of a floating-panel resize; the panel is focused and raised with it.
|
|
1953
|
+
* @param paneId - the floating pane.
|
|
1954
|
+
* @param rect - its new rectangle.
|
|
1955
|
+
*/
|
|
1956
|
+
resizeFloat(paneId, rect) {
|
|
1957
|
+
this.run([{
|
|
1958
|
+
type: "resizeFloat",
|
|
1959
|
+
paneId,
|
|
1960
|
+
rect
|
|
1961
|
+
}]);
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Record the net sizes of a divider drag, clamped to the pane minimum.
|
|
1965
|
+
* @param splitId - the split whose divider moved.
|
|
1966
|
+
* @param sizes - the fractions the drag reached.
|
|
1967
|
+
*/
|
|
1968
|
+
resizeSplit(splitId, sizes) {
|
|
1969
|
+
this.run(planResizeSplit(splitId, sizes));
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Step back one intent, or one run of consecutive focus-only intents.
|
|
1973
|
+
* @returns false when there is nothing to undo.
|
|
1974
|
+
*/
|
|
1975
|
+
undo() {
|
|
1976
|
+
if (!this.sequencer.undo()) return false;
|
|
1977
|
+
this.commit();
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Step forward over what the matching undo stepped back.
|
|
1982
|
+
* @returns false when there is nothing to redo.
|
|
1983
|
+
*/
|
|
1984
|
+
redo() {
|
|
1985
|
+
if (!this.sequencer.redo()) return false;
|
|
1986
|
+
this.commit();
|
|
1987
|
+
return true;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* The pane a new tab lands in, for an embedder that needs to name it.
|
|
1991
|
+
* @returns the active pane when docked, else the first docked pane.
|
|
1992
|
+
*/
|
|
1993
|
+
activeDockPaneId() {
|
|
1994
|
+
return activeDockPaneId(this.state);
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
//#endregion
|
|
1998
|
+
//#region lib/types/components/measure.js
|
|
1999
|
+
const NO_RECT = {
|
|
2000
|
+
x: 0,
|
|
2001
|
+
y: 0,
|
|
2002
|
+
width: 0,
|
|
2003
|
+
height: 0
|
|
2004
|
+
};
|
|
2005
|
+
/** What an unmeasured pane is taken to be: fitting, until a reading says otherwise. */
|
|
2006
|
+
const UNMEASURED = {
|
|
2007
|
+
row: true,
|
|
2008
|
+
column: true
|
|
2009
|
+
};
|
|
2010
|
+
function rectOf(element) {
|
|
2011
|
+
return element === null ? NO_RECT : element.getBoundingClientRect();
|
|
2012
|
+
}
|
|
2013
|
+
function px(value) {
|
|
2014
|
+
const parsed = Number.parseFloat(value);
|
|
2015
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Every docked pane element under `root`, in document order, with the pane id
|
|
2019
|
+
* each carries.
|
|
2020
|
+
* @param root - the docked surface's element.
|
|
2021
|
+
* @returns pane ids paired with their elements.
|
|
2022
|
+
*/
|
|
2023
|
+
function paneElements(root) {
|
|
2024
|
+
const panes = [];
|
|
2025
|
+
for (const pane of root.querySelectorAll("[data-dockkit-pane]")) {
|
|
2026
|
+
const paneId = pane.dataset.dockkitPane;
|
|
2027
|
+
/* v8 ignore next -- the selector admits only elements carrying the attribute. */
|
|
2028
|
+
if (paneId === void 0) continue;
|
|
2029
|
+
panes.push([paneId, pane]);
|
|
2030
|
+
}
|
|
2031
|
+
return panes;
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* One chip's minimum footprint from a rendered chip's computed style; the
|
|
2035
|
+
* stylesheet fallback where none is rendered or styles are not applied.
|
|
2036
|
+
*/
|
|
2037
|
+
function chipMinimum(root) {
|
|
2038
|
+
const chip = root.querySelector("[data-dockkit-tab]");
|
|
2039
|
+
if (chip === null) return SPLIT_MINIMUMS.chip;
|
|
2040
|
+
const style = getComputedStyle(chip);
|
|
2041
|
+
const min = px(style.minWidth);
|
|
2042
|
+
if (min <= 0) return SPLIT_MINIMUMS.chip;
|
|
2043
|
+
if (style.boxSizing === "border-box") return min;
|
|
2044
|
+
return min + px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth);
|
|
2045
|
+
}
|
|
2046
|
+
/** A rendered divider's thickness, or the stylesheet fallback before the first split. */
|
|
2047
|
+
function dividerSize(root) {
|
|
2048
|
+
const divider = root.querySelector("[data-dockkit-divider]");
|
|
2049
|
+
if (divider === null) return SPLIT_MINIMUMS.divider;
|
|
2050
|
+
const { width, height } = divider.getBoundingClientRect();
|
|
2051
|
+
const thickness = Math.min(width, height);
|
|
2052
|
+
return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider;
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Measure every docked pane under `root`.
|
|
2056
|
+
* @param root - the docked surface's element.
|
|
2057
|
+
* @returns each pane's fit, keyed by pane id.
|
|
2058
|
+
*/
|
|
2059
|
+
function measurePaneFits(root) {
|
|
2060
|
+
const minimums = {
|
|
2061
|
+
divider: dividerSize(root),
|
|
2062
|
+
chip: chipMinimum(root),
|
|
2063
|
+
body: SPLIT_MINIMUMS.body
|
|
2064
|
+
};
|
|
2065
|
+
const fits = /* @__PURE__ */ new Map();
|
|
2066
|
+
for (const [paneId, pane] of paneElements(root)) fits.set(paneId, halvesFit({
|
|
2067
|
+
pane: rectOf(pane),
|
|
2068
|
+
strip: rectOf(pane.querySelector("[data-dockkit-strip]")),
|
|
2069
|
+
chipsWidth: rectOf(pane.querySelector("[data-dockkit-strip-tabs]")).width,
|
|
2070
|
+
fillWidth: rectOf(pane.querySelector("[data-dockkit-strip-fill]")).width
|
|
2071
|
+
}, minimums));
|
|
2072
|
+
return fits;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* One pane's latest reading. A pane the map does not name has not been
|
|
2076
|
+
* measured and fits: the rule only blocks on a positive reading.
|
|
2077
|
+
* @param fits - the latest measurement.
|
|
2078
|
+
* @param paneId - the pane asked about.
|
|
2079
|
+
* @returns whether each split axis leaves two working halves.
|
|
2080
|
+
*/
|
|
2081
|
+
function fitOf(fits, paneId) {
|
|
2082
|
+
return fits.get(paneId) ?? UNMEASURED;
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Whether two measurements agree, so a re-measure that changed nothing re-renders nothing.
|
|
2086
|
+
* @param a - one measurement.
|
|
2087
|
+
* @param b - the other.
|
|
2088
|
+
* @returns whether both name the same panes with the same readings.
|
|
2089
|
+
*/
|
|
2090
|
+
function sameFits(a, b) {
|
|
2091
|
+
if (a.size !== b.size) return false;
|
|
2092
|
+
for (const [paneId, fit] of a) {
|
|
2093
|
+
const other = b.get(paneId);
|
|
2094
|
+
if (other === void 0 || other.row !== fit.row || other.column !== fit.column) return false;
|
|
2095
|
+
}
|
|
2096
|
+
return true;
|
|
2097
|
+
}
|
|
2098
|
+
//#endregion
|
|
2099
|
+
//#region lib/types/components/pointer.js
|
|
2100
|
+
/**
|
|
2101
|
+
* Pointer ownership shared by the docking surface and the float layer.
|
|
2102
|
+
*
|
|
2103
|
+
* Capture is hardening, not the mechanism: the window listeners carry the
|
|
2104
|
+
* gesture either way. Capture is what stops a scroll container the pointer
|
|
2105
|
+
* crosses from claiming it, which Chromium reports as a cancelled pointer and
|
|
2106
|
+
* an abandoned drag. Environments without the API (jsdom) simply go unhardened.
|
|
2107
|
+
*/
|
|
2108
|
+
/**
|
|
2109
|
+
* Take ownership of the pointer for the rest of the gesture.
|
|
2110
|
+
* @param element - the element the gesture started on.
|
|
2111
|
+
* @param pointerId - the pointer to capture.
|
|
2112
|
+
*/
|
|
2113
|
+
function capturePointer(element, pointerId) {
|
|
2114
|
+
if (typeof element.setPointerCapture !== "function") return;
|
|
2115
|
+
element.setPointerCapture(pointerId);
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Capture the pointer, then follow it on the window until release or cancel.
|
|
2119
|
+
* Only that pointer's events count: a second finger or a pen beside the mouse
|
|
2120
|
+
* neither moves nor ends the gesture. The listeners remove themselves before
|
|
2121
|
+
* `up` or `cancel` runs; the returned callback ends the gesture early, for an
|
|
2122
|
+
* unmount or a superseding press.
|
|
2123
|
+
* @param element - the element the gesture started on.
|
|
2124
|
+
* @param pointerId - the pointer to capture and follow.
|
|
2125
|
+
* @param followers - listeners for move, release, and cancel.
|
|
2126
|
+
* @returns detach callback removing the three listeners.
|
|
2127
|
+
*/
|
|
2128
|
+
function followPointer(element, pointerId, followers) {
|
|
2129
|
+
capturePointer(element, pointerId);
|
|
2130
|
+
const controller = new AbortController();
|
|
2131
|
+
const { signal } = controller;
|
|
2132
|
+
const own = (event) => event.pointerId === pointerId;
|
|
2133
|
+
window.addEventListener("pointermove", (event) => {
|
|
2134
|
+
if (own(event)) followers.move(event);
|
|
2135
|
+
}, { signal });
|
|
2136
|
+
window.addEventListener("pointerup", (event) => {
|
|
2137
|
+
if (!own(event)) return;
|
|
2138
|
+
controller.abort();
|
|
2139
|
+
followers.up(event);
|
|
2140
|
+
}, { signal });
|
|
2141
|
+
window.addEventListener("pointercancel", (event) => {
|
|
2142
|
+
if (!own(event)) return;
|
|
2143
|
+
controller.abort();
|
|
2144
|
+
followers.cancel();
|
|
2145
|
+
}, { signal });
|
|
2146
|
+
return () => {
|
|
2147
|
+
controller.abort();
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* One pointer gesture at a time for a component. A gesture ends on release, on
|
|
2152
|
+
* cancel, or when a new press supersedes it; `reset` runs at each of those ends
|
|
2153
|
+
* so the component clears its preview. Unmounting mid-gesture removes the
|
|
2154
|
+
* listeners without resetting anything.
|
|
2155
|
+
* @param reset - clears the component's gesture preview.
|
|
2156
|
+
* @returns the gesture starter, called from a pointer-down handler.
|
|
2157
|
+
*/
|
|
2158
|
+
function useGesture(reset) {
|
|
2159
|
+
const inFlight = useRef(void 0);
|
|
2160
|
+
useEffect(() => () => {
|
|
2161
|
+
inFlight.current?.stop();
|
|
2162
|
+
}, []);
|
|
2163
|
+
return (element, pointerId, followers) => {
|
|
2164
|
+
inFlight.current?.end();
|
|
2165
|
+
const settle = () => {
|
|
2166
|
+
inFlight.current = void 0;
|
|
2167
|
+
reset();
|
|
2168
|
+
};
|
|
2169
|
+
const stop = followPointer(element, pointerId, {
|
|
2170
|
+
move: followers.move,
|
|
2171
|
+
up: (event) => {
|
|
2172
|
+
settle();
|
|
2173
|
+
followers.up(event);
|
|
2174
|
+
},
|
|
2175
|
+
cancel: settle
|
|
2176
|
+
});
|
|
2177
|
+
inFlight.current = {
|
|
2178
|
+
stop,
|
|
2179
|
+
end: () => {
|
|
2180
|
+
stop();
|
|
2181
|
+
settle();
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
//#endregion
|
|
2187
|
+
//#region lib/types/components/TabMenu.js
|
|
2188
|
+
/**
|
|
2189
|
+
* The per-tab context menu, opened by a secondary press on the chip. It carries
|
|
2190
|
+
* the close gesture and whatever the embedder appends; the copy and float
|
|
2191
|
+
* gestures have no menu item — copying is an embedder API, floating is a drag
|
|
2192
|
+
* released clear of the surface. Presentational — it renders what its props
|
|
2193
|
+
* supply and dismisses itself on outside presses.
|
|
2194
|
+
*
|
|
2195
|
+
* It renders in a portal, positioned against the control that opened it. The tab
|
|
2196
|
+
* strip clips its overflow on purpose (so it never becomes a scroll container
|
|
2197
|
+
* that claims a drag), and a menu drawn inside the strip would be clipped with
|
|
2198
|
+
* it; a portal puts it above every clipping ancestor. React still bubbles the
|
|
2199
|
+
* portal's synthetic events through the strip, which is why the press guards
|
|
2200
|
+
* below remain necessary.
|
|
2201
|
+
*/
|
|
2202
|
+
/** Gap between the opening control and the menu, and the viewport margin kept clear. */
|
|
2203
|
+
const MENU_GAP = 4;
|
|
2204
|
+
/** Where the menu sits, or `undefined` before the first measurement. */
|
|
2205
|
+
function placeMenu(anchor, menu) {
|
|
2206
|
+
const rect = anchor.getBoundingClientRect();
|
|
2207
|
+
const width = menu.offsetWidth;
|
|
2208
|
+
const left = rect.left + width + MENU_GAP > window.innerWidth ? Math.max(MENU_GAP, rect.right - width) : rect.left;
|
|
2209
|
+
return {
|
|
2210
|
+
top: rect.bottom + MENU_GAP,
|
|
2211
|
+
left
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
/** The actions menu body, anchored to the control that opened it. */
|
|
2215
|
+
function TabMenu({ labels, anchor, onClose, onDismiss, extras }) {
|
|
2216
|
+
const self = useRef(null);
|
|
2217
|
+
const [position, setPosition] = useState(void 0);
|
|
2218
|
+
useLayoutEffect(() => {
|
|
2219
|
+
/* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
|
|
2220
|
+
if (self.current === null) return;
|
|
2221
|
+
setPosition(placeMenu(anchor, self.current));
|
|
2222
|
+
}, [anchor]);
|
|
2223
|
+
useEffect(() => {
|
|
2224
|
+
const menu = self.current;
|
|
2225
|
+
/* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
|
|
2226
|
+
if (menu === null) return void 0;
|
|
2227
|
+
const onPointerDown = (event) => {
|
|
2228
|
+
if (event.target instanceof Node && menu.contains(event.target)) return;
|
|
2229
|
+
onDismiss();
|
|
2230
|
+
};
|
|
2231
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
2232
|
+
return () => {
|
|
2233
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
2234
|
+
};
|
|
2235
|
+
}, [onDismiss]);
|
|
2236
|
+
return createPortal(jsxs("div", {
|
|
2237
|
+
className: css.menu,
|
|
2238
|
+
ref: self,
|
|
2239
|
+
role: "menu",
|
|
2240
|
+
"data-dockkit-tab-menu": true,
|
|
2241
|
+
style: position ?? {
|
|
2242
|
+
visibility: "hidden",
|
|
2243
|
+
top: 0,
|
|
2244
|
+
left: 0
|
|
2245
|
+
},
|
|
2246
|
+
onPointerDown: (event) => {
|
|
2247
|
+
event.stopPropagation();
|
|
2248
|
+
},
|
|
2249
|
+
onClick: (event) => {
|
|
2250
|
+
event.stopPropagation();
|
|
2251
|
+
},
|
|
2252
|
+
children: [jsx("button", {
|
|
2253
|
+
type: "button",
|
|
2254
|
+
role: "menuitem",
|
|
2255
|
+
className: css.menuItem,
|
|
2256
|
+
"data-dockkit-menu-close": true,
|
|
2257
|
+
onClick: onClose,
|
|
2258
|
+
children: labels.closeTab
|
|
2259
|
+
}), extras]
|
|
2260
|
+
}), document.body);
|
|
2261
|
+
}
|
|
2262
|
+
//#endregion
|
|
2263
|
+
//#region lib/types/components/TabPanel.js
|
|
2264
|
+
/**
|
|
2265
|
+
* One pane: its tab strip (drag source, drop target, split control) and the
|
|
2266
|
+
* active tab's body with the dock preview overlay. Presentational; every gesture
|
|
2267
|
+
* leaves through `PaneCallbacks`, and the body itself comes from `renderTab`.
|
|
2268
|
+
*
|
|
2269
|
+
* A chip is a capsule carrying one control, its close, at its right end; the
|
|
2270
|
+
* context menu (secondary press) carries the same close plus whatever the
|
|
2271
|
+
* embedder appends. The chips sit in their own box, the strip's one shrinking
|
|
2272
|
+
* part: in a narrow pane they ellipsize and then clip there, so the add
|
|
2273
|
+
* control after them (drawn while the embedder's `canAddTab` allows), the
|
|
2274
|
+
* pane's split control, and the embedder's chrome keep their width and their
|
|
2275
|
+
* place at the strip's end.
|
|
2276
|
+
*/
|
|
2277
|
+
/** The split control's glyph: a frame divided by a vertical line, as the split itself is. */
|
|
2278
|
+
function SplitGlyph() {
|
|
2279
|
+
return jsxs("svg", {
|
|
2280
|
+
width: "14",
|
|
2281
|
+
height: "14",
|
|
2282
|
+
viewBox: "0 0 14 14",
|
|
2283
|
+
fill: "none",
|
|
2284
|
+
"aria-hidden": "true",
|
|
2285
|
+
children: [jsx("rect", {
|
|
2286
|
+
x: "1.5",
|
|
2287
|
+
y: "2",
|
|
2288
|
+
width: "11",
|
|
2289
|
+
height: "10",
|
|
2290
|
+
rx: "1.5",
|
|
2291
|
+
stroke: "currentColor"
|
|
2292
|
+
}), jsx("path", {
|
|
2293
|
+
d: "M7 2v10",
|
|
2294
|
+
stroke: "currentColor"
|
|
2295
|
+
})]
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
/** The add control's glyph. */
|
|
2299
|
+
function PlusGlyph() {
|
|
2300
|
+
return jsx("svg", {
|
|
2301
|
+
width: "12",
|
|
2302
|
+
height: "12",
|
|
2303
|
+
viewBox: "0 0 12 12",
|
|
2304
|
+
fill: "none",
|
|
2305
|
+
"aria-hidden": "true",
|
|
2306
|
+
children: jsx("path", {
|
|
2307
|
+
d: "M6 1.5v9M1.5 6h9",
|
|
2308
|
+
stroke: "currentColor",
|
|
2309
|
+
strokeWidth: "1.2",
|
|
2310
|
+
strokeLinecap: "round"
|
|
2311
|
+
})
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
/** The close control's glyph. */
|
|
2315
|
+
function CloseGlyph() {
|
|
2316
|
+
return jsx("svg", {
|
|
2317
|
+
width: "10",
|
|
2318
|
+
height: "10",
|
|
2319
|
+
viewBox: "0 0 10 10",
|
|
2320
|
+
fill: "none",
|
|
2321
|
+
"aria-hidden": "true",
|
|
2322
|
+
children: jsx("path", {
|
|
2323
|
+
d: "M1.5 1.5l7 7M8.5 1.5l-7 7",
|
|
2324
|
+
stroke: "currentColor",
|
|
2325
|
+
strokeWidth: "1.2",
|
|
2326
|
+
strokeLinecap: "round"
|
|
2327
|
+
})
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* The chip a navigation key moves focus to, in the WAI-ARIA tabs pattern with
|
|
2332
|
+
* manual activation: Left and Right step through the strip and wrap, Home and
|
|
2333
|
+
* End jump to its ends. Selecting is a separate key.
|
|
2334
|
+
* @returns the chip to focus, or `undefined` when the key is not a navigation key.
|
|
2335
|
+
*/
|
|
2336
|
+
function chipToFocus(key, tabs, tabId) {
|
|
2337
|
+
const count = tabs.length;
|
|
2338
|
+
const index = tabs.indexOf(tabId);
|
|
2339
|
+
switch (key) {
|
|
2340
|
+
case "ArrowLeft": return tabs[(index - 1 + count) % count];
|
|
2341
|
+
case "ArrowRight": return tabs[(index + 1) % count];
|
|
2342
|
+
case "Home": return tabs[0];
|
|
2343
|
+
case "End": return tabs.at(-1);
|
|
2344
|
+
default: return;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
/** Whether a key selects the focused chip. */
|
|
2348
|
+
function selects(key) {
|
|
2349
|
+
return key === "Enter" || key === " ";
|
|
2350
|
+
}
|
|
2351
|
+
/** The split control's title: what it does, or why it cannot right now. */
|
|
2352
|
+
function splitTitle(labels, block) {
|
|
2353
|
+
switch (block) {
|
|
2354
|
+
case void 0: return labels.splitPane;
|
|
2355
|
+
case "budget": return labels.splitPaneDisabled;
|
|
2356
|
+
case "width": return labels.splitPaneNarrow;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
/** The pane's tab strip, split control, and body. */
|
|
2360
|
+
function TabPanel({ state, pane, callbacks }) {
|
|
2361
|
+
const [menu, setMenu] = useState(void 0);
|
|
2362
|
+
const [chips] = useState(() => /* @__PURE__ */ new Map());
|
|
2363
|
+
const active = pane.activeTabId === void 0 ? void 0 : getTab(state, pane.activeTabId);
|
|
2364
|
+
const block = callbacks.splitBlock(pane.id);
|
|
2365
|
+
const target = callbacks.dropTarget;
|
|
2366
|
+
const stripIndex = target !== void 0 && target.kind === "strip" && target.paneId === pane.id ? target.index : void 0;
|
|
2367
|
+
const zone = target !== void 0 && target.kind === "zone" && target.paneId === pane.id ? target.zone : void 0;
|
|
2368
|
+
/** Select a tab from a click or a key, unless it is the active pane's selected tab already: that changes nothing. */
|
|
2369
|
+
const activate = (tabId) => {
|
|
2370
|
+
if (state.activePaneId === pane.id && pane.activeTabId === tabId) return;
|
|
2371
|
+
callbacks.onFocusTab(tabId);
|
|
2372
|
+
};
|
|
2373
|
+
const focusChip = (tabId) => {
|
|
2374
|
+
const chip = chips.get(tabId);
|
|
2375
|
+
/* v8 ignore next -- every tab in the strip has a mounted chip, registered by its ref. */
|
|
2376
|
+
if (chip === void 0) return;
|
|
2377
|
+
chip.focus();
|
|
2378
|
+
};
|
|
2379
|
+
return jsxs("section", {
|
|
2380
|
+
className: css.pane,
|
|
2381
|
+
"data-dockkit-pane": pane.id,
|
|
2382
|
+
"data-dockkit-pane-active": state.activePaneId === pane.id || void 0,
|
|
2383
|
+
onClick: () => {
|
|
2384
|
+
if (state.activePaneId === pane.id) return;
|
|
2385
|
+
callbacks.onFocusPane(pane.id);
|
|
2386
|
+
},
|
|
2387
|
+
children: [jsxs("div", {
|
|
2388
|
+
className: css.tabStrip,
|
|
2389
|
+
role: "tablist",
|
|
2390
|
+
"data-dockkit-strip": pane.id,
|
|
2391
|
+
children: [
|
|
2392
|
+
jsxs("div", {
|
|
2393
|
+
className: css.stripTabs,
|
|
2394
|
+
role: "presentation",
|
|
2395
|
+
"data-dockkit-strip-tabs": pane.id,
|
|
2396
|
+
children: [pane.tabs.map((tabId, index) => {
|
|
2397
|
+
const tab = getTab(state, tabId);
|
|
2398
|
+
const selected = tabId === pane.activeTabId;
|
|
2399
|
+
return jsxs(Fragment$1, { children: [stripIndex === index && jsx("div", {
|
|
2400
|
+
className: css.caret,
|
|
2401
|
+
"data-dockkit-caret": index
|
|
2402
|
+
}), jsxs("div", {
|
|
2403
|
+
role: "tab",
|
|
2404
|
+
"aria-selected": selected,
|
|
2405
|
+
tabIndex: selected ? 0 : -1,
|
|
2406
|
+
className: clsx(css.tab, selected && css.tabActive, callbacks.draggingTabId === tabId && css.tabDragging),
|
|
2407
|
+
"data-dockkit-tab": tabId,
|
|
2408
|
+
ref: (element) => {
|
|
2409
|
+
if (element === null) chips.delete(tabId);
|
|
2410
|
+
else chips.set(tabId, element);
|
|
2411
|
+
},
|
|
2412
|
+
onPointerDown: (event) => {
|
|
2413
|
+
if (event.button === 2) return;
|
|
2414
|
+
callbacks.onTabPressed(tabId, event);
|
|
2415
|
+
},
|
|
2416
|
+
onClick: (event) => {
|
|
2417
|
+
event.stopPropagation();
|
|
2418
|
+
activate(tabId);
|
|
2419
|
+
},
|
|
2420
|
+
onKeyDown: (event) => {
|
|
2421
|
+
if (event.target !== event.currentTarget) return;
|
|
2422
|
+
const next = chipToFocus(event.key, pane.tabs, tabId);
|
|
2423
|
+
if (next !== void 0) {
|
|
2424
|
+
event.preventDefault();
|
|
2425
|
+
focusChip(next);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
if (selects(event.key)) {
|
|
2429
|
+
event.preventDefault();
|
|
2430
|
+
activate(tabId);
|
|
2431
|
+
}
|
|
2432
|
+
},
|
|
2433
|
+
onContextMenu: (event) => {
|
|
2434
|
+
event.preventDefault();
|
|
2435
|
+
const anchor = event.currentTarget;
|
|
2436
|
+
setMenu((current) => current?.tabId === tabId ? void 0 : {
|
|
2437
|
+
tabId,
|
|
2438
|
+
anchor
|
|
2439
|
+
});
|
|
2440
|
+
},
|
|
2441
|
+
children: [
|
|
2442
|
+
jsx("span", {
|
|
2443
|
+
className: css.tabTitle,
|
|
2444
|
+
"data-dockkit-tab-title": true,
|
|
2445
|
+
children: callbacks.renderTabTitle?.(tab) ?? tab.title
|
|
2446
|
+
}),
|
|
2447
|
+
jsx("button", {
|
|
2448
|
+
type: "button",
|
|
2449
|
+
className: css.tabClose,
|
|
2450
|
+
"aria-label": callbacks.labels.closeTab,
|
|
2451
|
+
"data-dockkit-tab-close": tabId,
|
|
2452
|
+
onPointerDown: (event) => {
|
|
2453
|
+
event.stopPropagation();
|
|
2454
|
+
},
|
|
2455
|
+
onClick: (event) => {
|
|
2456
|
+
event.stopPropagation();
|
|
2457
|
+
callbacks.onCloseTab(tabId);
|
|
2458
|
+
},
|
|
2459
|
+
children: jsx(CloseGlyph, {})
|
|
2460
|
+
}),
|
|
2461
|
+
menu?.tabId === tabId && jsx(TabMenu, {
|
|
2462
|
+
labels: callbacks.labels,
|
|
2463
|
+
anchor: menu.anchor,
|
|
2464
|
+
onClose: () => {
|
|
2465
|
+
setMenu(void 0);
|
|
2466
|
+
callbacks.onCloseTab(tabId);
|
|
2467
|
+
},
|
|
2468
|
+
onDismiss: () => {
|
|
2469
|
+
setMenu(void 0);
|
|
2470
|
+
},
|
|
2471
|
+
extras: callbacks.renderTabMenuItems?.(tab, () => {
|
|
2472
|
+
setMenu(void 0);
|
|
2473
|
+
})
|
|
2474
|
+
})
|
|
2475
|
+
]
|
|
2476
|
+
})] }, tabId);
|
|
2477
|
+
}), stripIndex === pane.tabs.length && jsx("div", {
|
|
2478
|
+
className: css.caret,
|
|
2479
|
+
"data-dockkit-caret": stripIndex
|
|
2480
|
+
})]
|
|
2481
|
+
}),
|
|
2482
|
+
callbacks.canAddTab(pane.id) && jsx("button", {
|
|
2483
|
+
type: "button",
|
|
2484
|
+
className: css.addTab,
|
|
2485
|
+
"aria-label": callbacks.labels.addTab,
|
|
2486
|
+
title: callbacks.labels.addTab,
|
|
2487
|
+
"data-dockkit-add-tab": pane.id,
|
|
2488
|
+
onClick: (event) => {
|
|
2489
|
+
event.stopPropagation();
|
|
2490
|
+
callbacks.onAddTab(pane.id);
|
|
2491
|
+
},
|
|
2492
|
+
children: jsx(PlusGlyph, {})
|
|
2493
|
+
}),
|
|
2494
|
+
jsx("div", {
|
|
2495
|
+
className: css.stripFill,
|
|
2496
|
+
"data-dockkit-strip-fill": true
|
|
2497
|
+
}),
|
|
2498
|
+
!(callbacks.hideSplitAtCapacity && block === "budget") && jsx("button", {
|
|
2499
|
+
type: "button",
|
|
2500
|
+
className: css.iconButton,
|
|
2501
|
+
"aria-label": callbacks.labels.splitPane,
|
|
2502
|
+
title: splitTitle(callbacks.labels, block),
|
|
2503
|
+
disabled: block !== void 0,
|
|
2504
|
+
"data-dockkit-split-button": pane.id,
|
|
2505
|
+
"data-dockkit-split-blocked": block,
|
|
2506
|
+
onClick: (event) => {
|
|
2507
|
+
event.stopPropagation();
|
|
2508
|
+
callbacks.onSplitPane(pane.id);
|
|
2509
|
+
},
|
|
2510
|
+
children: jsx(SplitGlyph, {})
|
|
2511
|
+
}),
|
|
2512
|
+
pane.id === callbacks.chromePaneId && callbacks.chrome !== void 0 && jsx("div", {
|
|
2513
|
+
className: css.stripChrome,
|
|
2514
|
+
"data-dockkit-strip-chrome": true,
|
|
2515
|
+
onClick: (event) => {
|
|
2516
|
+
event.stopPropagation();
|
|
2517
|
+
},
|
|
2518
|
+
children: callbacks.chrome
|
|
2519
|
+
})
|
|
2520
|
+
]
|
|
2521
|
+
}), jsxs("div", {
|
|
2522
|
+
className: css.paneBody,
|
|
2523
|
+
children: [active === void 0 ? jsx("p", {
|
|
2524
|
+
className: css.empty,
|
|
2525
|
+
children: callbacks.labels.emptyPane
|
|
2526
|
+
}) : callbacks.renderTab(active), zone !== void 0 && (callbacks.horizontalDrops && zone !== "center" ? jsxs(Fragment, { children: [jsx("div", {
|
|
2527
|
+
className: css.dockHint,
|
|
2528
|
+
"data-dockkit-dock-zone": "left",
|
|
2529
|
+
"data-dockkit-drop-active": zone === "left" || void 0
|
|
2530
|
+
}), jsx("div", {
|
|
2531
|
+
className: css.dockHint,
|
|
2532
|
+
"data-dockkit-dock-zone": "right",
|
|
2533
|
+
"data-dockkit-drop-active": zone === "right" || void 0
|
|
2534
|
+
})] }) : jsx("div", {
|
|
2535
|
+
className: css.dockHint,
|
|
2536
|
+
"data-dockkit-dock-zone": zone
|
|
2537
|
+
}))]
|
|
2538
|
+
})]
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
//#endregion
|
|
2542
|
+
//#region lib/types/components/PaneTree.js
|
|
2543
|
+
/**
|
|
2544
|
+
* The docked split tree: nested flex runs sized by each split's fractions, with a
|
|
2545
|
+
* draggable divider between neighbours. A live divider drag renders from the
|
|
2546
|
+
* preview fractions instead of the recorded ones — the gesture only settles one
|
|
2547
|
+
* intent when it ends.
|
|
2548
|
+
*/
|
|
2549
|
+
/** Render a split or pane node and everything under it. */
|
|
2550
|
+
function PaneTree({ state, nodeId, callbacks, preview }) {
|
|
2551
|
+
const node = getNode(state, nodeId);
|
|
2552
|
+
if (node.kind === "pane") return jsx(TabPanel, {
|
|
2553
|
+
state,
|
|
2554
|
+
pane: node,
|
|
2555
|
+
callbacks
|
|
2556
|
+
});
|
|
2557
|
+
const sizes = preview !== void 0 && preview.splitId === node.id ? preview.sizes : node.sizes;
|
|
2558
|
+
return jsx("div", {
|
|
2559
|
+
className: clsx(css.split, node.axis === "row" ? css.splitRow : css.splitColumn),
|
|
2560
|
+
"data-dockkit-split": node.id,
|
|
2561
|
+
children: node.children.map((childId, index) => jsxs(Fragment$1, { children: [index > 0 && jsx("div", {
|
|
2562
|
+
className: css.divider,
|
|
2563
|
+
"data-dockkit-divider": `${node.id}:${index - 1}`,
|
|
2564
|
+
onPointerDown: (event) => {
|
|
2565
|
+
callbacks.onDividerPressed(node.id, index - 1, event);
|
|
2566
|
+
}
|
|
2567
|
+
}), jsx("div", {
|
|
2568
|
+
className: css.splitCell,
|
|
2569
|
+
"data-dockkit-cell": `${node.id}:${index}`,
|
|
2570
|
+
style: { flexGrow: sizes[index] },
|
|
2571
|
+
children: jsx(PaneTree, {
|
|
2572
|
+
state,
|
|
2573
|
+
nodeId: childId,
|
|
2574
|
+
callbacks,
|
|
2575
|
+
preview
|
|
2576
|
+
})
|
|
2577
|
+
})] }, childId))
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
//#endregion
|
|
2581
|
+
//#region lib/types/components/DockSurface.js
|
|
2582
|
+
/**
|
|
2583
|
+
* The docked surface: the split tree plus the tab and divider gestures over it.
|
|
2584
|
+
* This is the whole kit as far as an embedder's layout column is concerned —
|
|
2585
|
+
* chrome around it (a rail, a header, a collapsed state) belongs to the embedder.
|
|
2586
|
+
*
|
|
2587
|
+
* A gesture only previews until it ends, then leaves through one intent, so the
|
|
2588
|
+
* embedder's operation sequence stays the single source of truth. Releasing a tab
|
|
2589
|
+
* clear of this surface floats it; releasing inside it but on no pane is not a
|
|
2590
|
+
* move at all.
|
|
2591
|
+
*/
|
|
2592
|
+
const NO_PREVIEW = {
|
|
2593
|
+
draggingTabId: void 0,
|
|
2594
|
+
dropTarget: void 0,
|
|
2595
|
+
sizes: void 0
|
|
2596
|
+
};
|
|
2597
|
+
/** Nothing measured yet: every pane fits until a reading says otherwise. */
|
|
2598
|
+
const NO_FITS = /* @__PURE__ */ new Map();
|
|
2599
|
+
/** The default add-control policy: every pane offers one. */
|
|
2600
|
+
const ALWAYS = () => true;
|
|
2601
|
+
/**
|
|
2602
|
+
* Resolve where a pointer sits inside the docked surface. An edge zone is only
|
|
2603
|
+
* offered where the split it would make is allowed: within the pane budget and
|
|
2604
|
+
* with room for two halves; otherwise the release is not a move at all.
|
|
2605
|
+
*/
|
|
2606
|
+
function hitTest(root, x, y, canSplit, fits, dropZones) {
|
|
2607
|
+
for (const [paneId, pane] of paneElements(root)) {
|
|
2608
|
+
const rect = pane.getBoundingClientRect();
|
|
2609
|
+
if (!containsPoint(rect, x, y)) continue;
|
|
2610
|
+
const strip = pane.querySelector("[data-dockkit-strip]");
|
|
2611
|
+
if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) return {
|
|
2612
|
+
kind: "strip",
|
|
2613
|
+
paneId,
|
|
2614
|
+
index: insertionIndex([...strip.querySelectorAll("[data-dockkit-tab]")].map((tab) => tab.getBoundingClientRect()), x)
|
|
2615
|
+
};
|
|
2616
|
+
const zone = dropZones === "horizontal" ? canSplit && fitOf(fits, paneId).row ? x < rect.x + rect.width / 2 ? "left" : "right" : "center" : zoneInRect(rect, x, y);
|
|
2617
|
+
if (zone !== "center") {
|
|
2618
|
+
const fit = fitOf(fits, paneId);
|
|
2619
|
+
const room = zone === "left" || zone === "right" ? fit.row : fit.column;
|
|
2620
|
+
if (!canSplit || !room) return void 0;
|
|
2621
|
+
}
|
|
2622
|
+
return {
|
|
2623
|
+
kind: "zone",
|
|
2624
|
+
paneId,
|
|
2625
|
+
zone
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
/** Fractions a divider drag has reached, clamped to the pane minimum. */
|
|
2630
|
+
function draggedSizes(drag, x, y, minimum) {
|
|
2631
|
+
const moved = (drag.axis === "row" ? x : y) - drag.origin;
|
|
2632
|
+
const delta = drag.extent > 0 ? moved / drag.extent : 0;
|
|
2633
|
+
return clampSizes(dividerSizes(drag.sizes, drag.index, delta), minimum);
|
|
2634
|
+
}
|
|
2635
|
+
/** Fractions closer than this are the same split: renormalizing recorded sizes moves them by no more. */
|
|
2636
|
+
const SIZE_TOLERANCE = 1e-9;
|
|
2637
|
+
/** Whether two fraction lists describe the same split. */
|
|
2638
|
+
function sameSizes(a, b) {
|
|
2639
|
+
return a.length === b.length && a.every((size, index) => {
|
|
2640
|
+
const other = b[index];
|
|
2641
|
+
return other !== void 0 && Math.abs(size - other) < SIZE_TOLERANCE;
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
/** The split tree and the gestures over it. */
|
|
2645
|
+
function DockSurface({ state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, dropZones = "edges", minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false }) {
|
|
2646
|
+
const surface = useRef(null);
|
|
2647
|
+
const [preview, setPreview] = useState(NO_PREVIEW);
|
|
2648
|
+
const [fits, setFits] = useState(NO_FITS);
|
|
2649
|
+
const begin = useGesture(() => {
|
|
2650
|
+
setPreview(NO_PREVIEW);
|
|
2651
|
+
});
|
|
2652
|
+
/** Run `use` on the surface element, which every commit and every press inside it has mounted. */
|
|
2653
|
+
const withSurface = useCallback((use) => {
|
|
2654
|
+
const root = surface.current;
|
|
2655
|
+
/* v8 ignore next -- ref-null guard: the surface div renders unconditionally. */
|
|
2656
|
+
if (root === null) return;
|
|
2657
|
+
use(root);
|
|
2658
|
+
}, []);
|
|
2659
|
+
const remeasure = useCallback(() => {
|
|
2660
|
+
withSurface((root) => {
|
|
2661
|
+
const next = measurePaneFits(root);
|
|
2662
|
+
setFits((current) => sameFits(current, next) ? current : next);
|
|
2663
|
+
});
|
|
2664
|
+
}, [withSurface]);
|
|
2665
|
+
useLayoutEffect(() => {
|
|
2666
|
+
remeasure();
|
|
2667
|
+
});
|
|
2668
|
+
useEffect(() => {
|
|
2669
|
+
onRoom?.(fits);
|
|
2670
|
+
}, [fits, onRoom]);
|
|
2671
|
+
useEffect(() => {
|
|
2672
|
+
const root = surface.current;
|
|
2673
|
+
if (root === null || typeof ResizeObserver === "undefined") return void 0;
|
|
2674
|
+
const observer = new ResizeObserver(() => {
|
|
2675
|
+
remeasure();
|
|
2676
|
+
});
|
|
2677
|
+
observer.observe(root);
|
|
2678
|
+
return () => {
|
|
2679
|
+
observer.disconnect();
|
|
2680
|
+
};
|
|
2681
|
+
}, [remeasure]);
|
|
2682
|
+
/** Why a pane cannot split right now: the budget first, then its own width. */
|
|
2683
|
+
const splitBlock = (paneId) => {
|
|
2684
|
+
if (!canSplit) return "budget";
|
|
2685
|
+
return fitOf(fits, paneId).row ? void 0 : "width";
|
|
2686
|
+
};
|
|
2687
|
+
const callbacks = {
|
|
2688
|
+
onFocusTab: intents.focusTab.bind(intents),
|
|
2689
|
+
onFocusPane: intents.focusPane.bind(intents),
|
|
2690
|
+
onSplitPane: intents.splitPane.bind(intents),
|
|
2691
|
+
onAddTab: intents.addTab.bind(intents),
|
|
2692
|
+
onCloseTab: intents.closeTab.bind(intents),
|
|
2693
|
+
onTabPressed: (tabId, event) => {
|
|
2694
|
+
withSurface((root) => {
|
|
2695
|
+
const startX = event.clientX;
|
|
2696
|
+
const startY = event.clientY;
|
|
2697
|
+
let dragging = false;
|
|
2698
|
+
begin(event.currentTarget, event.pointerId, {
|
|
2699
|
+
move: (moved) => {
|
|
2700
|
+
if (!dragging) {
|
|
2701
|
+
if (!passedThreshold(startX, startY, moved.clientX, moved.clientY)) return;
|
|
2702
|
+
dragging = true;
|
|
2703
|
+
}
|
|
2704
|
+
setPreview({
|
|
2705
|
+
...NO_PREVIEW,
|
|
2706
|
+
draggingTabId: tabId,
|
|
2707
|
+
dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones)
|
|
2708
|
+
});
|
|
2709
|
+
},
|
|
2710
|
+
up: (released) => {
|
|
2711
|
+
if (!dragging) return;
|
|
2712
|
+
const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones);
|
|
2713
|
+
if (target === void 0) {
|
|
2714
|
+
if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return;
|
|
2715
|
+
intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE));
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
if (target.kind === "strip") intents.placeTab(tabId, target.paneId, target.index);
|
|
2719
|
+
else intents.dropTab(tabId, target.paneId, target.zone);
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
});
|
|
2723
|
+
},
|
|
2724
|
+
onDividerPressed: (splitId, index, event) => {
|
|
2725
|
+
const container = event.currentTarget.parentElement;
|
|
2726
|
+
/* v8 ignore next -- a divider is rendered as a child of its split's element. */
|
|
2727
|
+
if (container === null) return;
|
|
2728
|
+
const split = getSplit(state, splitId);
|
|
2729
|
+
const box = container.getBoundingClientRect();
|
|
2730
|
+
const drag = {
|
|
2731
|
+
splitId,
|
|
2732
|
+
index,
|
|
2733
|
+
axis: split.axis,
|
|
2734
|
+
origin: split.axis === "row" ? event.clientX : event.clientY,
|
|
2735
|
+
extent: split.axis === "row" ? box.width : box.height,
|
|
2736
|
+
sizes: split.sizes
|
|
2737
|
+
};
|
|
2738
|
+
begin(event.currentTarget, event.pointerId, {
|
|
2739
|
+
move: (moved) => {
|
|
2740
|
+
setPreview({
|
|
2741
|
+
...NO_PREVIEW,
|
|
2742
|
+
sizes: {
|
|
2743
|
+
splitId,
|
|
2744
|
+
sizes: draggedSizes(drag, moved.clientX, moved.clientY, minPaneFraction)
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
},
|
|
2748
|
+
up: (released) => {
|
|
2749
|
+
const sizes = draggedSizes(drag, released.clientX, released.clientY, minPaneFraction);
|
|
2750
|
+
if (sameSizes(sizes, drag.sizes)) return;
|
|
2751
|
+
intents.resizeSplit(splitId, sizes);
|
|
2752
|
+
}
|
|
2753
|
+
});
|
|
2754
|
+
},
|
|
2755
|
+
splitBlock,
|
|
2756
|
+
hideSplitAtCapacity,
|
|
2757
|
+
canAddTab: canAddTab ?? ALWAYS,
|
|
2758
|
+
dropTarget: preview.dropTarget,
|
|
2759
|
+
horizontalDrops: dropZones === "horizontal",
|
|
2760
|
+
draggingTabId: preview.draggingTabId,
|
|
2761
|
+
labels,
|
|
2762
|
+
renderTab,
|
|
2763
|
+
renderTabTitle,
|
|
2764
|
+
renderTabMenuItems,
|
|
2765
|
+
chromePaneId: topRightPaneId(state),
|
|
2766
|
+
chrome
|
|
2767
|
+
};
|
|
2768
|
+
return jsx("div", {
|
|
2769
|
+
className: css.surface,
|
|
2770
|
+
ref: surface,
|
|
2771
|
+
"data-dockkit-surface": true,
|
|
2772
|
+
"data-dockkit-drop-zones": dropZones,
|
|
2773
|
+
children: jsx(PaneTree, {
|
|
2774
|
+
state,
|
|
2775
|
+
nodeId: state.rootId,
|
|
2776
|
+
callbacks,
|
|
2777
|
+
preview: preview.sizes
|
|
2778
|
+
})
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
//#endregion
|
|
2782
|
+
//#region lib/types/components/FloatLayer.js
|
|
2783
|
+
/**
|
|
2784
|
+
* The floating layer: one overlay panel per floating pane, bottom-to-top in the
|
|
2785
|
+
* model's z order. A floating pane hosts exactly one tab and renders no tab
|
|
2786
|
+
* strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip
|
|
2787
|
+
* and corner report through their gesture instead: a press released in place is
|
|
2788
|
+
* a click and raises the panel; a drag records the move or resize, and that
|
|
2789
|
+
* operation raises the panel itself, so one gesture is one intent. Raising a
|
|
2790
|
+
* panel that is active and on top already changes nothing and reports nothing.
|
|
2791
|
+
*
|
|
2792
|
+
* The layer owns its own drag and resize gestures, so where it mounts is not
|
|
2793
|
+
* part of its contract: panels are positioned in viewport coordinates and read
|
|
2794
|
+
* only `state` and the outward contracts. An embedder may portal it anywhere,
|
|
2795
|
+
* and nothing here assumes the docked tree is an ancestor or even present.
|
|
2796
|
+
*/
|
|
2797
|
+
/** The rectangle a gesture has reached. */
|
|
2798
|
+
function draggedRect(drag, x, y) {
|
|
2799
|
+
const dx = x - drag.originX;
|
|
2800
|
+
const dy = y - drag.originY;
|
|
2801
|
+
return drag.mode === "move" ? movedRect(drag.rect, dx, dy) : resizedRect(drag.rect, dx, dy, FLOAT_MIN_SIZE);
|
|
2802
|
+
}
|
|
2803
|
+
/** Whether two rectangles agree in every coordinate. */
|
|
2804
|
+
function sameRect(a, b) {
|
|
2805
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
2806
|
+
}
|
|
2807
|
+
/** Whether a floating pane is already where a raise would put it: focused and on top. */
|
|
2808
|
+
function raised(state, paneId) {
|
|
2809
|
+
return state.activePaneId === paneId && state.floats.at(-1) === paneId;
|
|
2810
|
+
}
|
|
2811
|
+
/** Every floating panel, in z order. */
|
|
2812
|
+
function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }) {
|
|
2813
|
+
const [preview, setPreview] = useState(void 0);
|
|
2814
|
+
const begin = useGesture(() => {
|
|
2815
|
+
setPreview(void 0);
|
|
2816
|
+
});
|
|
2817
|
+
/** Focus and raise a panel from a press or click on it, unless it is raised already. */
|
|
2818
|
+
const raise = (paneId) => {
|
|
2819
|
+
if (raised(state, paneId)) return;
|
|
2820
|
+
intents.focusPane(paneId);
|
|
2821
|
+
};
|
|
2822
|
+
/** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */
|
|
2823
|
+
const drag = (mode, paneId, event) => {
|
|
2824
|
+
event.stopPropagation();
|
|
2825
|
+
const start = {
|
|
2826
|
+
mode,
|
|
2827
|
+
originX: event.clientX,
|
|
2828
|
+
originY: event.clientY,
|
|
2829
|
+
rect: floatRect(getPane(state, paneId))
|
|
2830
|
+
};
|
|
2831
|
+
begin(event.currentTarget, event.pointerId, {
|
|
2832
|
+
move: (moved) => {
|
|
2833
|
+
setPreview({
|
|
2834
|
+
paneId,
|
|
2835
|
+
rect: draggedRect(start, moved.clientX, moved.clientY)
|
|
2836
|
+
});
|
|
2837
|
+
},
|
|
2838
|
+
up: (released) => {
|
|
2839
|
+
const rect = draggedRect(start, released.clientX, released.clientY);
|
|
2840
|
+
if (sameRect(rect, start.rect)) raise(paneId);
|
|
2841
|
+
else if (mode === "move") intents.moveFloat(paneId, rect.x, rect.y);
|
|
2842
|
+
else intents.resizeFloat(paneId, rect);
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
};
|
|
2846
|
+
return jsx(Fragment, { children: state.floats.map((paneId, depth) => {
|
|
2847
|
+
const pane = getPane(state, paneId);
|
|
2848
|
+
const tab = getTab(state, onlyTabId(pane));
|
|
2849
|
+
const lifted = preview?.paneId === paneId ? preview.rect : void 0;
|
|
2850
|
+
const live = lifted ?? floatRect(pane);
|
|
2851
|
+
return jsxs("div", {
|
|
2852
|
+
className: css.float,
|
|
2853
|
+
"data-dockkit-float": paneId,
|
|
2854
|
+
"data-dockkit-float-active": state.activePaneId === paneId || void 0,
|
|
2855
|
+
style: {
|
|
2856
|
+
left: live.x,
|
|
2857
|
+
top: live.y,
|
|
2858
|
+
width: live.width,
|
|
2859
|
+
height: live.height,
|
|
2860
|
+
zIndex: lifted === void 0 ? depth + 1 : state.floats.length + 1
|
|
2861
|
+
},
|
|
2862
|
+
onPointerDown: () => {
|
|
2863
|
+
raise(paneId);
|
|
2864
|
+
},
|
|
2865
|
+
children: [
|
|
2866
|
+
jsxs("header", {
|
|
2867
|
+
className: css.floatHeader,
|
|
2868
|
+
"data-dockkit-float-grip": paneId,
|
|
2869
|
+
onPointerDown: (event) => {
|
|
2870
|
+
drag("move", paneId, event);
|
|
2871
|
+
},
|
|
2872
|
+
children: [
|
|
2873
|
+
jsx("span", {
|
|
2874
|
+
className: css.floatTitle,
|
|
2875
|
+
"data-dockkit-float-title": true,
|
|
2876
|
+
children: renderTabTitle?.(tab) ?? tab.title
|
|
2877
|
+
}),
|
|
2878
|
+
jsx("button", {
|
|
2879
|
+
type: "button",
|
|
2880
|
+
className: css.iconButton,
|
|
2881
|
+
"aria-label": labels.dockFloat,
|
|
2882
|
+
"data-dockkit-float-dock": paneId,
|
|
2883
|
+
onPointerDown: (event) => {
|
|
2884
|
+
event.stopPropagation();
|
|
2885
|
+
},
|
|
2886
|
+
onClick: () => {
|
|
2887
|
+
intents.unfloatPane(paneId);
|
|
2888
|
+
},
|
|
2889
|
+
children: "⇤"
|
|
2890
|
+
}),
|
|
2891
|
+
jsx("button", {
|
|
2892
|
+
type: "button",
|
|
2893
|
+
className: css.iconButton,
|
|
2894
|
+
"aria-label": labels.closeFloat,
|
|
2895
|
+
"data-dockkit-float-close": paneId,
|
|
2896
|
+
onPointerDown: (event) => {
|
|
2897
|
+
event.stopPropagation();
|
|
2898
|
+
},
|
|
2899
|
+
onClick: () => {
|
|
2900
|
+
intents.closeTab(tab.id);
|
|
2901
|
+
},
|
|
2902
|
+
children: "✕"
|
|
2903
|
+
})
|
|
2904
|
+
]
|
|
2905
|
+
}),
|
|
2906
|
+
jsx("div", {
|
|
2907
|
+
className: css.floatBody,
|
|
2908
|
+
children: renderTab(tab)
|
|
2909
|
+
}),
|
|
2910
|
+
jsx("div", {
|
|
2911
|
+
className: css.floatResize,
|
|
2912
|
+
"data-dockkit-float-resize": paneId,
|
|
2913
|
+
onPointerDown: (event) => {
|
|
2914
|
+
drag("resize", paneId, event);
|
|
2915
|
+
}
|
|
2916
|
+
})
|
|
2917
|
+
]
|
|
2918
|
+
}, paneId);
|
|
2919
|
+
}) });
|
|
2920
|
+
}
|
|
2921
|
+
//#endregion
|
|
2922
|
+
export { DOCK_EDGE_FRACTION, DOCK_ZONES, DRAG_THRESHOLD, DockController, DockSurface, EMPTY_HISTORY, FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE, FloatLayer, MAX_DOCK_PANES, MIN_PANE_FRACTION, SPLIT_MINIMUMS, Sequencer, activeDockPaneId, applyOp, canSplit, canStepBack, canStepForward, clampSizes, containsPoint, createIdMinter, createInitialState, dividerSizes, dockPaneCount, dockPaneIds, findContentTab, findPaneContentTab, findParent, findTabPane, floatRectAt, getNode, getPane, getSplit, getTab, halvesFit, insertionIndex, isFocusOp, movedRect, passedThreshold, planAddTab, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, record, recordedOps, replay, resizedRect, stepBack, stepForward, topRightPaneId, zoneAt, zoneInRect, zoneSplit };
|
|
2923
|
+
|
|
2924
|
+
//# sourceMappingURL=index.js.map
|