@dimina-kit/electron-deck 0.1.0-dev.20260702182435 → 0.1.0-dev.20260703051110

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.
Files changed (63) hide show
  1. package/README.md +45 -15
  2. package/dist/client/index.d.ts +8 -2
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +1 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/dock-react/dock-view.d.ts +43 -19
  7. package/dist/dock-react/dock-view.d.ts.map +1 -1
  8. package/dist/dock-react/drag-redock.d.ts +18 -0
  9. package/dist/dock-react/drag-redock.d.ts.map +1 -1
  10. package/dist/dock-react/group-view.d.ts +18 -0
  11. package/dist/dock-react/group-view.d.ts.map +1 -0
  12. package/dist/dock-react/index.d.ts +3 -2
  13. package/dist/dock-react/index.d.ts.map +1 -1
  14. package/dist/dock-react/index.js +384 -289
  15. package/dist/dock-react/index.js.map +1 -1
  16. package/dist/dock-react/panel-body.d.ts +24 -0
  17. package/dist/dock-react/panel-body.d.ts.map +1 -0
  18. package/dist/dock-react/split-sizing.d.ts +109 -0
  19. package/dist/dock-react/split-sizing.d.ts.map +1 -0
  20. package/dist/dock-react/split-view.d.ts +15 -0
  21. package/dist/dock-react/split-view.d.ts.map +1 -0
  22. package/dist/{electron-deck-B9fun93z.js → electron-deck-Bi-u-Aze.js} +175 -79
  23. package/dist/electron-deck-Bi-u-Aze.js.map +1 -0
  24. package/dist/electron-deck.d.ts +5 -0
  25. package/dist/electron-deck.d.ts.map +1 -1
  26. package/dist/events.d.ts +1 -0
  27. package/dist/events.d.ts.map +1 -1
  28. package/dist/host/index.d.ts +5 -0
  29. package/dist/host/index.d.ts.map +1 -1
  30. package/dist/host/index.js +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/internal/deck-app-view-move.d.ts +17 -0
  33. package/dist/internal/deck-app-view-move.d.ts.map +1 -0
  34. package/dist/internal/deck-app.d.ts +58 -1
  35. package/dist/internal/deck-app.d.ts.map +1 -1
  36. package/dist/layout/index.d.ts +1 -0
  37. package/dist/layout/index.d.ts.map +1 -1
  38. package/dist/layout/index.js +2 -2
  39. package/dist/layout/model.d.ts.map +1 -1
  40. package/dist/layout/mutations.d.ts.map +1 -1
  41. package/dist/layout/tree-query.d.ts +18 -0
  42. package/dist/layout/tree-query.d.ts.map +1 -0
  43. package/dist/layout/types.d.ts +11 -9
  44. package/dist/layout/types.d.ts.map +1 -1
  45. package/dist/{layout-ByOiOdnc.js → layout-D-LYJIpM.js} +57 -41
  46. package/dist/layout-D-LYJIpM.js.map +1 -0
  47. package/dist/main/compositor.d.ts.map +1 -1
  48. package/dist/main/index.js +1 -1
  49. package/dist/main/view-handle.d.ts.map +1 -1
  50. package/dist/preload/index.cjs +5 -0
  51. package/dist/preload/index.cjs.map +1 -1
  52. package/dist/preload/index.d.ts +5 -0
  53. package/dist/preload/index.d.ts.map +1 -1
  54. package/dist/preload/index.js +5 -0
  55. package/dist/preload/index.js.map +1 -1
  56. package/dist/types.d.ts +32 -10
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/{view-handle-DDhpBuW-.js → view-handle-zav25k3-.js} +141 -86
  59. package/dist/view-handle-zav25k3-.js.map +1 -0
  60. package/package.json +11 -5
  61. package/dist/electron-deck-B9fun93z.js.map +0 -1
  62. package/dist/layout-ByOiOdnc.js.map +0 -1
  63. package/dist/view-handle-DDhpBuW-.js.map +0 -1
@@ -1,9 +1,79 @@
1
- import { a as closePanelForUser, c as extractPanel, d as setActive, m as splitPanel, p as setSizes, u as movePanel } from "../layout-ByOiOdnc.js";
1
+ import { a as closePanelForUser, c as extractPanel, d as setActive, g as findGroupById, h as countPanels, m as splitPanel, p as setSizes, u as movePanel, v as findPanelGroupId } from "../layout-D-LYJIpM.js";
2
2
  import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
3
3
  import { Group, Panel, Separator } from "react-resizable-panels";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/dock-react/drag-redock.ts
