@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/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