@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.
@@ -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