6
6
  /**
7
+ * A zero/negative/non-finite rect has no meaningful edge bands; a non-finite
8
+ * point can't be classified either. Note a non-finite WIDTH/HEIGHT (e.g.
9
+ * Infinity) satisfies `> 0` and would otherwise slip through — `band = ef *
10
+ * min(Infinity, h)` yields a finite band and the point is misclassified as an
11
+ * EDGE zone — so reject finiteness EXPLICITLY (N1).
12
+ */
13
+ function isDegenerateDropInput(width, height, x, y) {
14
+ return !(width > 0) || !(height > 0) || !Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y);
15
+ }
16
+ /**
17
+ * Clamp the band fraction to [0, 0.5] (N1): a fraction > 0.5 makes the left
18
+ * and right bands (and top/bottom) OVERLAP, so a point near the right edge of
19
+ * a narrow rect would satisfy BOTH `inLeft` and `inRight` and be misread as
20
+ * `left`. Capping at 0.5 keeps the two bands disjoint.
21
+ */
22
+ function clampEdgeFraction(edgeFraction) {
23
+ return Number.isFinite(edgeFraction) ? Math.max(0, Math.min(.5, edgeFraction)) : .25;
24
+ }
25
+ /**
26
+ * A pointer dragged past the panel edge has no band membership; clamp it to
27
+ * the nearest edge zone. `undefined` when the point is IN the rect (the
28
+ * caller falls through to band classification). Both axes out (a diagonal
29
+ * corner): the axis with the LARGER overshoot magnitude wins; an exact tie is
30
+ * broken by HORIZONTAL (the x axis).
31
+ */
32
+ function classifyOutOfRectDropZone(width, height, x, y) {
33
+ const outX = x < 0 ? x : x > width ? x - width : 0;
34
+ const outY = y < 0 ? y : y > height ? y - height : 0;
35
+ if (outX === 0 && outY === 0) return void 0;
36
+ const magX = Math.abs(outX);
37
+ if (magX >= Math.abs(outY) && magX > 0) return outX < 0 ? "left" : "right";
38
+ return outY < 0 ? "top" : "bottom";
39
+ }
40
+ function computeBandMembership(width, height, x, y, ef) {
41
+ const band = ef * Math.min(width, height);
42
+ const inLeft = x < band;
43
+ const inRight = x > width - band;
44
+ const inTop = y < band;
45
+ const inBottom = y > height - band;
46
+ return {
47
+ inLeft,
48
+ inTop,
49
+ activeHoriz: inLeft || inRight,
50
+ activeVert: inTop || inBottom
51
+ };
52
+ }
53
+ /**
54
+ * Corner tie-break (both axes active): pick the edge with the SMALLER
55
+ * normalized distance. Only the ACTIVE horizontal edge and ACTIVE vertical
56
+ * edge can compete (left vs right and top vs bottom can't both be active for
57
+ * a sane rect). On an exact tie, HORIZONTAL wins (`<=` on the horizontal
58
+ * distance).
59
+ */
60
+ function cornerTieBreak(width, height, x, y, inLeft, inTop) {
61
+ if ((inLeft ? x / width : (width - x) / width) <= (inTop ? y / height : (height - y) / height)) return inLeft ? "left" : "right";
62
+ return inTop ? "top" : "bottom";
63
+ }
64
+ /**
65
+ * Classify an IN-RECT point by edge-band membership. No band => interior
66
+ * (`center`, the tab-join zone). One active band => that edge directly. Both
67
+ * axes active (a corner) => {@link cornerTieBreak}.
68
+ */
69
+ function classifyInRectDropZone(width, height, x, y, ef) {
70
+ const { inLeft, inTop, activeHoriz, activeVert } = computeBandMembership(width, height, x, y, ef);
71
+ if (!activeHoriz && !activeVert) return "center";
72
+ if (activeHoriz && !activeVert) return inLeft ? "left" : "right";
73
+ if (activeVert && !activeHoriz) return inTop ? "top" : "bottom";
74
+ return cornerTieBreak(width, height, x, y, inLeft, inTop);
75
+ }
76
+ /**
7
77
  * Classify a pointer position (RELATIVE to the rect's top-left; `(0,0)` is the
8
78
  * corner) into a drop zone.
9
79
  *
@@ -18,29 +88,11 @@ import { jsx, jsxs } from "react/jsx-runtime";
18
88
  function computeDropZone(rect, point, edgeFraction = .25) {
19
89
  const { width, height } = rect;
20
90
  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";
91
+ if (isDegenerateDropInput(width, height, x, y)) return "center";
92
+ const ef = clampEdgeFraction(edgeFraction);
93
+ const outOfRectZone = classifyOutOfRectDropZone(width, height, x, y);
94
+ if (outOfRectZone !== void 0) return outOfRectZone;
95
+ return classifyInRectDropZone(width, height, x, y, ef);
44
96
  }
45
97
  /**
46
98
  * Translate a drop zone into an engine-neutral re-dock descriptor.
@@ -107,55 +159,44 @@ function computeReorderIndex(tabRects, pointerX) {
107
159
  else break;
108
160
  return index;
109
161
  }
110
- //#endregion
111
- //#region src/dock-react/dock-view.tsx
112
162
  /**
113
- * `<DockView>` React renderer for a layout-as-data tree.
114
- *
115
- * Pure function of the current model snapshot (+ registry + the two host
116
- * callbacks). It owns NO layout state: it subscribes to the `LayoutModel`,
117
- * keeps the latest tree in component state, and re-renders on every emission.
118
- * All structural targeting is via STABLE `data-*` attributes — see the
119
- * contract doc-block in `dock-view.test.tsx`.
163
+ * Translate a tab strip's VISIBLE-tab drop index into the insertion index
164
+ * `movePanel`'s same-group reorder expects.
120
165
  *
121
- * This file lives under `src/dock-react/` (NOT `src/layout/`), so importing
122
- * react here does not violate the pure-TS layout boundary.
123
- */
124
- /**
125
- * The dataTransfer MIME under which a drag carries the dragged panel id. A
126
- * custom type (vs `text/plain`) keeps deck drags from being confused with
127
- * arbitrary text drops; the panel id also stays recoverable from the source
128
- * tab's `data-deck-tab` attribute (the jsdom seam relies on the latter).
129
- */
130
- var DRAG_PANEL_MIME = "application/x-deck-panel";
131
- var activeDragPanelId = null;
132
- /**
133
- * The current layout EPOCH — the model's revision, bumped once per committed
134
- * mutation. `DockView` provides it; descendant panels read it via
135
- * `useDockLayoutEpoch`.
166
+ * `computeReorderIndex` reports how many VISIBLE-tab midpoints the pointer has
167
+ * passed (0..`visibleTabIds.length`), COUNTING the dragged tab's own midpoint and
168
+ * measured over rects that OMIT `hideTab` panels. `movePanel` inserts into
169
+ * `panels.filter(p => p !== dragged)` — a different coordinate space. Two shifts
170
+ * reconcile them:
136
171
  *
137
- * Its reason to exist: a native-view overlay anchored inside a dock panel
138
- * (a `WebContentsView` tracking its slot's geometry) re-publishes its bounds
139
- * only on GEOMETRY events (ResizeObserver / window-resize / splitter-drag). A
140
- * layout mutation that REORDERS a slot without resizing it flipping a
141
- * fixed-width simulator column left↔right, moving a region produces NO such
142
- * event (a same-size flex reorder fires nothing), so the overlay would freeze
143
- * at its old position. The epoch is the layout layer's explicit "something
144
- * moved" signal: a panel hosting a native overlay re-measures (e.g. pulses its
145
- * view-anchor) when the epoch changes, catching the translate the browser never
146
- * reports. Default 0 (outside a provider): the consuming effect runs once on
147
- * mount, which is harmless.
148
- */
149
- var LayoutEpochContext = createContext(0);
150
- /**
151
- * Read the current dock layout epoch (the model revision). Re-renders the caller
152
- * whenever a layout mutation commits. Keyed into a panel's effect deps, it lets
153
- * a native-overlay host re-measure on a reorder that fires no geometry event.
154
- * Returns 0 when used outside a `<DockView>`.
172
+ * 1. Once the pointer passes the dragged tab's OWN midpoint the strip index has
173
+ * counted the dragged slot, so drop it back out (−1) to get the insertion
174
+ * slot among the OTHER visible tabs.
175
+ * 2. Map that visible slot onto the full `panels` order (which may carry hidden
176
+ * tabs the strip never measured): insert before whichever visible tab now
177
+ * occupies the slot, or append when the slot is past the last visible tab.
155
178
  */
