@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/package.json +18 -16
- package/src/DockView.tsx +312 -0
- package/src/TilesView.tsx +133 -0
- package/src/dock.test.ts +482 -0
- package/src/dock.ts +484 -0
- package/src/index.ts +60 -0
- package/src/model.ts +26 -12
- package/src/tiles.test.ts +262 -0
- package/src/tiles.ts +285 -0
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
108
|
+
const ma = mapPaneNode(a, id, fn);
|
|
95
109
|
if (ma) return { ...node, children: [ma, b] };
|
|
96
|
-
const mb =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
228
|
+
const root = mapPaneNode(m.root!, paneId, (leaf) => ({ ...(leaf as PaneLeaf<T>), data: next }));
|
|
215
229
|
return { ...m, root };
|
|
216
230
|
}
|
|
217
231
|
|