@deepseek-ai/dsh-client-ui-dockkit 0.1.5-alpha.1

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