156
- function useDockLayoutEpoch() {
157
- return useContext(LayoutEpochContext);
179
+ function resolveReorderInsertIndex(panels, visibleTabIds, draggedPanelId, stripInsertIndex) {
180
+ const draggedVisibleIndex = visibleTabIds.indexOf(draggedPanelId);
181
+ const passedOwnMidpoint = draggedVisibleIndex >= 0 && stripInsertIndex > draggedVisibleIndex;
182
+ const filteredVisible = visibleTabIds.filter((id) => id !== draggedPanelId);
183
+ const filteredPanels = panels.filter((id) => id !== draggedPanelId);
184
+ let visibleInsert = passedOwnMidpoint ? stripInsertIndex - 1 : stripInsertIndex;
185
+ if (visibleInsert < 0) visibleInsert = 0;
186
+ if (visibleInsert > filteredVisible.length) visibleInsert = filteredVisible.length;
187
+ if (visibleInsert >= filteredVisible.length) return filteredPanels.length;
188
+ const anchor = filteredVisible[visibleInsert];
189
+ const anchorIndex = filteredPanels.indexOf(anchor);
190
+ return anchorIndex >= 0 ? anchorIndex : filteredPanels.length;
158
191
  }
192
+ //#endregion
193
+ //#region src/dock-react/split-sizing.ts
194
+ /** Epsilon (in percentage points) within which two split layouts are treated as
195
+ * equivalent. Guards BOTH sides of the model↔view loop: we skip pushing a
196
+ * `setLayout` when the live layout already matches the target, and skip the
197
+ * `onLayoutChanged` write-back when the incoming layout matches the model — so
198
+ * `setLayout`→`onLayoutChanged`→write-back→re-sync cannot loop. */
199
+ var LAYOUT_EPSILON = .5;
159
200
  /** Normalize raw split weights to percentages summing ~100 for `defaultSize`. */
160
201
  function toPercentages(sizes) {
161
202
  const total = sizes.reduce((a, b) => a + (b > 0 ? b : 0), 0);
@@ -181,146 +222,6 @@ function computeFlexiblePercentages(sizes, constraints) {
181
222
  });
182
223
  return out;
183
224
  }
