@dimina-kit/electron-deck 0.1.0-dev.20260616024534 → 0.1.0-dev.20260616102751
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/README.md +2 -0
- package/dist/dock-react/dock-view.d.ts +51 -0
- package/dist/dock-react/dock-view.d.ts.map +1 -0
- package/dist/dock-react/drag-redock.d.ts +95 -0
- package/dist/dock-react/drag-redock.d.ts.map +1 -0
- package/dist/dock-react/index.d.ts +12 -0
- package/dist/dock-react/index.d.ts.map +1 -0
- package/dist/dock-react/index.js +671 -0
- package/dist/dock-react/index.js.map +1 -0
- package/dist/layout/index.d.ts +13 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +2 -0
- package/dist/layout/model.d.ts +16 -0
- package/dist/layout/model.d.ts.map +1 -0
- package/dist/layout/mutations.d.ts +31 -0
- package/dist/layout/mutations.d.ts.map +1 -0
- package/dist/layout/registry.d.ts +6 -0
- package/dist/layout/registry.d.ts.map +1 -0
- package/dist/layout/serialize.d.ts +10 -0
- package/dist/layout/serialize.d.ts.map +1 -0
- package/dist/layout/types.d.ts +83 -0
- package/dist/layout/types.d.ts.map +1 -0
- package/dist/layout-CZtF0auJ.js +522 -0
- package/dist/layout-CZtF0auJ.js.map +1 -0
- package/dist/types.d.ts +52 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +26 -5
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { a as movePanel, c as setSizes, l as splitPanel, n as closePanel, o as setActive, r as extractPanel } from "../layout-CZtF0auJ.js";
|
|
2
|
+
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Group, Panel, Separator } from "react-resizable-panels";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/dock-react/drag-redock.ts
|
|
6
|
+
/**
|
|
7
|
+
* Classify a pointer position (RELATIVE to the rect's top-left; `(0,0)` is the
|
|
8
|
+
* corner) into a drop zone.
|
|
9
|
+
*
|
|
10
|
+
* Band thickness = `edgeFraction * min(width, height)` — a single symmetric band
|
|
11
|
+
* width derived from the SHORTER side so a wide/short panel doesn't get an
|
|
12
|
+
* absurdly fat horizontal band. A point in NO band is `center` (the tab-join
|
|
13
|
+
* interior). A point in TWO bands (a corner) tie-breaks by NORMALIZED distance
|
|
14
|
+
* to each active edge; on an exact tie HORIZONTAL wins. A point OUTSIDE the rect
|
|
15
|
+
* clamps to the nearest edge zone (per-axis; diagonal corners pick the larger
|
|
16
|
+
* overshoot, horizontal breaking ties).
|
|
17
|
+
*/
|
|
18
|
+
function computeDropZone(rect, point, edgeFraction = .25) {
|
|
19
|
+
const { width, height } = rect;
|
|
20
|
+
const { x, y } = point;
|
|
21
|
+
if (!(width > 0) || !(height > 0) || !Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return "center";
|
|
22
|
+
const ef = Number.isFinite(edgeFraction) ? Math.max(0, Math.min(.5, edgeFraction)) : .25;
|
|
23
|
+
const outX = x < 0 ? x : x > width ? x - width : 0;
|
|
24
|
+
const outY = y < 0 ? y : y > height ? y - height : 0;
|
|
25
|
+
if (outX !== 0 || outY !== 0) {
|
|
26
|
+
const magX = Math.abs(outX);
|
|
27
|
+
if (magX >= Math.abs(outY)) {
|
|
28
|
+
if (magX > 0) return outX < 0 ? "left" : "right";
|
|
29
|
+
}
|
|
30
|
+
return outY < 0 ? "top" : "bottom";
|
|
31
|
+
}
|
|
32
|
+
const band = ef * Math.min(width, height);
|
|
33
|
+
const inLeft = x < band;
|
|
34
|
+
const inRight = x > width - band;
|
|
35
|
+
const inTop = y < band;
|
|
36
|
+
const inBottom = y > height - band;
|
|
37
|
+
const activeHoriz = inLeft || inRight;
|
|
38
|
+
const activeVert = inTop || inBottom;
|
|
39
|
+
if (!activeHoriz && !activeVert) return "center";
|
|
40
|
+
if (activeHoriz && !activeVert) return inLeft ? "left" : "right";
|
|
41
|
+
if (activeVert && !activeHoriz) return inTop ? "top" : "bottom";
|
|
42
|
+
if ((inLeft ? x / width : (width - x) / width) <= (inTop ? y / height : (height - y) / height)) return inLeft ? "left" : "right";
|
|
43
|
+
return inTop ? "top" : "bottom";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Translate a drop zone into an engine-neutral re-dock descriptor.
|
|
47
|
+
*
|
|
48
|
+
* center => move the dragged panel into the target's tab group.
|
|
49
|
+
* left/right => split the target panel horizontally (row), dragged before/after.
|
|
50
|
+
* top/bottom => split the target panel vertically (column), dragged before/after.
|
|
51
|
+
*/
|
|
52
|
+
function dropZoneToMutation(zone, dragged, target) {
|
|
53
|
+
if (zone === "center") return {
|
|
54
|
+
kind: "move",
|
|
55
|
+
panelId: dragged,
|
|
56
|
+
destGroupId: target.groupId
|
|
57
|
+
};
|
|
58
|
+
const dir = zone === "left" || zone === "right" ? "row" : "column";
|
|
59
|
+
const side = zone === "left" || zone === "top" ? "before" : "after";
|
|
60
|
+
return {
|
|
61
|
+
kind: "split",
|
|
62
|
+
atPanelId: target.panelId,
|
|
63
|
+
dir,
|
|
64
|
+
side,
|
|
65
|
+
newPanelId: dragged
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A re-dock is a NO-OP when it would not change the tree:
|
|
70
|
+
*
|
|
71
|
+
* 1. The drop targets the dragged panel ITSELF (`dragged === target.panelId`),
|
|
72
|
+
* in ANY zone — you can neither tab a panel onto its own tab nor split a
|
|
73
|
+
* panel around itself. Crucially this also guards the SPLIT case: without
|
|
74
|
+
* it, `extractPanel(dragged)` then `splitPanel(atPanelId = dragged)` removes
|
|
75
|
+
* the very anchor it then splits at and THROWS (the self-collapse / single-
|
|
76
|
+
* panel-self-split bug).
|
|
77
|
+
* 2. A `center` drop into a group the dragged panel ALREADY lives in
|
|
78
|
+
* (`draggedGroupId === target.groupId`) — `movePanel` would re-append it and
|
|
79
|
+
* bump the revision for no visible change (churn).
|
|
80
|
+
*
|
|
81
|
+
* A SPLIT onto a DIFFERENT panel of the dragged panel's own group is still a real
|
|
82
|
+
* re-dock (it splits the group around that sibling), so it is NOT a no-op.
|
|
83
|
+
*
|
|
84
|
+
* `draggedGroupId` is the id of the tab group currently holding `dragged`
|
|
85
|
+
* (`undefined` if it is not in the tree).
|
|
86
|
+
*/
|
|
87
|
+
function isNoopRedock(dragged, draggedGroupId, target, zone) {
|
|
88
|
+
if (dragged === target.panelId) return true;
|
|
89
|
+
if (zone === "center" && draggedGroupId !== void 0 && draggedGroupId === target.groupId) return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/dock-react/dock-view.tsx
|
|
94
|
+
/**
|
|
95
|
+
* `<DockView>` — React renderer for a layout-as-data tree.
|
|
96
|
+
*
|
|
97
|
+
* Pure function of the current model snapshot (+ registry + the two host
|
|
98
|
+
* callbacks). It owns NO layout state: it subscribes to the `LayoutModel`,
|
|
99
|
+
* keeps the latest tree in component state, and re-renders on every emission.
|
|
100
|
+
* All structural targeting is via STABLE `data-*` attributes — see the
|
|
101
|
+
* contract doc-block in `dock-view.test.tsx`.
|
|
102
|
+
*
|
|
103
|
+
* This file lives under `src/dock-react/` (NOT `src/layout/`), so importing
|
|
104
|
+
* react here does not violate the pure-TS layout boundary.
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* The dataTransfer MIME under which a drag carries the dragged panel id. A
|
|
108
|
+
* custom type (vs `text/plain`) keeps deck drags from being confused with
|
|
109
|
+
* arbitrary text drops; the panel id also stays recoverable from the source
|
|
110
|
+
* tab's `data-deck-tab` attribute (the jsdom seam relies on the latter).
|
|
111
|
+
*/
|
|
112
|
+
var DRAG_PANEL_MIME = "application/x-deck-panel";
|
|
113
|
+
/** Normalize raw split weights to percentages summing ~100 for `defaultSize`. */
|
|
114
|
+
function toPercentages(sizes) {
|
|
115
|
+
const total = sizes.reduce((a, b) => a + (b > 0 ? b : 0), 0);
|
|
116
|
+
if (total <= 0) {
|
|
117
|
+
const even = sizes.length > 0 ? 100 / sizes.length : 100;
|
|
118
|
+
return sizes.map(() => even);
|
|
119
|
+
}
|
|
120
|
+
return sizes.map((s) => (s > 0 ? s : 0) / total * 100);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Compute the `defaultSize` percentage for each FLEXIBLE (unconstrained) child,
|
|
124
|
+
* keyed by its ORIGINAL child index. Fixed (px-pinned) children are excluded
|
|
125
|
+
* from the pool entirely so their weight never pollutes the flexible siblings'
|
|
126
|
+
* normalization (FIX E1). `constraints[i]` non-null ⇒ child i is fixed and is
|
|
127
|
+
* absent from the returned map.
|
|
128
|
+
*/
|
|
129
|
+
function computeFlexiblePercentages(sizes, constraints) {
|
|
130
|
+
const flexibleIndices = sizes.map((_, i) => i).filter((i) => (constraints?.[i] ?? null) === null);
|
|
131
|
+
const pct = toPercentages(flexibleIndices.map((i) => sizes[i] ?? 0));
|
|
132
|
+
const out = /* @__PURE__ */ new Map();
|
|
133
|
+
flexibleIndices.forEach((origIndex, j) => {
|
|
134
|
+
out.set(origIndex, pct[j]);
|
|
135
|
+
});
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function DockView(props) {
|
|
139
|
+
const { model, registry, renderDomPanel, bindNativeSlot } = props;
|
|
140
|
+
const [tree, setTree] = useState(() => model.get());
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setTree(model.get());
|
|
143
|
+
return model.subscribe((snap) => {
|
|
144
|
+
setTree(snap.tree);
|
|
145
|
+
});
|
|
146
|
+
}, [model]);
|
|
147
|
+
const handleActivate = useCallback((groupId, panelId) => {
|
|
148
|
+
model.apply((t) => setActive(t, groupId, panelId));
|
|
149
|
+
}, [model]);
|
|
150
|
+
const handleClose = useCallback((panelId) => {
|
|
151
|
+
model.apply((t) => closePanel(t, panelId));
|
|
152
|
+
}, [model]);
|
|
153
|
+
const canClose = countPanels(tree.root) > 1;
|
|
154
|
+
const isPanelInTree = useCallback((panelId) => findPanelGroupId(tree.root, panelId) !== void 0, [tree]);
|
|
155
|
+
const handleApplyLayout = useCallback((splitId, weights) => {
|
|
156
|
+
model.apply((t) => setSizes(t, splitId, weights));
|
|
157
|
+
}, [model]);
|
|
158
|
+
const handleRedock = useCallback((groupId, activePanelId, draggedPanelId, zone) => {
|
|
159
|
+
const target = {
|
|
160
|
+
groupId,
|
|
161
|
+
panelId: activePanelId
|
|
162
|
+
};
|
|
163
|
+
if (isNoopRedock(draggedPanelId, findPanelGroupId(model.get().root, draggedPanelId), target, zone)) return;
|
|
164
|
+
const mutation = dropZoneToMutation(zone, draggedPanelId, target);
|
|
165
|
+
if (mutation.kind === "move") {
|
|
166
|
+
model.apply((t) => movePanel(t, mutation.panelId, { groupId: mutation.destGroupId }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
model.apply((t) => {
|
|
170
|
+
const { tree } = extractPanel(t, mutation.newPanelId);
|
|
171
|
+
if (findPanelGroupId(tree.root, mutation.atPanelId) === void 0) return t;
|
|
172
|
+
return splitPanel(tree, mutation.atPanelId, mutation.dir, mutation.newPanelId, mutation.side);
|
|
173
|
+
});
|
|
174
|
+
}, [model]);
|
|
175
|
+
return /* @__PURE__ */ jsx(Fragment, { children: renderNode(tree.root, {
|
|
176
|
+
registry,
|
|
177
|
+
renderDomPanel,
|
|
178
|
+
bindNativeSlot,
|
|
179
|
+
onActivate: handleActivate,
|
|
180
|
+
onApplyLayout: handleApplyLayout,
|
|
181
|
+
onRedock: handleRedock,
|
|
182
|
+
onClose: handleClose,
|
|
183
|
+
canClose,
|
|
184
|
+
isPanelInTree
|
|
185
|
+
}) });
|
|
186
|
+
}
|
|
187
|
+
/** The id of the tab group currently holding `panelId`, or `undefined` if the
|
|
188
|
+
* panel is not in the tree. Used by `handleRedock` to detect a drop back into
|
|
189
|
+
* the dragged panel's own group (M2) and to guard a vanished split anchor (M1). */
|
|
190
|
+
function findPanelGroupId(node, panelId) {
|
|
191
|
+
if (node.kind === "tabs") return node.panels.includes(panelId) ? node.id : void 0;
|
|
192
|
+
for (const child of node.children) {
|
|
193
|
+
const found = findPanelGroupId(child, panelId);
|
|
194
|
+
if (found !== void 0) return found;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Total panels anywhere in the tree. Drives the last-panel close suppression:
|
|
198
|
+
* a GroupView only knows its own node, so DockView computes this global count
|
|
199
|
+
* once and threads the resulting `canClose` boolean down. */
|
|
200
|
+
function countPanels(node) {
|
|
201
|
+
if (node.kind === "tabs") return node.panels.length;
|
|
202
|
+
return node.children.reduce((sum, child) => sum + countPanels(child), 0);
|
|
203
|
+
}
|
|
204
|
+
function renderNode(node, ctx) {
|
|
205
|
+
return node.kind === "split" ? renderSplit(node, ctx) : renderGroup(node, ctx);
|
|
206
|
+
}
|
|
207
|
+
/** A split is a stateful component (it holds the rrp Group imperative ref + runs
|
|
208
|
+
* the M1 model→view sync effect), so render it via JSX with a STABLE key so
|
|
209
|
+
* React preserves that ref / its kept-alive subtree across model emissions
|
|
210
|
+
* rather than remounting on every snapshot. Mirrors `renderGroup`/`GroupView`. */
|
|
211
|
+
function renderSplit(node, ctx) {
|
|
212
|
+
return /* @__PURE__ */ jsx(SplitView, {
|
|
213
|
+
node,
|
|
214
|
+
ctx
|
|
215
|
+
}, node.id);
|
|
216
|
+
}
|
|
217
|
+
/** Epsilon (in percentage points) within which two split layouts are treated as
|
|
218
|
+
* equivalent. Guards BOTH sides of the model↔view loop: we skip pushing a
|
|
219
|
+
* `setLayout` when the live layout already matches the target, and skip the
|
|
220
|
+
* `onLayoutChanged` write-back when the incoming layout matches the model — so
|
|
221
|
+
* `setLayout`→`onLayoutChanged`→write-back→re-sync cannot loop. */
|
|
222
|
+
var LAYOUT_EPSILON = .5;
|
|
223
|
+
/** TIGHT tolerance (percentage points) for the BASIS-NORMALIZED flexible-ratio
|
|
224
|
+
* compare in `handleLayoutChanged`. We normalize the incoming layout's flexible
|
|
225
|
+
* subset to ratios summing to 100 and compare them against the model's flexible
|
|
226
|
+
* ratios (`computeFlexiblePercentages`, also summing to 100). If they match
|
|
227
|
+
* within this tolerance the `onLayoutChanged` is either our own `setLayout` echo
|
|
228
|
+
* OR a ratio-preserving spontaneous re-measure (mount / fixed-px re-pin /
|
|
229
|
+
* container resize) — SKIP the write-back. If they differ it is a genuine user
|
|
230
|
+
* resize (pointer OR keyboard) — WRITE BACK.
|
|
231
|
+
*
|
|
232
|
+
* Set to ~0.1pp: large enough to absorb rrp's ~3-decimal float noise on an echo,
|
|
233
|
+
* yet FAR below a real drag's delta. R1's "sub-0.5%" drag moves a panel ~0.33pp
|
|
234
|
+
* of the container; normalized over the two-flexible-child subset that is ~0.66pp
|
|
235
|
+
* of ratio — comfortably above 0.1, so it is NOT mistaken for an echo and is
|
|
236
|
+
* written back.
|
|
237
|
+
*
|
|
238
|
+
* CAVEAT: this is a flexible-RATIO tolerance (the flexible subset normalized to
|
|
239
|
+
* sum 100), NOT a container-%. It is safe against rrp's 3-decimal echo noise.
|
|
240
|
+
* In a pathologically WIDE split (~≥10 flexible children) a single arrow-key
|
|
241
|
+
* nudge (±~5% of the container on one child) can, once normalized over many
|
|
242
|
+
* flexible siblings, fall BELOW 0.1pp of ratio and be skipped — exotic and
|
|
243
|
+
* self-healing (the next, larger resize writes back). If that ever matters,
|
|
244
|
+
* scale the tolerance down by the flexible child count. */
|
|
245
|
+
var FLEX_RATIO_TOLERANCE = .1;
|
|
246
|
+
/** Are two panelId→percentage maps equivalent within `epsilon` percentage
|
|
247
|
+
* points? Both maps must cover EXACTLY the `ids` key set — a missing key OR an
|
|
248
|
+
* extra key (a key present in `a`/`b` but absent from `ids`) counts as NOT
|
|
249
|
+
* equivalent so the sync is not falsely suppressed. A non-finite value
|
|
250
|
+
* (NaN/±Infinity) is likewise NOT equivalent — `typeof NaN === 'number'` and
|
|
251
|
+
* `Math.abs(NaN - x) > eps` is `false`, so without the `Number.isFinite` guard a
|
|
252
|
+
* NaN would slip through as "equivalent" and wrongly suppress a legitimate
|
|
253
|
+
* sync/write-back (N1). EXPORTED (pure) for direct unit coverage. */
|
|
254
|
+
function layoutsEquivalent(a, b, ids, epsilon = LAYOUT_EPSILON) {
|
|
255
|
+
const idSet = new Set(ids);
|
|
256
|
+
for (const k of Object.keys(a)) if (!idSet.has(k)) return false;
|
|
257
|
+
for (const k of Object.keys(b)) if (!idSet.has(k)) return false;
|
|
258
|
+
for (const id of ids) {
|
|
259
|
+
const av = a[id];
|
|
260
|
+
const bv = b[id];
|
|
261
|
+
if (!Number.isFinite(av) || !Number.isFinite(bv)) return false;
|
|
262
|
+
if (Math.abs(av - bv) > epsilon) return false;
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Build the panel-ID→PERCENTAGE map for an imperative `setLayout`, given the
|
|
268
|
+
* model's full-length raw `sizes` (per child) and `constraints`:
|
|
269
|
+
*
|
|
270
|
+
* - FIXED (px-pinned) children keep their CURRENT measured percentage (read
|
|
271
|
+
* from the live `getLayout()`); they are NOT derived from weights, so a
|
|
272
|
+
* flexible-weights change never disturbs their pixel lock.
|
|
273
|
+
* - The REMAINING percentage (100 − Σ fixed%) is distributed across the
|
|
274
|
+
* FLEXIBLE children in proportion to their weights.
|
|
275
|
+
*
|
|
276
|
+
* Returns `null` when the map can't be built faithfully (e.g. `live` is empty —
|
|
277
|
+
* jsdom's stub — or a fixed child's live % is missing), so the caller skips the
|
|
278
|
+
* `setLayout` rather than pushing a corrupt total. The result always sums to
|
|
279
|
+
* ~100 over all children.
|
|
280
|
+
*/
|
|
281
|
+
function buildSetLayoutMap(childIds, sizes, constraints, live) {
|
|
282
|
+
const isFixedAt = (i) => (constraints?.[i] ?? null) !== null;
|
|
283
|
+
let fixedTotal = 0;
|
|
284
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
285
|
+
if (!isFixedAt(i)) continue;
|
|
286
|
+
const livePct = live[childIds[i]];
|
|
287
|
+
if (typeof livePct !== "number") return null;
|
|
288
|
+
fixedTotal += livePct;
|
|
289
|
+
}
|
|
290
|
+
const remaining = Math.max(0, 100 - fixedTotal);
|
|
291
|
+
let flexWeightTotal = 0;
|
|
292
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
293
|
+
if (isFixedAt(i)) continue;
|
|
294
|
+
const w = sizes[i] ?? 0;
|
|
295
|
+
flexWeightTotal += w > 0 ? w : 0;
|
|
296
|
+
}
|
|
297
|
+
const flexibleCount = childIds.length - childIds.filter((_, i) => isFixedAt(i)).length;
|
|
298
|
+
const out = {};
|
|
299
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
300
|
+
const id = childIds[i];
|
|
301
|
+
if (isFixedAt(i)) {
|
|
302
|
+
out[id] = live[id];
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (flexWeightTotal <= 0) {
|
|
306
|
+
out[id] = flexibleCount > 0 ? remaining / flexibleCount : remaining;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const w = sizes[i] ?? 0;
|
|
310
|
+
out[id] = remaining * (w > 0 ? w : 0) / flexWeightTotal;
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Extract an rrp `onLayoutChanged` map's FLEXIBLE subset (skipping fixed-px
|
|
316
|
+
* children) in child order and NORMALIZE it by its own sum to ratios summing to
|
|
317
|
+
* ~100 — the basis `computeFlexiblePercentages` already produces for the model,
|
|
318
|
+
* so the two are directly comparable. Returns the original child `indices`
|
|
319
|
+
* alongside the `ratios`. Null when the map is malformed (a flexible id is
|
|
320
|
+
* missing / non-finite) or there is no flexible child — the caller then never
|
|
321
|
+
* writes back from it.
|
|
322
|
+
*/
|
|
323
|
+
function incomingFlexRatios(childIds, constraints, layout) {
|
|
324
|
+
const indices = [];
|
|
325
|
+
const raw = [];
|
|
326
|
+
for (let i = 0; i < childIds.length; i++) {
|
|
327
|
+
if ((constraints?.[i] ?? null) !== null) continue;
|
|
328
|
+
const v = layout[childIds[i]];
|
|
329
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
330
|
+
indices.push(i);
|
|
331
|
+
raw.push(v > 0 ? v : 0);
|
|
332
|
+
}
|
|
333
|
+
if (indices.length === 0) return null;
|
|
334
|
+
return {
|
|
335
|
+
indices,
|
|
336
|
+
ratios: toPercentages(raw)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function SplitView(props) {
|
|
340
|
+
const { node, ctx } = props;
|
|
341
|
+
const orientation = node.orientation === "row" ? "horizontal" : "vertical";
|
|
342
|
+
const percentageByIndex = computeFlexiblePercentages(node.sizes, node.constraints);
|
|
343
|
+
const groupRef = useRef(null);
|
|
344
|
+
const nodeRef = useRef(node);
|
|
345
|
+
nodeRef.current = node;
|
|
346
|
+
const items = [];
|
|
347
|
+
node.children.forEach((child, i) => {
|
|
348
|
+
if (i > 0) items.push(/* @__PURE__ */ jsx(Separator, { "data-deck-resize-handle": "" }, `handle-${i}`));
|
|
349
|
+
const constraint = node.constraints?.[i] ?? null;
|
|
350
|
+
if (constraint) {
|
|
351
|
+
const px = `${constraint.fixedPx}px`;
|
|
352
|
+
items.push(/* @__PURE__ */ jsx(Panel, {
|
|
353
|
+
id: child.id,
|
|
354
|
+
defaultSize: px,
|
|
355
|
+
minSize: px,
|
|
356
|
+
maxSize: px,
|
|
357
|
+
groupResizeBehavior: "preserve-pixel-size",
|
|
358
|
+
children: renderNode(child, ctx)
|
|
359
|
+
}, panelKey(child)));
|
|
360
|
+
} else items.push(/* @__PURE__ */ jsx(Panel, {
|
|
361
|
+
id: child.id,
|
|
362
|
+
defaultSize: percentageByIndex.get(i),
|
|
363
|
+
children: renderNode(child, ctx)
|
|
364
|
+
}, panelKey(child)));
|
|
365
|
+
});
|
|
366
|
+
const handleLayoutChanged = (layout) => {
|
|
367
|
+
const cur = nodeRef.current;
|
|
368
|
+
const curChildIds = cur.children.map((c) => c.id);
|
|
369
|
+
const isFixedAt = (i) => (cur.constraints?.[i] ?? null) !== null;
|
|
370
|
+
const incoming = incomingFlexRatios(curChildIds, cur.constraints, layout);
|
|
371
|
+
if (!incoming) return;
|
|
372
|
+
const modelPctByIndex = computeFlexiblePercentages(cur.sizes, cur.constraints);
|
|
373
|
+
const modelNorm = incoming.indices.map((i) => modelPctByIndex.get(i) ?? 0);
|
|
374
|
+
if (!incoming.ratios.some((r, k) => Math.abs(r - modelNorm[k]) > FLEX_RATIO_TOLERANCE)) return;
|
|
375
|
+
const weights = curChildIds.map((id, i) => isFixedAt(i) ? cur.sizes[i] ?? 1 : layout[id]);
|
|
376
|
+
ctx.onApplyLayout(cur.id, weights);
|
|
377
|
+
};
|
|
378
|
+
const setSplitRef = (el) => {
|
|
379
|
+
if (el) {
|
|
380
|
+
el.__deckApplyLayout = (weights) => {
|
|
381
|
+
const cur = nodeRef.current;
|
|
382
|
+
const isFixedAt = (i) => (cur.constraints?.[i] ?? null) !== null;
|
|
383
|
+
const next = weights.map((w, i) => isFixedAt(i) ? cur.sizes[i] ?? 1 : w);
|
|
384
|
+
ctx.onApplyLayout(cur.id, next);
|
|
385
|
+
};
|
|
386
|
+
Object.defineProperty(el, "__deckGroupApi", {
|
|
387
|
+
configurable: true,
|
|
388
|
+
get: () => groupRef.current ?? void 0
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const runSync = useCallback(() => {
|
|
393
|
+
const api = groupRef.current;
|
|
394
|
+
if (!api) return false;
|
|
395
|
+
const cur = nodeRef.current;
|
|
396
|
+
const curChildIds = cur.children.map((c) => c.id);
|
|
397
|
+
const live = api.getLayout();
|
|
398
|
+
const targetMap = buildSetLayoutMap(curChildIds, cur.sizes, cur.constraints, live);
|
|
399
|
+
if (!targetMap) return false;
|
|
400
|
+
if (layoutsEquivalent(live, targetMap, curChildIds)) return false;
|
|
401
|
+
api.setLayout(targetMap);
|
|
402
|
+
return true;
|
|
403
|
+
}, []);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
runSync();
|
|
406
|
+
}, [node.sizes.join(","), runSync]);
|
|
407
|
+
return /* @__PURE__ */ jsx("div", {
|
|
408
|
+
ref: setSplitRef,
|
|
409
|
+
"data-deck-split": node.id,
|
|
410
|
+
"data-orientation": node.orientation,
|
|
411
|
+
"data-deck-sizes": node.sizes.join(","),
|
|
412
|
+
style: {
|
|
413
|
+
width: "100%",
|
|
414
|
+
height: "100%"
|
|
415
|
+
},
|
|
416
|
+
children: /* @__PURE__ */ jsx(Group, {
|
|
417
|
+
groupRef,
|
|
418
|
+
orientation,
|
|
419
|
+
onLayoutChanged: handleLayoutChanged,
|
|
420
|
+
style: {
|
|
421
|
+
width: "100%",
|
|
422
|
+
height: "100%"
|
|
423
|
+
},
|
|
424
|
+
children: items
|
|
425
|
+
})
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/** Stable React key for a child node (split id or group id). */
|
|
429
|
+
function panelKey(node) {
|
|
430
|
+
return node.id;
|
|
431
|
+
}
|
|
432
|
+
function renderGroup(node, ctx) {
|
|
433
|
+
return /* @__PURE__ */ jsx(GroupView, {
|
|
434
|
+
node,
|
|
435
|
+
ctx
|
|
436
|
+
}, node.id);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* One tab GROUP: tab strip (draggable tabs) + active body + the imperative
|
|
440
|
+
* `__deckHandleDrop` seam + a geometry-driven drop indicator while a drag hovers.
|
|
441
|
+
*/
|
|
442
|
+
function GroupView(props) {
|
|
443
|
+
const { node, ctx } = props;
|
|
444
|
+
const [dropZone, setDropZone] = useState(null);
|
|
445
|
+
const nodeRef = useRef(node);
|
|
446
|
+
nodeRef.current = node;
|
|
447
|
+
const redockRef = useRef(ctx.onRedock);
|
|
448
|
+
redockRef.current = ctx.onRedock;
|
|
449
|
+
const setGroupRef = (el) => {
|
|
450
|
+
if (el) el.__deckHandleDrop = (draggedPanelId, zone) => {
|
|
451
|
+
const current = nodeRef.current;
|
|
452
|
+
redockRef.current(current.id, current.active, draggedPanelId, zone);
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
const handleDragOver = (e) => {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
458
|
+
setDropZone(computeDropZone({
|
|
459
|
+
width: rect.width,
|
|
460
|
+
height: rect.height
|
|
461
|
+
}, {
|
|
462
|
+
x: e.clientX - rect.left,
|
|
463
|
+
y: e.clientY - rect.top
|
|
464
|
+
}));
|
|
465
|
+
};
|
|
466
|
+
const handleDragLeave = () => {
|
|
467
|
+
setDropZone(null);
|
|
468
|
+
};
|
|
469
|
+
const handleDrop = (e) => {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
472
|
+
const zone = computeDropZone({
|
|
473
|
+
width: rect.width,
|
|
474
|
+
height: rect.height
|
|
475
|
+
}, {
|
|
476
|
+
x: e.clientX - rect.left,
|
|
477
|
+
y: e.clientY - rect.top
|
|
478
|
+
});
|
|
479
|
+
setDropZone(null);
|
|
480
|
+
const dragged = e.dataTransfer.getData(DRAG_PANEL_MIME) || e.dataTransfer.getData("text/plain");
|
|
481
|
+
if (!dragged || ctx.registry.get(dragged) === void 0 || !ctx.isPanelInTree(dragged)) return;
|
|
482
|
+
ctx.onRedock(node.id, node.active, dragged, zone);
|
|
483
|
+
};
|
|
484
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
485
|
+
ref: setGroupRef,
|
|
486
|
+
"data-deck-group": node.id,
|
|
487
|
+
onDragOver: handleDragOver,
|
|
488
|
+
onDragLeave: handleDragLeave,
|
|
489
|
+
onDrop: handleDrop,
|
|
490
|
+
style: {
|
|
491
|
+
position: "relative",
|
|
492
|
+
display: "flex",
|
|
493
|
+
flexDirection: "column",
|
|
494
|
+
width: "100%",
|
|
495
|
+
height: "100%",
|
|
496
|
+
minWidth: 0,
|
|
497
|
+
minHeight: 0
|
|
498
|
+
},
|
|
499
|
+
children: [
|
|
500
|
+
/* @__PURE__ */ jsx("div", {
|
|
501
|
+
role: "tablist",
|
|
502
|
+
style: { flexShrink: 0 },
|
|
503
|
+
children: node.panels.map((panelId) => {
|
|
504
|
+
const active = panelId === node.active;
|
|
505
|
+
const title = ctx.registry.get(panelId)?.title ?? panelId;
|
|
506
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
507
|
+
type: "button",
|
|
508
|
+
role: "tab",
|
|
509
|
+
draggable: "true",
|
|
510
|
+
"data-deck-tab": panelId,
|
|
511
|
+
"data-active": active ? "true" : "false",
|
|
512
|
+
onDragStart: (e) => {
|
|
513
|
+
if (e.target instanceof Element && e.target.closest("[data-deck-tab-close]") !== null) {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
e.dataTransfer.setData(DRAG_PANEL_MIME, panelId);
|
|
518
|
+
e.dataTransfer.setData("text/plain", panelId);
|
|
519
|
+
e.dataTransfer.effectAllowed = "move";
|
|
520
|
+
},
|
|
521
|
+
onClick: () => {
|
|
522
|
+
if (!active) ctx.onActivate(node.id, panelId);
|
|
523
|
+
},
|
|
524
|
+
children: [title, ctx.canClose ? /* @__PURE__ */ jsx("span", {
|
|
525
|
+
role: "button",
|
|
526
|
+
tabIndex: 0,
|
|
527
|
+
"data-deck-tab-close": panelId,
|
|
528
|
+
"aria-label": `Close ${title}`,
|
|
529
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
530
|
+
onClick: (e) => {
|
|
531
|
+
e.stopPropagation();
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
ctx.onClose(panelId);
|
|
534
|
+
},
|
|
535
|
+
onKeyDown: (e) => {
|
|
536
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
537
|
+
e.stopPropagation();
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
ctx.onClose(panelId);
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
children: "×"
|
|
543
|
+
}) : null]
|
|
544
|
+
}, panelId);
|
|
545
|
+
})
|
|
546
|
+
}),
|
|
547
|
+
renderActiveBody(node, ctx),
|
|
548
|
+
dropZone ? /* @__PURE__ */ jsx(DropIndicator, { zone: dropZone }) : null
|
|
549
|
+
]
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Translate a drop zone into the indicator overlay's geometry. `center` covers
|
|
554
|
+
* the whole group (a tab-join highlight); each edge zone is a HALF-band ribbon
|
|
555
|
+
* pinned to that edge. Pure presentation — the host can re-skin via the
|
|
556
|
+
* `data-deck-drop-zone` attribute; these inline styles are a sane default.
|
|
557
|
+
*/
|
|
558
|
+
function dropIndicatorStyle(zone) {
|
|
559
|
+
const base = {
|
|
560
|
+
position: "absolute",
|
|
561
|
+
pointerEvents: "none",
|
|
562
|
+
background: "rgba(64, 128, 255, 0.25)",
|
|
563
|
+
outline: "2px solid rgba(64, 128, 255, 0.6)"
|
|
564
|
+
};
|
|
565
|
+
switch (zone) {
|
|
566
|
+
case "left": return {
|
|
567
|
+
...base,
|
|
568
|
+
left: "0",
|
|
569
|
+
top: "0",
|
|
570
|
+
width: "50%",
|
|
571
|
+
height: "100%"
|
|
572
|
+
};
|
|
573
|
+
case "right": return {
|
|
574
|
+
...base,
|
|
575
|
+
right: "0",
|
|
576
|
+
top: "0",
|
|
577
|
+
width: "50%",
|
|
578
|
+
height: "100%"
|
|
579
|
+
};
|
|
580
|
+
case "top": return {
|
|
581
|
+
...base,
|
|
582
|
+
left: "0",
|
|
583
|
+
top: "0",
|
|
584
|
+
width: "100%",
|
|
585
|
+
height: "50%"
|
|
586
|
+
};
|
|
587
|
+
case "bottom": return {
|
|
588
|
+
...base,
|
|
589
|
+
left: "0",
|
|
590
|
+
bottom: "0",
|
|
591
|
+
width: "100%",
|
|
592
|
+
height: "50%"
|
|
593
|
+
};
|
|
594
|
+
default: return {
|
|
595
|
+
...base,
|
|
596
|
+
left: "0",
|
|
597
|
+
top: "0",
|
|
598
|
+
width: "100%",
|
|
599
|
+
height: "100%"
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** The drop-zone highlight rendered over a group during a drag-over. */
|
|
604
|
+
function DropIndicator(props) {
|
|
605
|
+
return /* @__PURE__ */ jsx("div", {
|
|
606
|
+
"data-deck-drop-zone": props.zone,
|
|
607
|
+
style: dropIndicatorStyle(props.zone)
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Render a tab group's body region under DOM-panel KEEPALIVE.
|
|
612
|
+
*
|
|
613
|
+
* - DOM panels: ALL of the group's DOM panels are mounted SIMULTANEOUSLY, each
|
|
614
|
+
* under a STABLE React key (`dom-${panelId}`) so switching the active tab never
|
|
615
|
+
* remounts a body — React state + scroll persist across A→B→A. The active body
|
|
616
|
+
* fills the region (flex:1); inactive ones stay in the DOM but `display:none`.
|
|
617
|
+
* Each body's host renderer receives `{ active }` and is re-invoked with the
|
|
618
|
+
* new flag on every activation change (no remount), so a host can fire
|
|
619
|
+
* on-activation side effects off the false→true edge.
|
|
620
|
+
* - Native panels: EXEMPT from keepalive. The single ACTIVE native panel mounts a
|
|
621
|
+
* `NativeSlot` (keyed on the active id, so deactivation unmounts it and fires
|
|
622
|
+
* `bindNativeSlot(id, null)`); inactive native panels render nothing. Keeping a
|
|
623
|
+
* bound native slot mounted-but-hidden would collapse its WebContentsView rect.
|
|
624
|
+
*/
|
|
625
|
+
function renderActiveBody(node, ctx) {
|
|
626
|
+
const activeId = node.active;
|
|
627
|
+
if (!activeId) return null;
|
|
628
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [ctx.registry.get(activeId)?.kind === "native" ? /* @__PURE__ */ jsx(NativeSlot, {
|
|
629
|
+
panelId: activeId,
|
|
630
|
+
bindNativeSlot: ctx.bindNativeSlot
|
|
631
|
+
}, activeId) : null, node.panels.filter((panelId) => ctx.registry.get(panelId)?.kind !== "native").map((panelId) => {
|
|
632
|
+
const active = panelId === activeId;
|
|
633
|
+
return /* @__PURE__ */ jsx("div", {
|
|
634
|
+
"data-deck-panel-body": panelId,
|
|
635
|
+
style: {
|
|
636
|
+
display: active ? void 0 : "none",
|
|
637
|
+
flex: 1,
|
|
638
|
+
minWidth: 0,
|
|
639
|
+
minHeight: 0
|
|
640
|
+
},
|
|
641
|
+
children: ctx.renderDomPanel(panelId, { active })
|
|
642
|
+
}, `dom-${panelId}`);
|
|
643
|
+
})] });
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Empty anchor slot for an ACTIVE native panel. A ref-callback binds the live
|
|
647
|
+
* element on mount and unbinds (`null`) on unmount. Keying the element on the
|
|
648
|
+
* active panel id (in the parent) guarantees deactivation unmounts this slot —
|
|
649
|
+
* firing the `null` cleanup — and re-activation mounts a fresh one, re-binding.
|
|
650
|
+
*/
|
|
651
|
+
function NativeSlot(props) {
|
|
652
|
+
const { panelId, bindNativeSlot } = props;
|
|
653
|
+
const bindRef = useRef(bindNativeSlot);
|
|
654
|
+
bindRef.current = bindNativeSlot;
|
|
655
|
+
return /* @__PURE__ */ jsx("div", {
|
|
656
|
+
ref: useCallback((el) => {
|
|
657
|
+
bindRef.current(panelId, el);
|
|
658
|
+
}, [panelId]),
|
|
659
|
+
"data-deck-native-slot": panelId,
|
|
660
|
+
style: {
|
|
661
|
+
flex: 1,
|
|
662
|
+
minWidth: 0,
|
|
663
|
+
minHeight: 0,
|
|
664
|
+
height: "100%"
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
669
|
+
export { DockView, computeDropZone, computeFlexiblePercentages, dropZoneToMutation, isNoopRedock, layoutsEquivalent };
|
|
670
|
+
|
|
671
|
+
//# sourceMappingURL=index.js.map
|