@nativedesktop/panes 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dock.ts ADDED
@@ -0,0 +1,484 @@
1
+ // Dock model: a split tree whose leaves are PANELS, and each panel holds a
2
+ // stack of tabs. It is exactly PaneModel<DockPanel<T>>, so ratios, directional
3
+ // focus, collapse-on-close and migration come from model.ts unchanged and this
4
+ // file only adds the panel/tab vocabulary on top.
5
+ //
6
+ // Same invariant as model.ts, and for the same reason: an op that changes
7
+ // nothing returns the SAME reference. Docking a panel where it already sits,
8
+ // activating the active tab or moving a tab to its current index all hand back
9
+ // the model they were given, so a drop handler that fires twice cannot loop a
10
+ // render+persist cycle.
11
+
12
+ import {
13
+ clampPaneRatio,
14
+ closePane,
15
+ emptyPanes,
16
+ findPaneNode,
17
+ mapPaneNode,
18
+ migratePanes,
19
+ paneLeaves,
20
+ parentPaneSplit,
21
+ seedPanes,
22
+ updatePane,
23
+ } from "./model.ts";
24
+ import type { PaneLeaf, PaneModel, PaneNode, SplitOrientation } from "./model.ts";
25
+
26
+ /** `closePanel` is `closePane`: a panel IS a pane leaf, so closing one
27
+ * collapses its split and hands the space to the sibling. */
28
+ export { closePane as closePanel } from "./model.ts";
29
+
30
+ export type DockZone = "left" | "right" | "top" | "bottom" | "center";
31
+ export type DockEdgeZone = Exclude<DockZone, "center">;
32
+
33
+ export interface DockTab<T> {
34
+ id: string;
35
+ title: string;
36
+ /** Platform icon name, passed straight to the tab child's `tabIcon`. */
37
+ icon?: string;
38
+ data: T;
39
+ }
40
+
41
+ export interface DockPanel<T> {
42
+ tabs: DockTab<T>[];
43
+ activeTabId: string;
44
+ }
45
+
46
+ export type DockModel<T> = PaneModel<DockPanel<T>>;
47
+
48
+ export interface DockTabLocation<T> {
49
+ panelId: string;
50
+ index: number;
51
+ tab: DockTab<T>;
52
+ }
53
+
54
+ /** Axis and side each edge zone splits on. Side 0 is the leading child of the
55
+ * <paned>, so left/top put the incoming panel first. */
56
+ const EDGE: Record<DockEdgeZone, { orientation: SplitOrientation; side: 0 | 1 }> = {
57
+ left: { orientation: "horizontal", side: 0 },
58
+ right: { orientation: "horizontal", side: 1 },
59
+ top: { orientation: "vertical", side: 0 },
60
+ bottom: { orientation: "vertical", side: 1 },
61
+ };
62
+
63
+ export function emptyDock<T>(): DockModel<T> {
64
+ return emptyPanes<DockPanel<T>>();
65
+ }
66
+
67
+ /** A panel over `tabs`, active tab defaulting to the first. An empty tab list
68
+ * is not a legal panel (closeTab collapses the panel instead), so callers get
69
+ * an empty stack back only if they ask for one. */
70
+ export function dockPanelOf<T>(tabs: readonly DockTab<T>[], activeTabId?: string): DockPanel<T> {
71
+ const copy = tabs.slice();
72
+ const active = activeTabId !== undefined && copy.some((t) => t.id === activeTabId) ? activeTabId : (copy[0]?.id ?? "");
73
+ return { tabs: copy, activeTabId: active };
74
+ }
75
+
76
+ /** Balanced horizontal dock over one panel per tab group. Groups with no tabs
77
+ * are dropped, so every leaf starts with at least one tab. */
78
+ export function seedDock<T>(groups: readonly (readonly DockTab<T>[])[], id?: (panel: DockPanel<T>, i: number) => string): DockModel<T> {
79
+ const panels = groups.filter((g) => g.length > 0).map((g) => dockPanelOf(g));
80
+ return seedPanes(panels, id);
81
+ }
82
+
83
+ export function dockPanels<T>(m: DockModel<T>): PaneLeaf<DockPanel<T>>[] {
84
+ return paneLeaves(m);
85
+ }
86
+
87
+ export function findDockPanel<T>(m: DockModel<T>, panelId: string): DockPanel<T> | undefined {
88
+ const node = findPaneNode(m.root, panelId);
89
+ return node && node.kind === "leaf" ? node.data : undefined;
90
+ }
91
+
92
+ export function findDockTab<T>(m: DockModel<T>, tabId: string): DockTabLocation<T> | undefined {
93
+ for (const leaf of paneLeaves(m)) {
94
+ const index = leaf.data.tabs.findIndex((t) => t.id === tabId);
95
+ if (index >= 0) return { panelId: leaf.id, index, tab: leaf.data.tabs[index]! };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ export function activeDockTab<T>(panel: DockPanel<T>): DockTab<T> | undefined {
101
+ return panel.tabs.find((t) => t.id === panel.activeTabId);
102
+ }
103
+
104
+ /** Index of the active tab, for <tabview>'s selectedIndex. Falls back to 0 so
105
+ * a panel whose active id went stale still renders a selected tab. */
106
+ export function activeDockTabIndex<T>(panel: DockPanel<T>): number {
107
+ const index = panel.tabs.findIndex((t) => t.id === panel.activeTabId);
108
+ return index < 0 ? 0 : index;
109
+ }
110
+
111
+ function clampIndex(index: number, max: number): number {
112
+ if (!Number.isFinite(index)) return max;
113
+ return Math.min(max, Math.max(0, Math.trunc(index)));
114
+ }
115
+
116
+ /** Drops `tabId` from `panel`. Returns undefined when that was the last tab,
117
+ * which is the caller's signal to collapse the panel instead. */
118
+ function withoutTab<T>(panel: DockPanel<T>, tabId: string): DockPanel<T> | undefined {
119
+ const index = panel.tabs.findIndex((t) => t.id === tabId);
120
+ if (index < 0) return panel;
121
+ const tabs = panel.tabs.slice();
122
+ tabs.splice(index, 1);
123
+ if (tabs.length === 0) return undefined;
124
+ // Closing the active tab hands over to the one that slid into its place,
125
+ // and to the one before it when the last tab in the stack went.
126
+ const activeTabId = panel.activeTabId === tabId ? (tabs[index] ?? tabs[index - 1]!).id : panel.activeTabId;
127
+ return { tabs, activeTabId };
128
+ }
129
+
130
+ function withTab<T>(panel: DockPanel<T>, tab: DockTab<T>, index: number, activate: boolean): DockPanel<T> {
131
+ const tabs = panel.tabs.slice();
132
+ tabs.splice(clampIndex(index, tabs.length), 0, tab);
133
+ return { tabs, activeTabId: activate ? tab.id : panel.activeTabId };
134
+ }
135
+
136
+ function setPanel<T>(m: DockModel<T>, panelId: string, panel: DockPanel<T>): DockModel<T> {
137
+ const root = mapPaneNode(m.root!, panelId, (leaf) => ({ ...(leaf as PaneLeaf<DockPanel<T>>), data: panel }));
138
+ return { ...m, root };
139
+ }
140
+
141
+ /** Inserts `node` beside the leaf `targetId` on `zone`'s side, minting the
142
+ * split id from nextId the way splitPane does. */
143
+ function insertBeside<T>(
144
+ m: DockModel<T>,
145
+ targetId: string,
146
+ zone: DockEdgeZone,
147
+ node: PaneNode<DockPanel<T>>,
148
+ ): DockModel<T> {
149
+ const { orientation, side } = EDGE[zone];
150
+ const root = mapPaneNode(m.root!, targetId, (target) => ({
151
+ kind: "split",
152
+ id: `s${m.nextId}`,
153
+ orientation,
154
+ ratio: 0.5,
155
+ children: side === 0 ? [node, target] : [target, node],
156
+ }));
157
+ return { root, focusedId: m.focusedId, nextId: m.nextId + 1 };
158
+ }
159
+
160
+ /** True when `panelId` is already the immediate `zone`-side neighbour of
161
+ * `targetId`. Re-docking there would rebuild an identical tree under a fresh
162
+ * split id, so this is what keeps a repeated drop reference-stable. */
163
+ function alreadyDocked<T>(m: DockModel<T>, panelId: string, targetId: string, zone: DockEdgeZone): boolean {
164
+ const parent = parentPaneSplit(m.root, targetId);
165
+ if (!parent) return false;
166
+ const { orientation, side } = EDGE[zone];
167
+ if (parent.orientation !== orientation) return false;
168
+ return parent.children[side].id === panelId && parent.children[side === 0 ? 1 : 0].id === targetId;
169
+ }
170
+
171
+ /** Adds a tab to an existing panel and activates it. A tab id already used
172
+ * anywhere in the dock is rejected (same reference), since every op here
173
+ * addresses tabs by id. */
174
+ export function addTab<T>(m: DockModel<T>, panelId: string, tab: DockTab<T>, index?: number): DockModel<T> {
175
+ const panel = findDockPanel(m, panelId);
176
+ if (!panel || findDockTab(m, tab.id)) return m;
177
+ return setPanel(m, panelId, withTab(panel, tab, index ?? panel.tabs.length, true));
178
+ }
179
+
180
+ /** Closes a tab; the last tab in a panel closes the panel, which collapses
181
+ * the split and gives the space back to the sibling. */
182
+ export function closeTab<T>(m: DockModel<T>, tabId: string): DockModel<T> {
183
+ const found = findDockTab(m, tabId);
184
+ if (!found) return m;
185
+ const panel = findDockPanel(m, found.panelId)!;
186
+ const next = withoutTab(panel, tabId);
187
+ return next ? setPanel(m, found.panelId, next) : closePane(m, found.panelId);
188
+ }
189
+
190
+ /** Selects a tab inside its own panel. Focus is a separate op (`focusPane`):
191
+ * activating a tab in a background panel should not steal the focus ring. */
192
+ export function activateTab<T>(m: DockModel<T>, tabId: string): DockModel<T> {
193
+ const found = findDockTab(m, tabId);
194
+ if (!found) return m;
195
+ const panel = findDockPanel(m, found.panelId)!;
196
+ if (panel.activeTabId === tabId) return m;
197
+ return setPanel(m, found.panelId, { tabs: panel.tabs, activeTabId: tabId });
198
+ }
199
+
200
+ /** Moves a tab to `index` of `targetPanelId`. Within one panel this is a
201
+ * reorder and leaves the active tab alone; across panels the tab is activated
202
+ * in its new home, which takes the focus with it, and a source panel left
203
+ * empty collapses. */
204
+ export function moveTab<T>(m: DockModel<T>, tabId: string, targetPanelId: string, index?: number): DockModel<T> {
205
+ const found = findDockTab(m, tabId);
206
+ const target = findDockPanel(m, targetPanelId);
207
+ if (!found || !target) return m;
208
+ const source = findDockPanel(m, found.panelId)!;
209
+
210
+ if (found.panelId === targetPanelId) {
211
+ const rest = source.tabs.slice();
212
+ rest.splice(found.index, 1);
213
+ const at = clampIndex(index ?? rest.length, rest.length);
214
+ if (at === found.index) return m;
215
+ rest.splice(at, 0, found.tab);
216
+ return setPanel(m, found.panelId, { tabs: rest, activeTabId: source.activeTabId });
217
+ }
218
+
219
+ const landed = setPanel(m, targetPanelId, withTab(target, found.tab, index ?? target.tabs.length, true));
220
+ const drained = withoutTab(source, tabId);
221
+ const next = drained ? setPanel(landed, found.panelId, drained) : closePane(landed, found.panelId);
222
+ return { ...next, focusedId: targetPanelId };
223
+ }
224
+
225
+ /** Pulls a tab out of its panel into a new panel beside it. A panel holding
226
+ * one tab has nothing to undock, so that is a same-reference no-op. */
227
+ export function undockTab<T>(m: DockModel<T>, tabId: string, zone: DockEdgeZone = "right"): DockModel<T> {
228
+ const found = findDockTab(m, tabId);
229
+ if (!found) return m;
230
+ const source = findDockPanel(m, found.panelId)!;
231
+ if (source.tabs.length < 2) return m;
232
+ const drained = setPanel(m, found.panelId, withoutTab(source, tabId)!);
233
+ const panelId = String(m.nextId);
234
+ const leaf: PaneLeaf<DockPanel<T>> = { kind: "leaf", id: panelId, data: dockPanelOf([found.tab]) };
235
+ const next = insertBeside(drained, found.panelId, zone, leaf);
236
+ return { ...next, focusedId: panelId };
237
+ }
238
+
239
+ /** Relocates a whole panel against another one. `center` merges its tabs into
240
+ * the target's stack; the four edges split the target and take that side.
241
+ * `applyDockDrop` routes a `dropped` event here. */
242
+ export function dockPanel<T>(m: DockModel<T>, panelId: string, targetPanelId: string, zone: DockZone): DockModel<T> {
243
+ if (panelId === targetPanelId) return m;
244
+ const source = findDockPanel(m, panelId);
245
+ const target = findDockPanel(m, targetPanelId);
246
+ if (!source || !target) return m;
247
+
248
+ if (zone === "center") {
249
+ const merged: DockPanel<T> = {
250
+ tabs: [...target.tabs, ...source.tabs],
251
+ activeTabId: source.activeTabId,
252
+ };
253
+ const next = closePane(setPanel(m, targetPanelId, merged), panelId);
254
+ return { ...next, focusedId: targetPanelId };
255
+ }
256
+
257
+ if (alreadyDocked(m, panelId, targetPanelId, zone)) return m;
258
+ const removed = closePane(m, panelId);
259
+ const leaf: PaneLeaf<DockPanel<T>> = { kind: "leaf", id: panelId, data: source };
260
+ const next = insertBeside(removed, targetPanelId, zone, leaf);
261
+ return { ...next, focusedId: panelId };
262
+ }
263
+
264
+ export interface DockRect {
265
+ x: number;
266
+ y: number;
267
+ width: number;
268
+ height: number;
269
+ }
270
+
271
+ /** Which zone a pointer at (px, py) lands in over `rect`. Pure geometry, so
272
+ * it lives with the model rather than in whichever backend eventually reports
273
+ * the drag: the outer `edge` fraction of each side is that side's zone, the
274
+ * middle is `center`. A point past an edge still names that edge, and a
275
+ * degenerate rect is all center. */
276
+ export function hitTestDockZone(rect: DockRect, px: number, py: number, edge = 0.25): DockZone {
277
+ if (!(rect.width > 0) || !(rect.height > 0) || !Number.isFinite(px) || !Number.isFinite(py)) return "center";
278
+ const band = Number.isFinite(edge) ? Math.min(0.5, Math.max(0.01, edge)) : 0.25;
279
+ const u = (px - rect.x) / rect.width;
280
+ const v = (py - rect.y) / rect.height;
281
+ const distance: [DockEdgeZone, number][] = [
282
+ ["left", u],
283
+ ["right", 1 - u],
284
+ ["top", v],
285
+ ["bottom", 1 - v],
286
+ ];
287
+ let nearest = distance[0]!;
288
+ for (const candidate of distance) {
289
+ if (candidate[1] < nearest[1]) nearest = candidate;
290
+ }
291
+ return nearest[1] < band ? nearest[0] : "center";
292
+ }
293
+
294
+ // ============================================================================
295
+ // Drag and drop
296
+ // ============================================================================
297
+
298
+ /** Pixel extent of the whole dock surface. Widget drops report a point in the
299
+ * TARGET panel's own coordinate space, and nothing on the wire says how big
300
+ * that panel is, so the panel rects are derived here from the split ratios,
301
+ * which are fractions of this. Feed it from whatever sizes the dock (a
302
+ * window's sizeChanged, say). Without it every drop resolves to `center`. */
303
+ export interface DockSize {
304
+ width: number;
305
+ height: number;
306
+ }
307
+
308
+ export type DockDragKind = "tab" | "panel";
309
+
310
+ export interface DockDrag {
311
+ kind: DockDragKind;
312
+ id: string;
313
+ }
314
+
315
+ const DRAG_PREFIX = "nd-dock:";
316
+
317
+ /** The `dragPayload` a dock drag source carries. Namespaced because the drop
318
+ * side also receives plain text dragged in from other applications (both
319
+ * backends carry the payload as a system string type), and that must not be
320
+ * read as a tab id. */
321
+ export function dockDragPayload(kind: DockDragKind, id: string): string {
322
+ return `${DRAG_PREFIX}${kind}:${id}`;
323
+ }
324
+
325
+ /** The inverse, and the guard: anything not minted by dockDragPayload is
326
+ * undefined, which every drop path treats as "not ours, ignore". */
327
+ export function parseDockDrag(payload: string): DockDrag | undefined {
328
+ if (!payload.startsWith(DRAG_PREFIX)) return undefined;
329
+ const rest = payload.slice(DRAG_PREFIX.length);
330
+ const sep = rest.indexOf(":");
331
+ if (sep <= 0) return undefined;
332
+ const kind = rest.slice(0, sep);
333
+ const id = rest.slice(sep + 1);
334
+ if (id.length === 0 || (kind !== "tab" && kind !== "panel")) return undefined;
335
+ return { kind, id };
336
+ }
337
+
338
+ /** Every panel's rect inside a dock of `size`, from the split ratios alone.
339
+ * Approximate by exactly the divider thickness the platform draws between
340
+ * two panes, which is a couple of pixels against a zone band of a quarter of
341
+ * the panel. */
342
+ export function dockPanelRects<T>(m: DockModel<T>, size: DockSize): Map<string, DockRect> {
343
+ const out = new Map<string, DockRect>();
344
+ const width = Number.isFinite(size.width) ? Math.max(0, size.width) : 0;
345
+ const height = Number.isFinite(size.height) ? Math.max(0, size.height) : 0;
346
+
347
+ function walk(node: PaneNode<DockPanel<T>>, rect: DockRect): void {
348
+ if (node.kind === "leaf") {
349
+ out.set(node.id, rect);
350
+ return;
351
+ }
352
+ const ratio = clampPaneRatio(node.ratio);
353
+ if (node.orientation === "horizontal") {
354
+ const lead = rect.width * ratio;
355
+ walk(node.children[0], { x: rect.x, y: rect.y, width: lead, height: rect.height });
356
+ walk(node.children[1], { x: rect.x + lead, y: rect.y, width: rect.width - lead, height: rect.height });
357
+ return;
358
+ }
359
+ const lead = rect.height * ratio;
360
+ walk(node.children[0], { x: rect.x, y: rect.y, width: rect.width, height: lead });
361
+ walk(node.children[1], { x: rect.x, y: rect.y + lead, width: rect.width, height: rect.height - lead });
362
+ }
363
+
364
+ if (m.root) walk(m.root, { x: 0, y: 0, width, height });
365
+ return out;
366
+ }
367
+
368
+ /** Zone a drop point over `panelId` lands in. `x`/`y` are the panel-local
369
+ * coordinates a dragOver/dropped event reports, so the hit test runs against
370
+ * a rect at the origin: only the panel's extent matters, not where it sits.
371
+ * No size, or a panel that is not in the model, means `center`, which is the
372
+ * one zone that needs no geometry. */
373
+ export function dockZoneAt<T>(
374
+ m: DockModel<T>,
375
+ panelId: string,
376
+ x: number,
377
+ y: number,
378
+ size?: DockSize,
379
+ edge?: number,
380
+ ): DockZone {
381
+ if (!size) return "center";
382
+ const rect = dockPanelRects(m, size).get(panelId);
383
+ if (!rect) return "center";
384
+ return hitTestDockZone({ x: 0, y: 0, width: rect.width, height: rect.height }, x, y, edge);
385
+ }
386
+
387
+ /** Turns one drop into one model op, and it is the whole drop policy:
388
+ *
389
+ * - a panel drag is `dockPanel`, merging into the target's tab stack on
390
+ * `center` and splitting the target on an edge;
391
+ * - a tab dropped on `center` is `moveTab` into the target's stack;
392
+ * - a tab dropped on an edge is `moveTab` followed by `undockTab`, which
393
+ * lands it in a new panel on that side. A tab that is the only one in its
394
+ * panel takes the `dockPanel` path instead: the panel IS that tab, so
395
+ * moving it keeps its id (and its already-docked no-op) rather than
396
+ * collapsing the panel and minting a new one.
397
+ *
398
+ * A drag that ends where it started returns the SAME reference, so a drop is
399
+ * free to fire against a layout it does not change. */
400
+ export function applyDockDrop<T>(
401
+ m: DockModel<T>,
402
+ payload: string,
403
+ targetPanelId: string,
404
+ zone: DockZone,
405
+ ): DockModel<T> {
406
+ const drag = parseDockDrag(payload);
407
+ if (!drag) return m;
408
+ if (drag.kind === "panel") return dockPanel(m, drag.id, targetPanelId, zone);
409
+
410
+ const found = findDockTab(m, drag.id);
411
+ if (!found || !findDockPanel(m, targetPanelId)) return m;
412
+ const source = findDockPanel(m, found.panelId)!;
413
+
414
+ if (zone === "center") {
415
+ return found.panelId === targetPanelId ? m : moveTab(m, drag.id, targetPanelId);
416
+ }
417
+ if (source.tabs.length === 1) return dockPanel(m, found.panelId, targetPanelId, zone);
418
+ if (found.panelId === targetPanelId) return undockTab(m, drag.id, zone);
419
+ return undockTab(moveTab(m, drag.id, targetPanelId), drag.id, zone);
420
+ }
421
+
422
+ /** JSON-safe deep copy of the layout. The model is already plain data, so the
423
+ * copy is the point: a store flush that lands after further edits persists the
424
+ * layout as it was when serialized. */
425
+ export function serializeDock<T>(m: DockModel<T>): DockModel<T> {
426
+ function copy(node: PaneNode<DockPanel<T>> | undefined): PaneNode<DockPanel<T>> | undefined {
427
+ if (!node) return undefined;
428
+ if (node.kind === "leaf") {
429
+ const data: DockPanel<T> = {
430
+ tabs: node.data.tabs.map((tab) => ({ ...tab })),
431
+ activeTabId: node.data.activeTabId,
432
+ };
433
+ return { kind: "leaf", id: node.id, data };
434
+ }
435
+ return {
436
+ kind: "split",
437
+ id: node.id,
438
+ orientation: node.orientation,
439
+ ratio: node.ratio,
440
+ children: [copy(node.children[0])!, copy(node.children[1])!],
441
+ };
442
+ }
443
+ return { root: copy(m.root), focusedId: m.focusedId, nextId: m.nextId };
444
+ }
445
+
446
+ function normalizePanel<T>(raw: unknown, isData?: (d: unknown) => d is T): DockPanel<T> | undefined {
447
+ if (typeof raw !== "object" || raw === null) return undefined;
448
+ const panel = raw as { tabs?: unknown; activeTabId?: unknown };
449
+ if (!Array.isArray(panel.tabs) || panel.tabs.length === 0) return undefined;
450
+ const tabs: DockTab<T>[] = [];
451
+ for (const entry of panel.tabs) {
452
+ if (typeof entry !== "object" || entry === null) return undefined;
453
+ const tab = entry as { id?: unknown; title?: unknown; icon?: unknown; data?: unknown };
454
+ if (typeof tab.id !== "string" || tab.id.length === 0) return undefined;
455
+ if (typeof tab.title !== "string") return undefined;
456
+ if (isData && !isData(tab.data)) return undefined;
457
+ const next: DockTab<T> = { id: tab.id, title: tab.title, data: tab.data as T };
458
+ if (typeof tab.icon === "string") next.icon = tab.icon;
459
+ tabs.push(next);
460
+ }
461
+ const active = typeof panel.activeTabId === "string" ? panel.activeTabId : "";
462
+ return { tabs, activeTabId: tabs.some((t) => t.id === active) ? active : tabs[0]!.id };
463
+ }
464
+
465
+ /** Rebuilds a dock from persisted unknown data, on migratePanes' terms:
466
+ * anything malformed rejects the whole tree back to emptyDock(). A panel with
467
+ * no tabs, a tab without an id, tab data failing `isData` and a tab id used
468
+ * twice all count as malformed; an activeTabId naming a tab that is gone is
469
+ * repaired to the panel's first tab instead. */
470
+ export function deserializeDock<T>(raw: unknown, isData?: (d: unknown) => d is T): DockModel<T> {
471
+ const guard = (d: unknown): d is DockPanel<T> => normalizePanel(d, isData) !== undefined;
472
+ const migrated = migratePanes<DockPanel<T>>(raw, guard);
473
+ const seen = new Set<string>();
474
+ let model = migrated;
475
+ for (const leaf of paneLeaves(migrated)) {
476
+ const panel = normalizePanel<T>(leaf.data, isData)!;
477
+ for (const tab of panel.tabs) {
478
+ if (seen.has(tab.id)) return emptyDock<T>();
479
+ seen.add(tab.id);
480
+ }
481
+ model = updatePane(model, leaf.id, () => panel);
482
+ }
483
+ return model;
484
+ }
package/src/index.ts CHANGED
@@ -17,3 +17,63 @@ export {
17
17
  export type { PaneLeaf, PaneModel, PaneNode, PaneSplit, SplitOrientation } from "./model.ts";
18
18
  export { PaneTree, usePaneTree } from "./PaneTree.tsx";
19
19
  export type { PaneTreeProps, UsePaneTree } from "./PaneTree.tsx";
20
+ export {
21
+ activateTab,
22
+ activeDockTab,
23
+ activeDockTabIndex,
24
+ addTab,
25
+ applyDockDrop,
26
+ closePanel,
27
+ closeTab,
28
+ deserializeDock,
29
+ dockDragPayload,
30
+ dockPanel,
31
+ dockPanelOf,
32
+ dockPanelRects,
33
+ dockPanels,
34
+ dockZoneAt,
35
+ emptyDock,
36
+ findDockPanel,
37
+ findDockTab,
38
+ hitTestDockZone,
39
+ moveTab,
40
+ parseDockDrag,
41
+ seedDock,
42
+ serializeDock,
43
+ undockTab,
44
+ } from "./dock.ts";
45
+ export type {
46
+ DockDrag,
47
+ DockDragKind,
48
+ DockEdgeZone,
49
+ DockModel,
50
+ DockPanel,
51
+ DockRect,
52
+ DockSize,
53
+ DockTab,
54
+ DockTabLocation,
55
+ DockZone,
56
+ } from "./dock.ts";
57
+ export { DockView, useDock } from "./DockView.tsx";
58
+ export type { DockDragProps, DockPanelContext, DockTabContext, DockViewProps, UseDock } from "./DockView.tsx";
59
+ export {
60
+ applyTileDrop,
61
+ deserializeTiles,
62
+ emptyTiles,
63
+ findTile,
64
+ moveTile,
65
+ parseTileDrag,
66
+ placeTile,
67
+ raiseTile,
68
+ removeTile,
69
+ resizeTile,
70
+ seedTiles,
71
+ serializeTiles,
72
+ tileCellAt,
73
+ tileDragPayload,
74
+ tileRows,
75
+ updateTile,
76
+ } from "./tiles.ts";
77
+ export type { Tile, TileCell, TileModel, TilePlacement, TileSize } from "./tiles.ts";
78
+ export { TilesView, useTiles } from "./TilesView.tsx";
79
+ export type { TileContext, TilesViewProps, UseTiles } from "./TilesView.tsx";
package/src/model.ts CHANGED
@@ -74,26 +74,40 @@ export function seedPanes<T>(leaves: readonly T[], id?: (data: T, i: number) =>
74
74
  return { root, focusedId: leafNodes[0]!.id, nextId: counter };
75
75
  }
76
76
 
77
- function findNode<T>(node: PaneNode<T> | undefined, id: string): PaneNode<T> | undefined {
77
+ /** Package-internal tree access: dock.ts builds panel surgery (edge docking,
78
+ * undocking a tab into a new panel) on the same path-copying walk the split
79
+ * ops use, rather than a second copy of it. Not part of the public API. */
80
+ export function findPaneNode<T>(node: PaneNode<T> | undefined, id: string): PaneNode<T> | undefined {
78
81
  if (!node) return undefined;
79
82
  if (node.id === id) return node;
80
- if (node.kind === "split") return findNode(node.children[0], id) ?? findNode(node.children[1], id);
83
+ if (node.kind === "split") return findPaneNode(node.children[0], id) ?? findPaneNode(node.children[1], id);
81
84
  return undefined;
82
85
  }
83
86
 
87
+ /** The split holding `id`, or undefined when `id` is the root or absent. */
88
+ export function parentPaneSplit<T>(root: PaneNode<T> | undefined, id: string): PaneSplit<T> | undefined {
89
+ if (!root || root.kind === "leaf") return undefined;
90
+ if (root.children[0].id === id || root.children[1].id === id) return root;
91
+ return parentPaneSplit(root.children[0], id) ?? parentPaneSplit(root.children[1], id);
92
+ }
93
+
84
94
  function firstLeaf<T>(node: PaneNode<T>): PaneLeaf<T> {
85
95
  return node.kind === "leaf" ? node : firstLeaf(node.children[0]);
86
96
  }
87
97
 
88
98
  /** Path-copies the subtree containing `id`, applying `fn` to that node.
89
99
  * Returns undefined when `id` is not in the tree. */
90
- function mapNode<T>(node: PaneNode<T>, id: string, fn: (node: PaneNode<T>) => PaneNode<T>): PaneNode<T> | undefined {
100
+ export function mapPaneNode<T>(
101
+ node: PaneNode<T>,
102
+ id: string,
103
+ fn: (node: PaneNode<T>) => PaneNode<T>,
104
+ ): PaneNode<T> | undefined {
91
105
  if (node.id === id) return fn(node);
92
106
  if (node.kind === "leaf") return undefined;
93
107
  const [a, b] = node.children;
94
- const ma = mapNode(a, id, fn);
108
+ const ma = mapPaneNode(a, id, fn);
95
109
  if (ma) return { ...node, children: [ma, b] };
96
- const mb = mapNode(b, id, fn);
110
+ const mb = mapPaneNode(b, id, fn);
97
111
  if (mb) return { ...node, children: [a, mb] };
98
112
  return undefined;
99
113
  }
@@ -107,11 +121,11 @@ export function splitPane<T>(
107
121
  data: T,
108
122
  id?: string,
109
123
  ): PaneModel<T> {
110
- const target = findNode(m.root, paneId);
124
+ const target = findPaneNode(m.root, paneId);
111
125
  if (!target || target.kind !== "leaf") return m;
112
126
  const newLeafId = id ?? String(m.nextId);
113
127
  const newLeaf: PaneLeaf<T> = { kind: "leaf", id: newLeafId, data };
114
- const root = mapNode(m.root!, paneId, (leaf) => ({
128
+ const root = mapPaneNode(m.root!, paneId, (leaf) => ({
115
129
  kind: "split",
116
130
  id: `s${m.nextId}`,
117
131
  orientation,
@@ -153,7 +167,7 @@ export function closePane<T>(m: PaneModel<T>, paneId: string): PaneModel<T> {
153
167
 
154
168
  export function focusPane<T>(m: PaneModel<T>, paneId: string): PaneModel<T> {
155
169
  if (m.focusedId === paneId) return m;
156
- const node = findNode(m.root, paneId);
170
+ const node = findPaneNode(m.root, paneId);
157
171
  if (!node || node.kind !== "leaf") return m;
158
172
  return { ...m, focusedId: paneId };
159
173
  }
@@ -198,20 +212,20 @@ export function focusNeighbor<T>(m: PaneModel<T>, dir: "left" | "right" | "up" |
198
212
  }
199
213
 
200
214
  export function setPaneRatio<T>(m: PaneModel<T>, splitId: string, ratio: number): PaneModel<T> {
201
- const node = findNode(m.root, splitId);
215
+ const node = findPaneNode(m.root, splitId);
202
216
  if (!node || node.kind !== "split") return m;
203
217
  const clamped = clampPaneRatio(ratio);
204
218
  if (clamped === node.ratio) return m;
205
- const root = mapNode(m.root!, splitId, (split) => ({ ...(split as PaneSplit<T>), ratio: clamped }));
219
+ const root = mapPaneNode(m.root!, splitId, (split) => ({ ...(split as PaneSplit<T>), ratio: clamped }));
206
220
  return { ...m, root };
207
221
  }
208
222
 
209
223
  export function updatePane<T>(m: PaneModel<T>, paneId: string, fn: (d: T) => T): PaneModel<T> {
210
- const node = findNode(m.root, paneId);
224
+ const node = findPaneNode(m.root, paneId);
211
225
  if (!node || node.kind !== "leaf") return m;
212
226
  const next = fn(node.data);
213
227
  if (Object.is(next, node.data)) return m;
214
- const root = mapNode(m.root!, paneId, (leaf) => ({ ...(leaf as PaneLeaf<T>), data: next }));
228
+ const root = mapPaneNode(m.root!, paneId, (leaf) => ({ ...(leaf as PaneLeaf<T>), data: next }));
215
229
  return { ...m, root };
216
230
  }
217
231