184
- function DockView(props) {
185
- const { model, registry, renderDomPanel, bindNativeSlot, suppressReorderOnlyDropIndicator, onActiveTabClick } = props;
186
- const [tree, setTree] = useState(() => model.get());
187
- const [epoch, setEpoch] = useState(0);
188
- useEffect(() => {
189
- setTree(model.get());
190
- return model.subscribe((snap) => {
191
- setTree(snap.tree);
192
- setEpoch(snap.revision);
193
- });
194
- }, [model]);
195
- const handleActivate = useCallback((groupId, panelId) => {
196
- model.apply((t) => setActive(t, groupId, panelId));
197
- }, [model]);
198
- const handleClose = useCallback((panelId) => {
199
- model.apply((t) => closePanelForUser(t, panelId, registry));
200
- }, [model, registry]);
201
- const canClose = countPanels(tree.root) > 1;
202
- const isPanelInTree = useCallback((panelId) => findPanelGroupId(tree.root, panelId) !== void 0, [tree]);
203
- const handleApplyLayout = useCallback((splitId, weights) => {
204
- model.apply((t) => setSizes(t, splitId, weights));
205
- }, [model]);
206
- const handleRedock = useCallback((groupId, activePanelId, draggedPanelId, zone, reorderIndex) => {
207
- const target = {
208
- groupId,
209
- panelId: activePanelId
210
- };
211
- const draggedGroupId = findPanelGroupId(model.get().root, draggedPanelId);
212
- if (registry.get(draggedPanelId)?.draggable === false) return;
213
- if (registry.get(activePanelId)?.draggable === false) return;
214
- if (registry.get(draggedPanelId)?.dropPolicy === "reorder-only") {
215
- if (draggedGroupId === void 0 || draggedGroupId !== groupId) return;
216
- if (zone !== "center") return;
217
- const group = findGroupById(model.get().root, groupId);
218
- const index = reorderIndex ?? (group ? group.panels.indexOf(activePanelId) : void 0);
219
- model.apply((t) => movePanel(t, draggedPanelId, {
220
- groupId,
221
- index
222
- }));
223
- return;
224
- }
225
- if (isNoopRedock(draggedPanelId, draggedGroupId, target, zone)) return;
226
- const mutation = dropZoneToMutation(zone, draggedPanelId, target);
227
- if (mutation.kind === "move") {
228
- model.apply((t) => movePanel(t, mutation.panelId, { groupId: mutation.destGroupId }));
229
- return;
230
- }
231
- model.apply((t) => {
232
- const { tree } = extractPanel(t, mutation.newPanelId);
233
- if (findPanelGroupId(tree.root, mutation.atPanelId) === void 0) return t;
234
- return splitPanel(tree, mutation.atPanelId, mutation.dir, mutation.newPanelId, mutation.side);
235
- });
236
- }, [model, registry]);
237
- return /* @__PURE__ */ jsx(LayoutEpochContext.Provider, {
238
- value: epoch,
239
- children: renderNode(tree.root, {
240
- registry,
241
- renderDomPanel,
242
- bindNativeSlot,
243
- onActivate: handleActivate,
244
- onActiveTabClick,
245
- onApplyLayout: handleApplyLayout,
246
- onRedock: handleRedock,
247
- onClose: handleClose,
248
- canClose,
249
- isPanelInTree,
250
- suppressReorderOnlyDropIndicator: suppressReorderOnlyDropIndicator ?? false
251
- })
252
- });
253
- }
254
- /** The id of the tab group currently holding `panelId`, or `undefined` if the
255
- * panel is not in the tree. Used by `handleRedock` to detect a drop back into
256
- * the dragged panel's own group (M2) and to guard a vanished split anchor (M1). */
257
- function findPanelGroupId(node, panelId) {
258
- if (node.kind === "tabs") return node.panels.includes(panelId) ? node.id : void 0;
259
- for (const child of node.children) {
260
- const found = findPanelGroupId(child, panelId);
261
- if (found !== void 0) return found;
262
- }
263
- }
264
- /** The tab-group node with `groupId`, or `undefined`. Used by `handleRedock` to
265
- * read the live `panels` order of a reorder target (to anchor a same-group
266
- * reorder on the active panel's current index when the gesture carries no
267
- * pointer-derived index — e.g. the `__deckHandleDrop` test seam). */
268
- function findGroupById(node, groupId) {
269
- if (node.kind === "tabs") return node.id === groupId ? node : void 0;
270
- for (const child of node.children) {
271
- const found = findGroupById(child, groupId);
272
- if (found !== void 0) return found;
273
- }
274
- }
275
- /** Total panels anywhere in the tree. Drives the last-panel close suppression:
276
- * a GroupView only knows its own node, so DockView computes this global count
277
- * once and threads the resulting `canClose` boolean down. */
278
- function countPanels(node) {
279
- if (node.kind === "tabs") return node.panels.length;
280
- return node.children.reduce((sum, child) => sum + countPanels(child), 0);
281
- }
282
- function renderNode(node, ctx) {
283
- return node.kind === "split" ? renderSplit(node, ctx) : renderGroup(node, ctx);
284
- }
285
- /** A split is a stateful component (it holds the rrp Group imperative ref + runs
286
- * the M1 model→view sync effect), so render it via JSX with a STABLE key so
287
- * React preserves that ref / its kept-alive subtree across model emissions
288
- * rather than remounting on every snapshot. Mirrors `renderGroup`/`GroupView`. */
289
- function renderSplit(node, ctx) {
290
- return /* @__PURE__ */ jsx(SplitView, {
291
- node,
292
- ctx
293
- }, node.id);
294
- }
295
- /** Epsilon (in percentage points) within which two split layouts are treated as
296
- * equivalent. Guards BOTH sides of the model↔view loop: we skip pushing a
297
- * `setLayout` when the live layout already matches the target, and skip the
298
- * `onLayoutChanged` write-back when the incoming layout matches the model — so
299
- * `setLayout`→`onLayoutChanged`→write-back→re-sync cannot loop. */
300
- var LAYOUT_EPSILON = .5;
301
- /** TIGHT tolerance (percentage points) for the BASIS-NORMALIZED flexible-ratio
302
- * compare in `handleLayoutChanged`. We normalize the incoming layout's flexible
303
- * subset to ratios summing to 100 and compare them against the model's flexible
304
- * ratios (`computeFlexiblePercentages`, also summing to 100). If they match
305
- * within this tolerance the `onLayoutChanged` is either our own `setLayout` echo
306
- * OR a ratio-preserving spontaneous re-measure (mount / fixed-px re-pin /
307
- * container resize) — SKIP the write-back. If they differ it is a genuine user
308
- * resize (pointer OR keyboard) — WRITE BACK.
309
- *
310
- * Set to ~0.1pp: large enough to absorb rrp's ~3-decimal float noise on an echo,
311
- * yet FAR below a real drag's delta. R1's "sub-0.5%" drag moves a panel ~0.33pp
312
- * of the container; normalized over the two-flexible-child subset that is ~0.66pp
313
- * of ratio — comfortably above 0.1, so it is NOT mistaken for an echo and is
314
- * written back.
315
- *
316
- * CAVEAT: this is a flexible-RATIO tolerance (the flexible subset normalized to
317
- * sum 100), NOT a container-%. It is safe against rrp's 3-decimal echo noise.
318
- * In a pathologically WIDE split (~≥10 flexible children) a single arrow-key
319
- * nudge (±~5% of the container on one child) can, once normalized over many
320
- * flexible siblings, fall BELOW 0.1pp of ratio and be skipped — exotic and
321
- * self-healing (the next, larger resize writes back). If that ever matters,
322
- * scale the tolerance down by the flexible child count. */
323
- var FLEX_RATIO_TOLERANCE = .1;
324
225
  /**
325
226
  * The minimum weight a FLEXIBLE child may hold, given how many flexible children
326
227
  * share the split (Bug #1). A flexible `<Panel>` has no rrp `minSize` by default
@@ -388,38 +289,48 @@ function layoutsEquivalent(a, b, ids, epsilon = LAYOUT_EPSILON) {
388
289
  */
