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