389
290
  function buildSetLayoutMap(childIds, sizes, constraints, live) {
390
291
  const isFixedAt = (i) => (constraints?.[i] ?? null) !== null;
391
- let fixedTotal = 0;
392
- for (let i = 0; i < childIds.length; i++) {
393
- if (!isFixedAt(i)) continue;
394
- const livePct = live[childIds[i]];
395
- if (typeof livePct !== "number") return null;
396
- fixedTotal += livePct;
397
- }
292
+ const fixedTotal = sumFixedLivePercent(childIds, isFixedAt, live);
293
+ if (fixedTotal === null) return null;
398
294
  const remaining = Math.max(0, 100 - fixedTotal);
399
295
  let flexWeightTotal = 0;
296
+ let flexibleCount = 0;
400
297
  for (let i = 0; i < childIds.length; i++) {
401
298
  if (isFixedAt(i)) continue;
402
299
  const w = sizes[i] ?? 0;
403
300
  flexWeightTotal += w > 0 ? w : 0;
301
+ flexibleCount += 1;
404
302
  }
405
- const flexibleCount = childIds.length - childIds.filter((_, i) => isFixedAt(i)).length;
406
303
  const out = {};
407
304
  for (let i = 0; i < childIds.length; i++) {
408
305
  const id = childIds[i];
409
- if (isFixedAt(i)) {
410
- out[id] = live[id];
411
- continue;
412
- }
413
- if (flexWeightTotal <= 0) {
414
- out[id] = flexibleCount > 0 ? remaining / flexibleCount : remaining;
415
- continue;
416
- }
417
- const w = sizes[i] ?? 0;
418
- out[id] = remaining * (w > 0 ? w : 0) / flexWeightTotal;
306
+ out[id] = isFixedAt(i) ? live[id] : flexibleShare(sizes[i] ?? 0, remaining, flexWeightTotal, flexibleCount);
419
307
  }
420
308
  return out;
421
309
  }
422
310
  /**
311
+ * Sum the fixed children's CURRENT live percentages (preserving their px
312
+ * lock). Null when a fixed child has no measured live % — the caller cannot
313
+ * build a faithful map then.
314
+ */
315
+ function sumFixedLivePercent(childIds, isFixedAt, live) {
316
+ let fixedTotal = 0;
317
+ for (let i = 0; i < childIds.length; i++) {
318
+ if (!isFixedAt(i)) continue;
319
+ const livePct = live[childIds[i]];
320
+ if (typeof livePct !== "number") return null;
321
+ fixedTotal += livePct;
322
+ }
323
+ return fixedTotal;
324
+ }
325
+ /**
326
+ * One flexible child's percentage share of the `remaining` space. A degenerate
327
+ * weight pool (total ≤ 0) splits the remaining space evenly instead.
328
+ */
329
+ function flexibleShare(weight, remaining, flexWeightTotal, flexibleCount) {
330
+ if (flexWeightTotal <= 0) return flexibleCount > 0 ? remaining / flexibleCount : remaining;
331
+ return remaining * (weight > 0 ? weight : 0) / flexWeightTotal;
332
+ }
333
+ /**
423
334
  * Extract an rrp `onLayoutChanged` map's FLEXIBLE subset (skipping fixed-px
424
335
  * children) in child order and NORMALIZE it by its own sum to ratios summing to
425
336
  * ~100 — the basis `computeFlexiblePercentages` already produces for the model,
@@ -444,6 +355,14 @@ function incomingFlexRatios(childIds, constraints, layout) {
444
355
  ratios: toPercentages(raw)
445
356
  };
446
357
  }
358
+ //#endregion
359
+ //#region src/dock-react/split-view.tsx
360
+ /**
361
+ * `SplitView` — one split node rendered as a react-resizable-panels Group, plus
362
+ * the model↔view sync machine (M1): it pushes the model's flexible weights into
363
+ * the live Group via `setLayout` and writes a genuine user resize back through
364
+ * `ctx.onApplyLayout`. The split-sizing arithmetic lives in `./split-sizing`.
365
+ */
447
366
  function SplitView(props) {
448
367
  const { node, ctx } = props;
449
368
  const orientation = node.orientation === "row" ? "horizontal" : "vertical";
@@ -494,7 +413,7 @@ function SplitView(props) {
494
413
  if (!incoming) return;
495
414
  const modelPctByIndex = computeFlexiblePercentages(cur.sizes, cur.constraints);
496
415
  const modelNorm = incoming.indices.map((i) => modelPctByIndex.get(i) ?? 0);
497
- if (!incoming.ratios.some((r, k) => Math.abs(r - modelNorm[k]) > FLEX_RATIO_TOLERANCE)) return;
416
+ if (!incoming.ratios.some((r, k) => Math.abs(r - modelNorm[k]) > .1)) return;
498
417
  const weights = curChildIds.map((id, i) => isFixedAt(i) ? cur.sizes[i] ?? 1 : layout[id]);
499
418
  ctx.onApplyLayout(cur.id, clampFlexibleWeights(weights, cur.constraints));
500
419
  };
@@ -558,12 +477,85 @@ function SplitView(props) {
558
477
  function panelKey(node) {
559
478
  return node.id;
560
479
  }
561
- function renderGroup(node, ctx) {
562
- return /* @__PURE__ */ jsx(GroupView, {
563
- node,
564
- ctx
565
- }, node.id);
480
+ //#endregion
481
+ //#region src/dock-react/panel-body.tsx
482
+ /**
483
+ * Tab-group body rendering under DOM-panel keepalive: `renderActiveBody` (the
484
+ * body region) + `NativeSlot` (the anchor for an active native panel).
485
+ */
486
+ /**
487
+ * Render a tab group's body region under DOM-panel KEEPALIVE.
488
+ *
489
+ * - DOM panels: ALL of the group's DOM panels are mounted SIMULTANEOUSLY, each
490
+ * under a STABLE React key (`dom-${panelId}`) so switching the active tab never
491
+ * remounts a body — React state + scroll persist across A→B→A. The active body
492
+ * fills the region (flex:1); inactive ones stay in the DOM but `display:none`.
493
+ * Each body's host renderer receives `{ active }` and is re-invoked with the
494
+ * new flag on every activation change (no remount), so a host can fire
495
+ * on-activation side effects off the false→true edge.
496
+ * - Native panels: EXEMPT from keepalive. The single ACTIVE native panel mounts a
497
+ * `NativeSlot` (keyed on the active id, so deactivation unmounts it and fires
498
+ * `bindNativeSlot(id, null)`); inactive native panels render nothing. Keeping a
499
+ * bound native slot mounted-but-hidden would collapse its WebContentsView rect.
500
+ */
501
+ function renderActiveBody(node, ctx) {
502
+ const activeId = node.active;
503
+ if (!activeId) return null;
504
+ return /* @__PURE__ */ jsxs(Fragment, { children: [ctx.registry.get(activeId)?.kind === "native" ? /* @__PURE__ */ jsx(NativeSlot, {
505
+ panelId: activeId,
506
+ bindNativeSlot: ctx.bindNativeSlot
507
+ }, activeId) : null, node.panels.filter((panelId) => ctx.registry.get(panelId)?.kind !== "native").slice().sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((panelId) => {
508
+ const active = panelId === activeId;
509
+ return /* @__PURE__ */ jsx("div", {
510
+ "data-deck-panel-body": panelId,
511
+ style: {
512
+ display: active ? "flex" : "none",
513
+ flexDirection: "column",
514
+ flex: 1,
515
+ minWidth: 0,
516
+ minHeight: 0
517
+ },
518
+ children: ctx.renderDomPanel(panelId, { active })
519
+ }, `dom-${panelId}`);
520
+ })] });
521
+ }
522
+ /**
523
+ * Empty anchor slot for an ACTIVE native panel. A ref-callback binds the live
524
+ * element on mount and unbinds (`null`) on unmount. Keying the element on the
525
+ * active panel id (in the parent) guarantees deactivation unmounts this slot —
526
+ * firing the `null` cleanup — and re-activation mounts a fresh one, re-binding.
527
+ */
528
+ function NativeSlot(props) {
529
+ const { panelId, bindNativeSlot } = props;
530
+ const bindRef = useRef(bindNativeSlot);
531
+ bindRef.current = bindNativeSlot;
532
+ return /* @__PURE__ */ jsx("div", {
533
+ ref: useCallback((el) => {
534
+ bindRef.current(panelId, el);
535
+ }, [panelId]),
536
+ "data-deck-native-slot": panelId,
537
+ style: {
538
+ flex: 1,
539
+ minWidth: 0,
540
+ minHeight: 0,
541
+ height: "100%"
542
+ }
543
+ });
566
544
  }
545
+ //#endregion
546
+ //#region src/dock-react/group-view.tsx
547
+ /**
548
+ * `GroupView` — one tab GROUP: the draggable tab strip, the active body, the
549
+ * imperative `__deckHandleDrop` seam, and a geometry-driven drop indicator while
550
+ * a drag hovers. Owns all tab drag/drop handlers.
551
+ */
552
+ /**
553
+ * The dataTransfer MIME under which a drag carries the dragged panel id. A
554
+ * custom type (vs `text/plain`) keeps deck drags from being confused with
555
+ * arbitrary text drops; the panel id also stays recoverable from the source
556
+ * tab's `data-deck-tab` attribute (the jsdom seam relies on the latter).
557
+ */
558
+ var DRAG_PANEL_MIME = "application/x-deck-panel";
567
559
  /**
568
560
  * One tab GROUP: tab strip (draggable tabs) + active body + the imperative
569
561
  * `__deckHandleDrop` seam + a geometry-driven drop indicator while a drag hovers.
@@ -582,8 +574,10 @@ function GroupView(props) {
582
574
  };
583
575
  };
584
576
  const handleDragOver = (e) => {
577
+ if (!e.dataTransfer?.types.includes(DRAG_PANEL_MIME)) return;
585
578
  e.preventDefault();
586
- if (ctx.suppressReorderOnlyDropIndicator && activeDragPanelId !== null && ctx.registry.get(activeDragPanelId)?.dropPolicy === "reorder-only") {
579
+ const draggedId = ctx.activeDragPanelId.current;
580
+ if (ctx.suppressReorderOnlyDropIndicator && draggedId !== null && ctx.registry.get(draggedId)?.dropPolicy === "reorder-only") {
587
581
  setDropZone(null);
588
582
  return;
589
583
  }
@@ -610,7 +604,7 @@ function GroupView(props) {
610
604
  y: e.clientY - rect.top
611
605
  });
612
606
  setDropZone(null);
613
- const dragged = e.dataTransfer.getData(DRAG_PANEL_MIME) || e.dataTransfer.getData("text/plain");
607
+ const dragged = e.dataTransfer?.getData(DRAG_PANEL_MIME);
614
608
  if (!dragged || ctx.registry.get(dragged) === void 0 || !ctx.isPanelInTree(dragged)) return;
615
609
  const reorderIndex = computeReorderIndex(Array.from(e.currentTarget.querySelectorAll("[data-deck-tab]")).map((el) => {
616
610
  const r = el.getBoundingClientRect();
@@ -622,7 +616,7 @@ function GroupView(props) {
622
616
  ctx.onRedock(node.id, node.active, dragged, zone, reorderIndex);
623
617
  };
624
618
  const handleTabStripDragOver = (e) => {
625
- if (!e.dataTransfer.types.includes(DRAG_PANEL_MIME)) return;
619
+ if (!e.dataTransfer?.types.includes(DRAG_PANEL_MIME)) return;
626
620
  e.preventDefault();
627
621
  e.stopPropagation();
628
622
  setDropZone(null);
@@ -631,7 +625,7 @@ function GroupView(props) {
631
625
  e.preventDefault();
632
626
  e.stopPropagation();
633
627
  setDropZone(null);
634
- const dragged = e.dataTransfer.getData(DRAG_PANEL_MIME) || e.dataTransfer.getData("text/plain");
628
+ const dragged = e.dataTransfer?.getData(DRAG_PANEL_MIME);
635
629
  if (!dragged || ctx.registry.get(dragged) === void 0 || !ctx.isPanelInTree(dragged)) return;
636
630
  const reorderIndex = computeReorderIndex(Array.from(e.currentTarget.querySelectorAll("[data-deck-tab]")).map((el) => {
637
631
  const r = el.getBoundingClientRect();
@@ -682,10 +676,10 @@ function GroupView(props) {
682
676
  e.dataTransfer.setData(DRAG_PANEL_MIME, panelId);
683
677
  e.dataTransfer.setData("text/plain", panelId);
684
678
  e.dataTransfer.effectAllowed = "move";
685
- activeDragPanelId = panelId;
679
+ ctx.activeDragPanelId.current = panelId;
686
680
  },
687
681
  onDragEnd: () => {
688
- activeDragPanelId = null;
682
+ ctx.activeDragPanelId.current = null;
689
683
  },
690
684
  onClick: () => {
691
685
  if (!active) ctx.onActivate(node.id, panelId);
@@ -777,66 +771,167 @@ function DropIndicator(props) {
777
771
  style: dropIndicatorStyle(props.zone)
778
772
  });
779
773
  }
774
+ //#endregion
775
+ //#region src/dock-react/dock-view.tsx
780
776
  /**
781
- * Render a tab group's body region under DOM-panel KEEPALIVE.
777
+ * `<DockView>` React renderer for a layout-as-data tree.
782
778
  *
783
- * - DOM panels: ALL of the group's DOM panels are mounted SIMULTANEOUSLY, each
784
- * under a STABLE React key (`dom-${panelId}`) so switching the active tab never
785
- * remounts a body React state + scroll persist across A→B→A. The active body
786
- * fills the region (flex:1); inactive ones stay in the DOM but `display:none`.
787
- * Each body's host renderer receives `{ active }` and is re-invoked with the
788
- * new flag on every activation change (no remount), so a host can fire
789
- * on-activation side effects off the false→true edge.
790
- * - Native panels: EXEMPT from keepalive. The single ACTIVE native panel mounts a
791
- * `NativeSlot` (keyed on the active id, so deactivation unmounts it and fires
792
- * `bindNativeSlot(id, null)`); inactive native panels render nothing. Keeping a
793
- * bound native slot mounted-but-hidden would collapse its WebContentsView rect.
779
+ * Pure function of the current model snapshot (+ registry + the two host
780
+ * callbacks). It owns NO layout state: it subscribes to the `LayoutModel`,
781
+ * keeps the latest tree in component state, and re-renders on every emission.
782
+ * All structural targeting is via STABLE `data-*` attributes see the
783
+ * contract doc-block in `dock-view.test.tsx`.
784
+ *
785
+ * This file lives under `src/dock-react/` (NOT `src/layout/`), so importing
786
+ * react here does not violate the pure-TS layout boundary. The stateful child
787
+ * views (`SplitView`, `GroupView`) and the body renderer live in sibling
788
+ * modules; this file owns the orchestration (`renderNode`) + the shared
789
+ * `RenderContext`.
794
790
  */
795
- function renderActiveBody(node, ctx) {
796
- const activeId = node.active;
797
- if (!activeId) return null;
798
- return /* @__PURE__ */ jsxs(Fragment, { children: [ctx.registry.get(activeId)?.kind === "native" ? /* @__PURE__ */ jsx(NativeSlot, {
799
- panelId: activeId,
800
- bindNativeSlot: ctx.bindNativeSlot
801
- }, activeId) : null, node.panels.filter((panelId) => ctx.registry.get(panelId)?.kind !== "native").map((panelId) => {
802
- const active = panelId === activeId;
803
- return /* @__PURE__ */ jsx("div", {
804
- "data-deck-panel-body": panelId,
805
- style: {
806
- display: active ? "flex" : "none",
807
- flexDirection: "column",
808
- flex: 1,
809
- minWidth: 0,
810
- minHeight: 0
811
- },
812
- children: ctx.renderDomPanel(panelId, { active })
813
- }, `dom-${panelId}`);
814
- })] });
791
+ /**
792
+ * The current layout EPOCH — the model's revision, bumped once per committed
793
+ * mutation. `DockView` provides it; descendant panels read it via
794
+ * `useDockLayoutEpoch`.
795
+ *
796
+ * Its reason to exist: a native-view overlay anchored inside a dock panel
797
+ * (a `WebContentsView` tracking its slot's geometry) re-publishes its bounds
798
+ * only on GEOMETRY events (ResizeObserver / window-resize / splitter-drag). A
799
+ * layout mutation that REORDERS a slot without resizing it — flipping a
800
+ * fixed-width simulator column left↔right, moving a region — produces NO such
801
+ * event (a same-size flex reorder fires nothing), so the overlay would freeze
802
+ * at its old position. The epoch is the layout layer's explicit "something
803
+ * moved" signal: a panel hosting a native overlay re-measures (e.g. pulses its
804
+ * view-anchor) when the epoch changes, catching the translate the browser never
805
+ * reports. Default 0 (outside a provider): the consuming effect runs once on
806
+ * mount, which is harmless.
807
+ */
808
+ var LayoutEpochContext = createContext(0);
809
+ /**
810
+ * Read the current dock layout epoch (the model revision). Re-renders the caller
811
+ * whenever a layout mutation commits. Keyed into a panel's effect deps, it lets
812
+ * a native-overlay host re-measure on a reorder that fires no geometry event.
813
+ * Returns 0 when used outside a `<DockView>`.
814
+ */
815
+ function useDockLayoutEpoch() {
816
+ return useContext(LayoutEpochContext);
815
817
  }
816
818
  /**
817
- * Empty anchor slot for an ACTIVE native panel. A ref-callback binds the live
818
- * element on mount and unbinds (`null`) on unmount. Keying the element on the
819
- * active panel id (in the parent) guarantees deactivation unmounts this slot
820
- * firing the `null` cleanup and re-activation mounts a fresh one, re-binding.
819
+ * Apply a `reorder-only` panel's redock. Such a panel may ONLY reorder WITHIN
820
+ * its own group: it never leaves the group (a center drop into another group)
821
+ * and never edge-splits (any edge zone, own or other group) those drops are
822
+ * rejected here. The reorder anchors on the pointer-derived index when the
823
+ * gesture supplies one (real drag), else on the active anchor's CURRENT index
824
+ * (the imperative seam carries no pointer). The pointer index is a VISIBLE-tab
825
+ * strip index (hideTab panels omitted, dragged tab's own slot counted); the
826
+ * strip↔model coordinate reconciliation lives only here.
821
827
  */
822
- function NativeSlot(props) {
823
- const { panelId, bindNativeSlot } = props;
824
- const bindRef = useRef(bindNativeSlot);
825
- bindRef.current = bindNativeSlot;
826
- return /* @__PURE__ */ jsx("div", {
827
- ref: useCallback((el) => {
828
- bindRef.current(panelId, el);
829
- }, [panelId]),
830
- "data-deck-native-slot": panelId,
831
- style: {
832
- flex: 1,
833
- minWidth: 0,
834
- minHeight: 0,
835
- height: "100%"
828
+ function applyReorderOnlyRedock(model, registry, drop) {
829
+ const { groupId, activePanelId, draggedPanelId, draggedGroupId, zone, reorderIndex } = drop;
830
+ if (draggedGroupId === void 0 || draggedGroupId !== groupId) return;
831
+ if (zone !== "center") return;
832
+ const group = findGroupById(model.get().root, groupId);
833
+ let index;
834
+ if (reorderIndex !== void 0 && group) {
835
+ const visibleTabIds = group.panels.filter((panelId) => registry.get(panelId)?.hideTab !== true);
836
+ index = resolveReorderInsertIndex(group.panels, visibleTabIds, draggedPanelId, reorderIndex);
837
+ } else index = group ? group.panels.indexOf(activePanelId) : void 0;
838
+ model.apply((t) => movePanel(t, draggedPanelId, {
839
+ groupId,
840
+ index
841
+ }));
842
+ }
843
+ function DockView(props) {
844
+ const { model, registry, renderDomPanel, bindNativeSlot, suppressReorderOnlyDropIndicator, onActiveTabClick } = props;
845
+ const [tree, setTree] = useState(() => model.get());
846
+ const [epoch, setEpoch] = useState(0);
847
+ const activeDragPanelId = useRef(null);
848
+ useEffect(() => {
849
+ setTree(model.get());
850
+ return model.subscribe((snap) => {
851
+ setTree(snap.tree);
852
+ setEpoch(snap.revision);
853
+ });
854
+ }, [model]);
855
+ const handleActivate = useCallback((groupId, panelId) => {
856
+ model.apply((t) => setActive(t, groupId, panelId));
857
+ }, [model]);
858
+ const handleClose = useCallback((panelId) => {
859
+ model.apply((t) => closePanelForUser(t, panelId, registry));
860
+ }, [model, registry]);
861
+ const canClose = countPanels(tree.root) > 1;
862
+ const isPanelInTree = useCallback((panelId) => findPanelGroupId(tree.root, panelId) !== void 0, [tree]);
863
+ const handleApplyLayout = useCallback((splitId, weights) => {
864
+ model.apply((t) => setSizes(t, splitId, weights));
865
+ }, [model]);
866
+ const handleRedock = useCallback((groupId, activePanelId, draggedPanelId, zone, reorderIndex) => {
867
+ const target = {
868
+ groupId,
869
+ panelId: activePanelId
870
+ };
871
+ const draggedGroupId = findPanelGroupId(model.get().root, draggedPanelId);
872
+ if (registry.get(draggedPanelId)?.draggable === false) return;
873
+ if (registry.get(activePanelId)?.draggable === false) return;
874
+ if (registry.get(draggedPanelId)?.dropPolicy === "reorder-only") {
875
+ applyReorderOnlyRedock(model, registry, {
876
+ groupId,
877
+ activePanelId,
878
+ draggedPanelId,
879
+ draggedGroupId,
880
+ zone,
881
+ reorderIndex
882
+ });
883
+ return;
884
+ }
885
+ if (isNoopRedock(draggedPanelId, draggedGroupId, target, zone)) return;
886
+ const mutation = dropZoneToMutation(zone, draggedPanelId, target);
887
+ if (mutation.kind === "move") {
888
+ model.apply((t) => movePanel(t, mutation.panelId, { groupId: mutation.destGroupId }));
889
+ return;
836
890
  }
891
+ model.apply((t) => {
892
+ const { tree } = extractPanel(t, mutation.newPanelId);
893
+ if (findPanelGroupId(tree.root, mutation.atPanelId) === void 0) return t;
894
+ return splitPanel(tree, mutation.atPanelId, mutation.dir, mutation.newPanelId, mutation.side);
895
+ });
896
+ }, [model, registry]);
897
+ return /* @__PURE__ */ jsx(LayoutEpochContext.Provider, {
898
+ value: epoch,
899
+ children: renderNode(tree.root, {
900
+ registry,
901
+ renderDomPanel,
902
+ bindNativeSlot,
903
+ onActivate: handleActivate,
904
+ onActiveTabClick,
905
+ onApplyLayout: handleApplyLayout,
906
+ onRedock: handleRedock,
907
+ onClose: handleClose,
908
+ canClose,
909
+ isPanelInTree,
910
+ suppressReorderOnlyDropIndicator: suppressReorderOnlyDropIndicator ?? false,
911
+ activeDragPanelId
912
+ })
837
913
  });
838
914
  }
915
+ function renderNode(node, ctx) {
916
+ return node.kind === "split" ? renderSplit(node, ctx) : renderGroup(node, ctx);
917
+ }
918
+ /** A split is a stateful component (it holds the rrp Group imperative ref + runs
919
+ * the M1 model→view sync effect), so render it via JSX with a STABLE key so
920
+ * React preserves that ref / its kept-alive subtree across model emissions
921
+ * rather than remounting on every snapshot. Mirrors `renderGroup`/`GroupView`. */
922
+ function renderSplit(node, ctx) {
923
+ return /* @__PURE__ */ jsx(SplitView, {
924
+ node,
925
+ ctx
926
+ }, node.id);
927
+ }
928
+ function renderGroup(node, ctx) {
929
+ return /* @__PURE__ */ jsx(GroupView, {
930
+ node,
931
+ ctx
932
+ }, node.id);
933
+ }
839
934
  //#endregion
840
- export { DockView, computeDropZone, computeFlexiblePercentages, computeReorderIndex, dropZoneToMutation, isNoopRedock, layoutsEquivalent, useDockLayoutEpoch };
935
+ export { DockView, computeDropZone, computeFlexiblePercentages, computeReorderIndex, dropZoneToMutation, isNoopRedock, layoutsEquivalent, resolveReorderInsertIndex, useDockLayoutEpoch };
841
936
 
842
937
  //# sourceMappingURL=index.js.map