@grafloria/element 0.4.31 → 0.4.33

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.
@@ -156,6 +156,38 @@ export function pressOnDragHandle(sel, target, hostEl, clientX, clientY) {
156
156
  export const CAPTION_BAND = 28;
157
157
  /** A torn-out page never arrives shorter than this: a strip with no room under it is not a group. */
158
158
  export const TEAR_OUT_MIN_ROWS = 2;
159
+ /**
160
+ * Steps that depend on each other's RESULT — create a group, then move a page
161
+ * into it, then place the group on the board; or remove a member and then the
162
+ * group it emptied — run as one history step. A batch checks every member's
163
+ * `canExecute` before running any of them and every member's `canUndo` before
164
+ * undoing any, and a membership command's precondition (its group exists) is
165
+ * exactly what a neighbouring step creates or removes. This runs the chain in
166
+ * order and reverses it on undo, judging nothing up front.
167
+ */
168
+ export class SequenceCommand extends Command {
169
+ constructor(name, steps) {
170
+ super(name);
171
+ this.steps = steps;
172
+ }
173
+ execute(context) {
174
+ for (const c of this.steps)
175
+ c.execute(context);
176
+ }
177
+ undo(context) {
178
+ for (let i = this.steps.length - 1; i >= 0; i--)
179
+ this.steps[i].undo(context);
180
+ }
181
+ canExecute() {
182
+ return true;
183
+ }
184
+ canUndo() {
185
+ return true;
186
+ }
187
+ serialize() {
188
+ return { id: this.id, name: this.name, timestamp: this.timestamp, data: { steps: this.steps.map((c) => c.serialize()) } };
189
+ }
190
+ }
159
191
  /**
160
192
  * Binders on the same canvas find each other here. A WeakMap: a canvas that
161
193
  * is gone takes its (empty) peer set with it — the strong Map it replaced kept
@@ -1257,6 +1289,8 @@ export function bindDashboardGrid(api, group, options = {}) {
1257
1289
  return false;
1258
1290
  };
1259
1291
  let slabGesture = null;
1292
+ /** The slab gesture as it is NOW — read through a call so a guard's narrowing does not stick. */
1293
+ const currentSlab = () => slabGesture;
1260
1294
  /** The PARENT running a resize of OUR section from a press this tool claimed. */
1261
1295
  let forwardSlab = null;
1262
1296
  const frameOfGroup = (grp) => ({ x: grp.position.x, y: grp.position.y, width: sizeOf(grp).width, height: sizeOf(grp).height });
@@ -1281,10 +1315,48 @@ export function bindDashboardGrid(api, group, options = {}) {
1281
1315
  dx: edges.e ? grp.position.x + sizeOf(grp).width - ev.world.x : edges.w ? grp.position.x - ev.world.x : 0,
1282
1316
  dy: edges.s ? grp.position.y + sizeOf(grp).height - ev.world.y : edges.n ? grp.position.y - ev.world.y : 0,
1283
1317
  },
1318
+ move: false,
1284
1319
  };
1285
1320
  capturePointer(slabGesture.pointerId);
1286
1321
  api.container.style.cursor = cursorFor(edges);
1287
1322
  };
1323
+ /**
1324
+ * MOVE a section by its caption band or its strip's empty space. A section
1325
+ * is a LOCKED tile so that nothing pushes it; for the gesture's duration the
1326
+ * tile is unlocked (it is the one moving), other sections stay locked and
1327
+ * still refuse it (E4b), and every unlocked tile gets pushed the way a
1328
+ * widget pushes it. Its children ride along: the frame moves, the nested
1329
+ * board re-projects.
1330
+ */
1331
+ const beginSlabMove = (id, ev) => {
1332
+ const grp = diagram.getGroup(id);
1333
+ const it = engine.getItem(id);
1334
+ if (!grp || !it || gesture || slabGesture || isStatic)
1335
+ return;
1336
+ engine.beginGesture();
1337
+ const snap = snapshotAll();
1338
+ it.locked = false;
1339
+ slabGesture = {
1340
+ id,
1341
+ edges: NO_EDGES,
1342
+ pointerId: typeof PointerEvent !== 'undefined' && ev.source instanceof PointerEvent ? ev.source.pointerId : null,
1343
+ started: false,
1344
+ downScreen: { x: ev.screen.x, y: ev.screen.y },
1345
+ startCells: snap.cells,
1346
+ startGeom: snap.geoms,
1347
+ cellBefore: { x: it.x, y: it.y, w: it.w, h: it.h },
1348
+ frameBefore: frameOfGroup(grp),
1349
+ grab: { dx: grp.position.x - ev.world.x, dy: grp.position.y - ev.world.y },
1350
+ move: true,
1351
+ };
1352
+ capturePointer(slabGesture.pointerId);
1353
+ api.container.style.cursor = 'grabbing';
1354
+ };
1355
+ const relockSlab = (id) => {
1356
+ const it = engine.getItem(id);
1357
+ if (it)
1358
+ it.locked = true;
1359
+ };
1288
1360
  const slabMove = (ev) => {
1289
1361
  const g = slabGesture;
1290
1362
  if (!g)
@@ -1300,6 +1372,14 @@ export function bindDashboardGrid(api, group, options = {}) {
1300
1372
  return;
1301
1373
  const f = frame();
1302
1374
  const gg = geom();
1375
+ if (g.move) {
1376
+ // The section's top-left follows the pointer by the offset it was grabbed at.
1377
+ const cell = pointToCell(ev.world.x + g.grab.dx, ev.world.y + g.grab.dy, f, gg, rows(), it.w);
1378
+ if ((cell.x !== it.x || cell.y !== it.y) && engine.moveCheck(g.id, cell.x, cell.y, { gate: false }).changed)
1379
+ project();
1380
+ syncPlaceholder();
1381
+ return;
1382
+ }
1303
1383
  const cu = columnUnitFor(gg, f.width);
1304
1384
  const rh = rowHeightFor(gg, rows());
1305
1385
  // The grid line nearest the pointer, in cells (mirrored on RTL).
@@ -1346,6 +1426,8 @@ export function bindDashboardGrid(api, group, options = {}) {
1346
1426
  slabGesture = null;
1347
1427
  releasePointer(g.pointerId);
1348
1428
  api.container.style.cursor = '';
1429
+ if (g.move)
1430
+ relockSlab(g.id);
1349
1431
  if (!g.started) {
1350
1432
  engine.endGesture();
1351
1433
  return;
@@ -1359,11 +1441,11 @@ export function bindDashboardGrid(api, group, options = {}) {
1359
1441
  if (it && grp && (b.x !== it.x || b.y !== it.y || b.w !== it.w || b.h !== it.h)) {
1360
1442
  commands.push(new SetGroupCellCommand(g.id, b, { x: it.x, y: it.y, w: it.w, h: it.h }, g.frameBefore, frameOfGroup(grp)));
1361
1443
  }
1362
- const changed = execute('Resize section', commands);
1444
+ const changed = execute(g.move ? 'Move section' : 'Resize section', commands);
1363
1445
  disarmGlideSoon();
1364
1446
  syncHandles();
1365
1447
  api.renderNow();
1366
- (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: 'resize', nodeId: g.id, changed });
1448
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: g.move ? 'move' : 'resize', nodeId: g.id, changed });
1367
1449
  };
1368
1450
  const slabCancel = () => {
1369
1451
  var _a;
@@ -1373,6 +1455,8 @@ export function bindDashboardGrid(api, group, options = {}) {
1373
1455
  slabGesture = null;
1374
1456
  releasePointer(g.pointerId);
1375
1457
  api.container.style.cursor = '';
1458
+ if (g.move)
1459
+ relockSlab(g.id);
1376
1460
  if (g.started)
1377
1461
  engine.cancelGesture();
1378
1462
  else
@@ -1487,9 +1571,13 @@ export function bindDashboardGrid(api, group, options = {}) {
1487
1571
  (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed });
1488
1572
  };
1489
1573
  const cancelActiveGesture = (notify = true) => {
1490
- var _a, _b, _c, _d;
1574
+ var _a, _b, _c, _d, _e;
1575
+ if (gesture === null || gesture === void 0 ? void 0 : gesture.strip) {
1576
+ (_a = options.tabDrop) === null || _a === void 0 ? void 0 : _a.markDrop(null, null);
1577
+ gesture.strip = null;
1578
+ }
1491
1579
  if (forwardSlab) {
1492
- (_a = forwardSlab.slabCancel) === null || _a === void 0 ? void 0 : _a.call(forwardSlab);
1580
+ (_b = forwardSlab.slabCancel) === null || _b === void 0 ? void 0 : _b.call(forwardSlab);
1493
1581
  forwardSlab = null;
1494
1582
  }
1495
1583
  if (slabGesture)
@@ -1514,7 +1602,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1514
1602
  engine.endGesture();
1515
1603
  const items = [];
1516
1604
  for (const [id, c] of g.startCells) {
1517
- const lockedNode = ((_c = (_b = diagram.getNode(id)) === null || _b === void 0 ? void 0 : _b.state) === null || _c === void 0 ? void 0 : _c.locked) === true;
1605
+ const lockedNode = ((_d = (_c = diagram.getNode(id)) === null || _c === void 0 ? void 0 : _c.state) === null || _d === void 0 ? void 0 : _d.locked) === true;
1518
1606
  items.push(Object.assign(Object.assign({ id }, c), { locked: lockedNode || isGroupMember(id) }));
1519
1607
  }
1520
1608
  engine = engineFrom(items);
@@ -1562,7 +1650,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1562
1650
  enforceBoardHeight();
1563
1651
  api.renderNow();
1564
1652
  if (notify) {
1565
- (_d = options.onGesture) === null || _d === void 0 ? void 0 : _d.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1653
+ (_e = options.onGesture) === null || _e === void 0 ? void 0 : _e.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1566
1654
  }
1567
1655
  };
1568
1656
  /** Centre a w×h-span tile's top-left under the cursor, in world px. */
@@ -1577,7 +1665,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1577
1665
  };
1578
1666
  };
1579
1667
  const onToolMove = (ev) => {
1580
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
1668
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1581
1669
  const g = gesture;
1582
1670
  if (!g || g.kind === 'palette')
1583
1671
  return;
@@ -1594,6 +1682,36 @@ export function bindDashboardGrid(api, group, options = {}) {
1594
1682
  ghostStyleFastPath(g, desired);
1595
1683
  g.lastWorld = { x: ev.world.x, y: ev.world.y };
1596
1684
  g.lastScreen = { x: ev.screen.x, y: ev.screen.y };
1685
+ // A TAB STRIP under the pointer wins over every board: the widget will
1686
+ // become a new tab there, so it leaves this board (survivors settle
1687
+ // home) and the strip marks the slot.
1688
+ if (options.tabDrop && !isStatic) {
1689
+ const crect = api.container.getBoundingClientRect();
1690
+ const hitStrip = options.tabDrop.stripAt(crect.left + ev.screen.x, crect.top + ev.screen.y);
1691
+ if (hitStrip) {
1692
+ if (g.leg) {
1693
+ g.leg.adopted.abort();
1694
+ g.leg = null;
1695
+ }
1696
+ if (!g.removedFromBoard) {
1697
+ g.removedFromBoard = true;
1698
+ engine.remove(g.id);
1699
+ project();
1700
+ }
1701
+ (_a = hostOf(g.id)) === null || _a === void 0 ? void 0 : _a.classList.remove('axdb-out');
1702
+ if (!g.strip || g.strip.containerId !== hitStrip.containerId || g.strip.index !== hitStrip.index) {
1703
+ options.tabDrop.markDrop(hitStrip.containerId, hitStrip.index);
1704
+ }
1705
+ g.strip = hitStrip;
1706
+ syncPlaceholder();
1707
+ api.render();
1708
+ return;
1709
+ }
1710
+ if (g.strip) {
1711
+ options.tabDrop.markDrop(null, null);
1712
+ g.strip = null;
1713
+ }
1714
+ }
1597
1715
  // Deepest board under the pointer wins: the nested KPI strip beats the
1598
1716
  // tab that contains it; a foreign board beats "outside". Strict frames
1599
1717
  // first; the one-row grace band below each board (gridstack's extra
@@ -1633,13 +1751,13 @@ export function bindDashboardGrid(api, group, options = {}) {
1633
1751
  height: g.node.size.height,
1634
1752
  });
1635
1753
  if (adopted) {
1636
- (_a = hostOf(g.id)) === null || _a === void 0 ? void 0 : _a.classList.remove('axdb-out');
1754
+ (_b = hostOf(g.id)) === null || _b === void 0 ? void 0 : _b.classList.remove('axdb-out');
1637
1755
  g.leg = { peer, adopted };
1638
1756
  g.leg.adopted.move(ev.world);
1639
1757
  }
1640
1758
  else {
1641
1759
  // Bounded/full board refused the adoption: dim = will snap home.
1642
- (_b = hostOf(g.id)) === null || _b === void 0 ? void 0 : _b.classList.add('axdb-out');
1760
+ (_c = hostOf(g.id)) === null || _c === void 0 ? void 0 : _c.classList.add('axdb-out');
1643
1761
  }
1644
1762
  }
1645
1763
  }
@@ -1651,7 +1769,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1651
1769
  }
1652
1770
  if (g.removedFromBoard) {
1653
1771
  g.removedFromBoard = false;
1654
- (_c = hostOf(g.id)) === null || _c === void 0 ? void 0 : _c.classList.remove('axdb-out');
1772
+ (_d = hostOf(g.id)) === null || _d === void 0 ? void 0 : _d.classList.remove('axdb-out');
1655
1773
  const cell = pointToCell(desired.x, desired.y, frame(), geom(), rows(), g.spans.w);
1656
1774
  // Re-enter at the bottom edge (collision-free), then take the cursor
1657
1775
  // cell GATELESSLY — a first placement skips the anti-jitter gate.
@@ -1660,7 +1778,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1660
1778
  project();
1661
1779
  }
1662
1780
  else {
1663
- const spanW = (_e = (_d = engine.getItem(g.id)) === null || _d === void 0 ? void 0 : _d.w) !== null && _e !== void 0 ? _e : g.spans.w;
1781
+ const spanW = (_f = (_e = engine.getItem(g.id)) === null || _e === void 0 ? void 0 : _e.w) !== null && _f !== void 0 ? _f : g.spans.w;
1664
1782
  const cell = pointToCell(desired.x, desired.y, frame(), geom(), rows(), spanW);
1665
1783
  if (engine.moveCheck(g.id, cell.x, cell.y).changed)
1666
1784
  project();
@@ -1675,7 +1793,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1675
1793
  if (!g.removedFromBoard) {
1676
1794
  g.removedFromBoard = true;
1677
1795
  engine.remove(g.id); // survivors settle home; cells minted nowhere
1678
- (_f = hostOf(g.id)) === null || _f === void 0 ? void 0 : _f.classList.add('axdb-out');
1796
+ (_g = hostOf(g.id)) === null || _g === void 0 ? void 0 : _g.classList.add('axdb-out');
1679
1797
  project();
1680
1798
  }
1681
1799
  }
@@ -1785,7 +1903,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1785
1903
  maxRows = n;
1786
1904
  engine.maxRows = n;
1787
1905
  };
1788
- const slabRows = (_h = (_g = parent.memberCell(group.id)) === null || _g === void 0 ? void 0 : _g.h) !== null && _h !== void 0 ? _h : maxRows;
1906
+ const slabRows = (_j = (_h = parent.memberCell(group.id)) === null || _h === void 0 ? void 0 : _h.h) !== null && _j !== void 0 ? _j : maxRows;
1789
1907
  const inner = maxRows;
1790
1908
  const rhNow = rowHeightFor(geom(), rows());
1791
1909
  const rowPx = rhNow + gap;
@@ -1949,7 +2067,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1949
2067
  const anchorBottom = liveRect ? liveRect.y + liveRect.height : bottom;
1950
2068
  const px = E.w ? anchorRight - w : anchorLeft;
1951
2069
  const py = E.n ? anchorBottom - h : anchorTop;
1952
- g.node.setSize(w, h, (_j = g.node.size.depth) !== null && _j !== void 0 ? _j : 0);
2070
+ g.node.setSize(w, h, (_k = g.node.size.depth) !== null && _k !== void 0 ? _k : 0);
1953
2071
  g.node.setPosition(px, py);
1954
2072
  ghostStyleFastPath(g, { x: px, y: py, width: w, height: h });
1955
2073
  const spanF = bound() !== undefined ? frame() : f;
@@ -1990,7 +2108,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1990
2108
  syncPlaceholder();
1991
2109
  };
1992
2110
  const onToolUp = () => {
1993
- var _a, _b, _c, _d, _e, _f;
2111
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
1994
2112
  const g = gesture;
1995
2113
  if (!g || g.kind === 'palette')
1996
2114
  return;
@@ -1998,6 +2116,36 @@ export function bindDashboardGrid(api, group, options = {}) {
1998
2116
  gesture = null; // a plain click — the page's own click-to-focus handles it
1999
2117
  return;
2000
2118
  }
2119
+ if (g.strip && options.tabDrop) {
2120
+ // -- INTO A STRIP: the widget becomes a new tab of that container ------
2121
+ const target = g.strip;
2122
+ g.strip = null;
2123
+ options.tabDrop.markDrop(null, null);
2124
+ if (g.leg) {
2125
+ g.leg.adopted.abort();
2126
+ g.leg = null;
2127
+ }
2128
+ const displaced = buildCommitCommands(deltasSince(g.startCells, g.startGeom, g.id));
2129
+ const snap = g.startGeom.get(g.id);
2130
+ if (snap) {
2131
+ g.node.setPosition(snap.pos.x, snap.pos.y);
2132
+ g.node.setSize(snap.size.width, snap.size.height, (_a = snap.size.depth) !== null && _a !== void 0 ? _a : 0);
2133
+ }
2134
+ const cmds = options.tabDrop.dropIntoStrip(g.id, target.containerId, target.index, group.id, displaced);
2135
+ if (cmds.length === 0) {
2136
+ cancelActiveGesture();
2137
+ return;
2138
+ }
2139
+ engine.endGesture();
2140
+ cleanupGestureVisuals(g);
2141
+ gesture = null;
2142
+ execute('Move widget into a new tab', cmds);
2143
+ enforceBoardHeight();
2144
+ persistLayouts();
2145
+ api.renderNow();
2146
+ (_b = options.onGesture) === null || _b === void 0 ? void 0 : _b.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed: true });
2147
+ return;
2148
+ }
2001
2149
  if (g.leg) {
2002
2150
  // -- CROSS-CONTAINER COMMIT: one batch across both boards -----------
2003
2151
  const fin = g.leg.adopted.finalize();
@@ -2040,21 +2188,25 @@ export function bindDashboardGrid(api, group, options = {}) {
2040
2188
  isGroup: false,
2041
2189
  cellBefore: before !== null && before !== void 0 ? before : fin.cell,
2042
2190
  cellAfter: fin.cell,
2043
- posBefore: (_a = geomBefore === null || geomBefore === void 0 ? void 0 : geomBefore.pos) !== null && _a !== void 0 ? _a : { x: fin.rect.x, y: fin.rect.y },
2191
+ posBefore: (_c = geomBefore === null || geomBefore === void 0 ? void 0 : geomBefore.pos) !== null && _c !== void 0 ? _c : { x: fin.rect.x, y: fin.rect.y },
2044
2192
  posAfter: { x: fin.rect.x, y: fin.rect.y },
2045
- sizeBefore: (_b = geomBefore === null || geomBefore === void 0 ? void 0 : geomBefore.size) !== null && _b !== void 0 ? _b : { width: fin.rect.width, height: fin.rect.height },
2193
+ sizeBefore: (_d = geomBefore === null || geomBefore === void 0 ? void 0 : geomBefore.size) !== null && _d !== void 0 ? _d : { width: fin.rect.width, height: fin.rect.height },
2046
2194
  sizeAfter: { width: fin.rect.width, height: fin.rect.height },
2047
2195
  },
2048
2196
  ]),
2049
2197
  ];
2050
- execute('Move widget', crossing);
2198
+ // …and whatever follows a member out of this board: an emptied page
2199
+ // closes — in which case the membership commands ride INSIDE one
2200
+ // sequence with it, or the batch could never undo (its group is gone).
2201
+ const leaving = (_f = (_e = options.onMemberLeaving) === null || _e === void 0 ? void 0 : _e.call(options, g.id)) !== null && _f !== void 0 ? _f : [];
2202
+ execute('Move widget', leaving.length > 0 ? [new SequenceCommand('Move widget', [...crossing, ...leaving])] : crossing);
2051
2203
  engine.endGesture();
2052
2204
  cleanupGestureVisuals(g);
2053
2205
  gesture = null;
2054
2206
  enforceBoardHeight();
2055
2207
  persistLayouts();
2056
2208
  api.renderNow();
2057
- (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed: true });
2209
+ (_g = options.onGesture) === null || _g === void 0 ? void 0 : _g.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed: true });
2058
2210
  return;
2059
2211
  }
2060
2212
  if (g.removedFromBoard && dragOut === 'cancel') {
@@ -2085,15 +2237,15 @@ export function bindDashboardGrid(api, group, options = {}) {
2085
2237
  // Park the node on its start rect so the page's RemoveNodeCommand
2086
2238
  // captures sane geometry for undo.
2087
2239
  g.node.setPosition(snap.pos.x, snap.pos.y);
2088
- g.node.setSize(snap.size.width, snap.size.height, (_d = snap.size.depth) !== null && _d !== void 0 ? _d : 0);
2240
+ g.node.setSize(snap.size.width, snap.size.height, (_h = snap.size.depth) !== null && _h !== void 0 ? _h : 0);
2089
2241
  }
2090
2242
  engine.endGesture();
2091
2243
  cleanupGestureVisuals(g);
2092
2244
  gesture = null;
2093
2245
  enforceBoardHeight();
2094
2246
  api.renderNow();
2095
- void ((_e = options.onRemoveRequest) === null || _e === void 0 ? void 0 : _e.call(options, g.id, displaced));
2096
- (_f = options.onGesture) === null || _f === void 0 ? void 0 : _f.call(options, { type: 'remove', kind: g.kind, nodeId: g.id, changed: true });
2247
+ void ((_j = options.onRemoveRequest) === null || _j === void 0 ? void 0 : _j.call(options, g.id, displaced));
2248
+ (_k = options.onGesture) === null || _k === void 0 ? void 0 : _k.call(options, { type: 'remove', kind: g.kind, nodeId: g.id, changed: true });
2097
2249
  return;
2098
2250
  }
2099
2251
  commitGesture(g);
@@ -2301,6 +2453,34 @@ export function bindDashboardGrid(api, group, options = {}) {
2301
2453
  syncPlaceholder();
2302
2454
  return {
2303
2455
  groupId: group.id,
2456
+ baseline: () => startCells,
2457
+ place: (cell) => {
2458
+ if (!engine.getItem(node.id) && !engine.add({ id: node.id, x: 0, y: engine.rows(), w: cell.w, h: cell.h }))
2459
+ return false;
2460
+ const it = engine.getItem(node.id);
2461
+ if (!it)
2462
+ return false;
2463
+ // Shrink, move, then grow. A resize is clamped at the board's edge
2464
+ // where the ghost SITS, so a cell wider than fits there (a full-width
2465
+ // dock from a ghost parked mid-board) is reached by moving first —
2466
+ // and a cell narrower than the ghost by shrinking first, so the move
2467
+ // itself fits.
2468
+ const w0 = Math.min(it.w, cell.w);
2469
+ const h0 = Math.min(it.h, cell.h);
2470
+ if (w0 !== it.w || h0 !== it.h)
2471
+ engine.resizeCheck(node.id, w0, h0);
2472
+ const moved = engine.getItem(node.id);
2473
+ if (moved && (moved.x !== cell.x || moved.y !== cell.y))
2474
+ engine.moveCheck(node.id, cell.x, cell.y, { gate: false });
2475
+ const now = engine.getItem(node.id);
2476
+ if (now && (now.w !== cell.w || now.h !== cell.h))
2477
+ engine.resizeCheck(node.id, cell.w, cell.h);
2478
+ lastWant = null;
2479
+ project();
2480
+ syncPlaceholder();
2481
+ const at = engine.getItem(node.id);
2482
+ return !!at && at.x === cell.x && at.y === cell.y && at.w === cell.w && at.h === cell.h;
2483
+ },
2304
2484
  move: (w) => {
2305
2485
  const item = engine.getItem(node.id);
2306
2486
  if (!item)
@@ -2354,11 +2534,29 @@ export function bindDashboardGrid(api, group, options = {}) {
2354
2534
  const cell = { x: item.x, y: item.y, w: item.w, h: item.h };
2355
2535
  const rect = cellToRect(item, frame(), geom(), rows());
2356
2536
  const commands = buildCommitCommands(deltasSince(startCells, startGeom, node.id));
2537
+ const groups = [];
2538
+ for (const [id, before] of startCells) {
2539
+ if (!isGroupMember(id))
2540
+ continue;
2541
+ const it = engine.getItem(id);
2542
+ const g0 = startGeom.get(id);
2543
+ if (!it || !g0)
2544
+ continue;
2545
+ if (it.x === before.x && it.y === before.y && it.w === before.w && it.h === before.h)
2546
+ continue;
2547
+ groups.push({
2548
+ id,
2549
+ cellBefore: before,
2550
+ cellAfter: { x: it.x, y: it.y, w: it.w, h: it.h },
2551
+ frameBefore: { x: g0.pos.x, y: g0.pos.y, width: g0.size.width, height: g0.size.height },
2552
+ frameAfter: cellToRect(it, frame(), geom(), rows()),
2553
+ });
2554
+ }
2357
2555
  engine.endGesture();
2358
2556
  adoptedGhostId = null;
2359
2557
  disarmGlideSoon();
2360
2558
  syncPlaceholder();
2361
- return { commands, cell, rect };
2559
+ return { commands, cell, rect, groups };
2362
2560
  },
2363
2561
  };
2364
2562
  };
@@ -2388,6 +2586,56 @@ export function bindDashboardGrid(api, group, options = {}) {
2388
2586
  beginSlabResize(id, edges, ev);
2389
2587
  return (slabGesture === null || slabGesture === void 0 ? void 0 : slabGesture.id) === id;
2390
2588
  },
2589
+ beginSlabMove: (id, ev) => {
2590
+ if (isStatic)
2591
+ return false;
2592
+ beginSlabMove(id, ev);
2593
+ return (slabGesture === null || slabGesture === void 0 ? void 0 : slabGesture.id) === id;
2594
+ },
2595
+ dragMember: (id, ev) => {
2596
+ var _a;
2597
+ if (isStatic || disposed || !engine.getItem(id) || gesture || slabGesture)
2598
+ return false;
2599
+ const toTool = (e) => {
2600
+ var _a;
2601
+ const rect = api.container.getBoundingClientRect();
2602
+ const world = ((_a = api.viewport) === null || _a === void 0 ? void 0 : _a.clientToWorld) ? api.viewport.clientToWorld(e.clientX, e.clientY, rect) : { x: e.clientX - rect.left, y: e.clientY - rect.top };
2603
+ return { world, screen: { x: e.clientX - rect.left, y: e.clientY - rect.top }, source: e };
2604
+ };
2605
+ beginSlabMove(id, toTool(ev));
2606
+ if (((_a = currentSlab()) === null || _a === void 0 ? void 0 : _a.id) !== id)
2607
+ return false;
2608
+ const detachAll = () => {
2609
+ window.removeEventListener('pointermove', onMove, true);
2610
+ window.removeEventListener('pointerup', onUp, true);
2611
+ window.removeEventListener('pointercancel', onCancel, true);
2612
+ window.removeEventListener('keydown', onKey, true);
2613
+ };
2614
+ const onMove = (e) => {
2615
+ var _a;
2616
+ if (disposed || ((_a = currentSlab()) === null || _a === void 0 ? void 0 : _a.id) !== id)
2617
+ return detachAll();
2618
+ slabMove(toTool(e));
2619
+ api.render();
2620
+ };
2621
+ const onUp = () => {
2622
+ detachAll();
2623
+ slabUp();
2624
+ };
2625
+ const onCancel = () => {
2626
+ detachAll();
2627
+ slabCancel();
2628
+ };
2629
+ const onKey = (e) => {
2630
+ if (e.key === 'Escape')
2631
+ onCancel();
2632
+ };
2633
+ window.addEventListener('pointermove', onMove, true);
2634
+ window.addEventListener('pointerup', onUp, true);
2635
+ window.addEventListener('pointercancel', onCancel, true);
2636
+ window.addEventListener('keydown', onKey, true);
2637
+ return true;
2638
+ },
2391
2639
  slabMove: (ev) => slabMove(ev),
2392
2640
  slabUp: () => slabUp(),
2393
2641
  slabCancel: () => slabCancel(),
@@ -2494,7 +2742,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2494
2742
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
2495
2743
  },
2496
2744
  onPointerDown(ev, hit) {
2497
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
2745
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z;
2498
2746
  if (gesture)
2499
2747
  return; // mid-palette
2500
2748
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -2532,6 +2780,10 @@ export function bindDashboardGrid(api, group, options = {}) {
2532
2780
  : slabEdgesNear(grp, ev.world.x, ev.world.y);
2533
2781
  if (anyEdge(edges))
2534
2782
  beginSlabResize(slabId, edges, ev);
2783
+ // The caption band is the section's handle: pressed and travelled,
2784
+ // it moves the whole section (its inner empty space only selects).
2785
+ else if (ownCaption)
2786
+ beginSlabMove(slabId, ev);
2535
2787
  return;
2536
2788
  }
2537
2789
  // OUR OWN empty band, and we are a section of a parent board: the
@@ -2548,12 +2800,14 @@ export function bindDashboardGrid(api, group, options = {}) {
2548
2800
  : slabEdgesNear(group, ev.world.x, ev.world.y);
2549
2801
  if (anyEdge(edges) && parent.beginSlabResize(group.id, edges, ev))
2550
2802
  forwardSlab = parent;
2803
+ else if (!anyEdge(edges) && captionId === group.id && ((_r = parent.beginSlabMove) === null || _r === void 0 ? void 0 : _r.call(parent, group.id, ev)))
2804
+ forwardSlab = parent;
2551
2805
  }
2552
2806
  return;
2553
2807
  }
2554
2808
  // The board's own empty area: a void click. Nothing to drag, and the
2555
2809
  // selection clears exactly as a click outside any board would.
2556
- (_s = (_r = diagram).clearSelection) === null || _s === void 0 ? void 0 : _s.call(_r);
2810
+ (_t = (_s = diagram).clearSelection) === null || _t === void 0 ? void 0 : _t.call(_s);
2557
2811
  selectWidget(undefined);
2558
2812
  api.render();
2559
2813
  return;
@@ -2562,17 +2816,17 @@ export function bindDashboardGrid(api, group, options = {}) {
2562
2816
  if (!node)
2563
2817
  return;
2564
2818
  selectWidget(node.id); // a press selects, whether or not it starts a gesture
2565
- if (((_t = node.state) === null || _t === void 0 ? void 0 : _t.locked) === true)
2819
+ if (((_u = node.state) === null || _u === void 0 ? void 0 : _u.locked) === true)
2566
2820
  return; // pinned: refuse; click still focuses
2567
2821
  if (isStatic)
2568
2822
  return; // a static board: claimed and deadened, click still focuses
2569
2823
  // Which edges did the press take? The corner handle names its own (s+e,
2570
2824
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
2571
2825
  // takes that border; anywhere else is a move. A grip press is a move.
2572
- const onHandle = !!((_u = target === null || target === void 0 ? void 0 : target.closest) === null || _u === void 0 ? void 0 : _u.call(target, '.axdb-rs'));
2826
+ const onHandle = !!((_v = target === null || target === void 0 ? void 0 : target.closest) === null || _v === void 0 ? void 0 : _v.call(target, '.axdb-rs'));
2573
2827
  const hostEl = hostOf(node.id);
2574
- const resizable = ((_v = node.getMetadata) === null || _v === void 0 ? void 0 : _v.call(node, 'widgetResizable')) !== false;
2575
- const movable = ((_w = node.getMetadata) === null || _w === void 0 ? void 0 : _w.call(node, 'widgetMovable')) !== false;
2828
+ const resizable = ((_w = node.getMetadata) === null || _w === void 0 ? void 0 : _w.call(node, 'widgetResizable')) !== false;
2829
+ const movable = ((_x = node.getMetadata) === null || _x === void 0 ? void 0 : _x.call(node, 'widgetMovable')) !== false;
2576
2830
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
2577
2831
  // like with like — the source event's clientX/Y when there is one, else
2578
2832
  // the container's origin plus the local offset.
@@ -2620,9 +2874,10 @@ export function bindDashboardGrid(api, group, options = {}) {
2620
2874
  startSize: { width: node.size.width, height: node.size.height },
2621
2875
  startPos: { x: node.position.x, y: node.position.y },
2622
2876
  edges,
2623
- spans: { w: (_x = it === null || it === void 0 ? void 0 : it.w) !== null && _x !== void 0 ? _x : 1, h: (_y = it === null || it === void 0 ? void 0 : it.h) !== null && _y !== void 0 ? _y : 1 },
2877
+ spans: { w: (_y = it === null || it === void 0 ? void 0 : it.w) !== null && _y !== void 0 ? _y : 1, h: (_z = it === null || it === void 0 ? void 0 : it.h) !== null && _z !== void 0 ? _z : 1 },
2624
2878
  removedFromBoard: false,
2625
2879
  leg: null,
2880
+ strip: null,
2626
2881
  lastWorld: null,
2627
2882
  lastScreen: null,
2628
2883
  hostEl: null,
@@ -2927,31 +3182,258 @@ export function bindDashboardGrid(api, group, options = {}) {
2927
3182
  const rect = api.container.getBoundingClientRect();
2928
3183
  return ((_a = api.viewport) === null || _a === void 0 ? void 0 : _a.clientToWorld) ? api.viewport.clientToWorld(cx, cy, rect) : { x: cx - rect.left, y: cy - rect.top };
2929
3184
  };
2930
- const first = toWorld(ev.clientX, ev.clientY);
2931
3185
  // The board holds the NEW group, not the bare page: it is adopted under the
2932
3186
  // group's id, sized for the page plus its strip, taking the room under the
2933
3187
  // pointer with its top edge at the pointer — the tab is what the pointer
2934
- // holds, the page hangs below it.
2935
- const leg = adopt({ id: plan.arrivingId }, first, plan.size, { fit: 'shrink', anchor: 'top' });
2936
- if (!leg)
2937
- return false;
3188
+ // holds, the page hangs below it. NOT at the press: a ghost in the engine
3189
+ // costs rows, and on a fit board every tile squeezes the moment it enters,
3190
+ // so the ghost enters only when the pointer is over free board space, and
3191
+ // leaves whenever it is over a strip, a group or an edge. A full board that
3192
+ // refuses the ghost still lets the page join, reorder or split.
3193
+ let leg = null;
3194
+ const ensureLeg = (world) => {
3195
+ if (!leg)
3196
+ leg = adopt({ id: plan.arrivingId }, world, plan.size, { fit: 'shrink', anchor: 'top' });
3197
+ return leg;
3198
+ };
3199
+ const naturalSpan = (() => {
3200
+ const sp = sizeToSpan(plan.size.width, plan.size.height, frame(), geom(), rows());
3201
+ return { w: Math.max(1, Math.min(columns, sp.w)), h: Math.max(TEAR_OUT_MIN_ROWS, sp.h) };
3202
+ })();
3203
+ // A DOCKED group takes at most HALF the board — VS Code's edge drop splits
3204
+ // the area in two. A page as tall as the container it left would
3205
+ // otherwise shove the whole dashboard out of view. Half of the board as
3206
+ // it stood at the press, and of what the canvas shows, whichever is less.
3207
+ const dockSpan = (() => {
3208
+ const rows0 = rows();
3209
+ const rect = api.container.getBoundingClientRect();
3210
+ const rh = rowHeightFor(geom(), rows0) + gap;
3211
+ const visible = rect.height > 0 && rh > 0 ? Math.max(1, Math.floor((rect.height + gap) / rh)) : rows0;
3212
+ const halfRows = Math.max(TEAR_OUT_MIN_ROWS, Math.ceil(Math.min(rows0, visible) / 2));
3213
+ const halfCols = Math.max(1, Math.ceil(columns / 2));
3214
+ return { w: Math.min(naturalSpan.w, halfCols), h: Math.min(naturalSpan.h, halfRows) };
3215
+ })();
2938
3216
  tearing = pageId;
2939
3217
  const doc = (_a = api.container.ownerDocument) !== null && _a !== void 0 ? _a : document;
2940
3218
  const chip = doc.createElement('div');
2941
3219
  chip.className = 'axdb-drag-chip axdb-tab-chip';
2942
3220
  chip.textContent = plan.label;
2943
3221
  doc.body.appendChild(chip);
2944
- // Over ANOTHER tab container the page joins it instead of taking a cell:
2945
- // the tile leaves the board (its displaced tiles come home), the target's
2946
- // frame lights up and its strip marks where the tab will go.
3222
+ const moveChip = (cx, cy) => {
3223
+ chip.style.left = `${cx + 6}px`;
3224
+ chip.style.top = `${cy + 6}px`;
3225
+ };
3226
+ moveChip(ev.clientX, ev.clientY);
3227
+ armGlide();
3228
+ api.render();
2947
3229
  const layer = htmlLayer();
3230
+ const area = (g) => {
3231
+ const sz = sizeOf(g);
3232
+ return sz.width * sz.height;
3233
+ };
2948
3234
  const targets = plan.joinTargets
2949
3235
  .map((id) => diagram.getGroup(id))
2950
- .filter((g) => !!g && g.id !== fromGroupId);
2951
- const targetAt = (wx, wy) => { var _a; return (_a = targets.find((t) => worldInsideGroup(t, wx, wy))) !== null && _a !== void 0 ? _a : null; };
2952
- let over = null;
3236
+ .filter((g) => !!g && g.id !== fromGroupId)
3237
+ .sort((a, b) => area(a) - area(b));
3238
+ // GEOMETRY MUST NOT MOVE WITH THE GHOST. On a fit board the rows squeeze
3239
+ // while the ghost occupies some and relax when it leaves, so a target's
3240
+ // frame changes with the very decision being made about it: a join makes
3241
+ // the ghost leave, the frame grows, and the same pointer now reads as a
3242
+ // split. The target and its frame are therefore anchored when the pointer
3243
+ // enters it and kept until it leaves; the board frame is the one at press.
3244
+ let anchor = null;
3245
+ const inRect = (r, wx, wy) => wx >= r.x && wx <= r.x + r.width && wy >= r.y && wy <= r.y + r.height;
3246
+ const targetAt = (wx, wy) => {
3247
+ var _a;
3248
+ if (anchor && inRect(anchor.frame, wx, wy))
3249
+ return anchor.g;
3250
+ const t = (_a = targets.find((x) => worldInsideGroup(x, wx, wy))) !== null && _a !== void 0 ? _a : null;
3251
+ if (t)
3252
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the ghost leaves first, so the frame anchored is the relaxed one
3253
+ anchor = t ? { g: t, frame: frameOfGroup(t), stripH: plan.stripHeight(t.id) } : null;
3254
+ return t;
3255
+ };
3256
+ // THE BANDS ARE ON THE SCREEN. Dockview measures its container edges on
3257
+ // the element, and so must we: the camera moves during the drag (the
3258
+ // board glides, the ghost adds rows a bounded scroll answers to), so a
3259
+ // band fixed in world space at the press drifts away from the edge the
3260
+ // user sees. The frame is mapped to client space through the live
3261
+ // camera and clipped to the canvas, so a scrolled board's bands sit at
3262
+ // its VISIBLE edges.
3263
+ const ROOT_TOP = 20;
3264
+ const ROOT_BOTTOM = 20;
3265
+ const ROOT_SIDE = 40;
3266
+ const visibleFrame = () => {
3267
+ const rect = api.container.getBoundingClientRect();
3268
+ const o = toWorld(rect.left, rect.top);
3269
+ const u = toWorld(rect.left + 100, rect.top + 100);
3270
+ const sx = 100 / (u.x - o.x || 100);
3271
+ const sy = 100 / (u.y - o.y || 100);
3272
+ const f = frame();
3273
+ const left = rect.left + (f.x - o.x) * sx;
3274
+ const top = rect.top + (f.y - o.y) * sy;
3275
+ const right = left + f.width * sx;
3276
+ const bottom = top + f.height * sy;
3277
+ if (rect.width <= 0 || rect.height <= 0)
3278
+ return { left, top, right, bottom }; // unlaid-out: nothing to clip to
3279
+ return { left: Math.max(left, rect.left), top: Math.max(top, rect.top), right: Math.min(right, rect.right), bottom: Math.min(bottom, rect.bottom) };
3280
+ };
3281
+ const rootAt = (cx, cy) => {
3282
+ // The bands reach a little OUTSIDE the frame too: a pointer that
3283
+ // overshoots the board's top edge by a few pixels means "the top".
3284
+ const v = visibleFrame();
3285
+ if (cx < v.left - ROOT_SIDE || cx > v.right + ROOT_SIDE || cy < v.top - ROOT_TOP || cy > v.bottom + ROOT_BOTTOM)
3286
+ return null;
3287
+ const rows = Math.max(TEAR_OUT_MIN_ROWS, rowsWithout(plan.arrivingId));
3288
+ const w = dockSpan.w;
3289
+ const h = dockSpan.h;
3290
+ if (cy - v.top <= ROOT_TOP)
3291
+ return { kind: 'root', side: 'top', cell: { x: 0, y: 0, w: columns, h } };
3292
+ if (cx - v.left <= ROOT_SIDE)
3293
+ return { kind: 'root', side: 'left', cell: { x: 0, y: 0, w, h: rows } };
3294
+ if (v.right - cx <= ROOT_SIDE)
3295
+ return { kind: 'root', side: 'right', cell: { x: Math.max(0, columns - w), y: 0, w, h: rows } };
3296
+ if (v.bottom - cy <= ROOT_BOTTOM)
3297
+ return { kind: 'root', side: 'bottom', cell: { x: 0, y: rows, w: columns, h } };
3298
+ return null;
3299
+ };
3300
+ const rowsWithout = (id) => {
3301
+ let r = 0;
3302
+ for (const it of engine.getItems())
3303
+ if (it.id !== id)
3304
+ r = Math.max(r, it.y + it.h);
3305
+ return r;
3306
+ };
3307
+ /** A split being previewed: the target shrunk to its half, to be restored when the pointer leaves. */
3308
+ let split = null;
3309
+ const halves = (target, side) => {
3310
+ // The cell to halve is the target's own — not the half it is already
3311
+ // shrunk to while a split is being previewed.
3312
+ const live = engine.getItem(target.id);
3313
+ const it = split && split.id === target.id ? split.before : live;
3314
+ if (!it || !live)
3315
+ return null; // a group on another board: no cell of ours to halve
3316
+ if (side === 'left' || side === 'right') {
3317
+ if (it.w < 2)
3318
+ return null;
3319
+ const a = Math.ceil(it.w / 2);
3320
+ const b = it.w - a;
3321
+ return side === 'right'
3322
+ ? { keep: { x: it.x, y: it.y, w: a, h: it.h }, born: { x: it.x + a, y: it.y, w: b, h: it.h } }
3323
+ : { keep: { x: it.x + b, y: it.y, w: a, h: it.h }, born: { x: it.x, y: it.y, w: b, h: it.h } };
3324
+ }
3325
+ if (it.h < 2 * TEAR_OUT_MIN_ROWS)
3326
+ return null;
3327
+ const a = Math.ceil(it.h / 2);
3328
+ const b = it.h - a;
3329
+ return side === 'bottom'
3330
+ ? { keep: { x: it.x, y: it.y, w: it.w, h: a }, born: { x: it.x, y: it.y + a, w: it.w, h: b } }
3331
+ : { keep: { x: it.x, y: it.y + b, w: it.w, h: a }, born: { x: it.x, y: it.y, w: it.w, h: b } };
3332
+ };
3333
+ const zoneAt = (cx, cy, world) => {
3334
+ const target = targetAt(world.x, world.y);
3335
+ const home = !target && worldInsideGroup(from, world.x, world.y);
3336
+ // A strip is the most precise target there is: anyone's wins outright.
3337
+ if (target) {
3338
+ const idx = plan.stripIndex(target.id, cx, cy);
3339
+ if (idx !== null)
3340
+ return { kind: 'strip', target, index: idx };
3341
+ }
3342
+ else if (home) {
3343
+ const idx = plan.stripIndex(fromGroupId, cx, cy);
3344
+ if (idx !== null)
3345
+ return { kind: 'reorder', index: idx };
3346
+ }
3347
+ // The board's own edges beat whatever sits against them — Dockview's
3348
+ // container edges over its groups — so a group at the top of the board
3349
+ // still leaves the top band to the board.
3350
+ const root = rootAt(cx, cy);
3351
+ if (root)
3352
+ return root;
3353
+ if (target) {
3354
+ const f = anchor && anchor.g === target ? anchor.frame : frameOfGroup(target);
3355
+ const stripH = anchor && anchor.g === target ? anchor.stripH : plan.stripHeight(target.id);
3356
+ const bodyH = Math.max(1, f.height - stripH);
3357
+ const rx = Math.min(1, Math.max(0, (world.x - f.x) / Math.max(1, f.width)));
3358
+ const ry = Math.min(1, Math.max(0, (world.y - f.y - stripH) / bodyH));
3359
+ if (rx >= 1 / 3 && rx <= 2 / 3 && ry >= 1 / 3 && ry <= 2 / 3)
3360
+ return { kind: 'join', target };
3361
+ const d = [['left', rx], ['right', 1 - rx], ['top', ry], ['bottom', 1 - ry]];
3362
+ d.sort((p, q) => p[1] - q[1]);
3363
+ const h = halves(target, d[0][0]);
3364
+ return h ? Object.assign({ kind: 'split', target, side: d[0][0] }, h) : { kind: 'join', target };
3365
+ }
3366
+ if (home)
3367
+ return { kind: 'home' };
3368
+ return { kind: 'board' };
3369
+ };
3370
+ const zoneKey = (z) => JSON.stringify(z, (k, v) => (k === 'target' ? v.id : v));
3371
+ // DOCKING PUSHES SECTIONS. A section is a locked tile so that a widget
3372
+ // never pushes it; a group docked against the board's edge is the one
3373
+ // gesture that must — everything below the top band moves down. For the
3374
+ // preview every member group is unlocked; they are relocked when the
3375
+ // pointer leaves the band, and their moved cells are committed with the
3376
+ // dock.
3377
+ let unlockedGroups = [];
3378
+ const unlockGroups = () => {
3379
+ if (unlockedGroups.length > 0)
3380
+ return;
3381
+ for (const it of engine.getItems()) {
3382
+ if (it.id !== plan.arrivingId && it.locked && isGroupMember(it.id)) {
3383
+ it.locked = false;
3384
+ unlockedGroups.push(it.id);
3385
+ }
3386
+ }
3387
+ };
3388
+ const relockGroups = () => {
3389
+ for (const id of unlockedGroups) {
3390
+ const it = engine.getItem(id);
3391
+ if (it)
3392
+ it.locked = true;
3393
+ }
3394
+ unlockedGroups = [];
3395
+ };
3396
+ const groupCommands = (fin, except) => fin.groups.filter((g) => g.id !== except).map((g) => new SetGroupCellCommand(g.id, g.cellBefore, g.cellAfter, g.frameBefore, g.frameAfter));
3397
+ // A TOP DOCK INSERTS ROWS. The engine's push cascade resolves the band's
3398
+ // collisions one tile at a time and scrambles what stood beneath it (the
3399
+ // demo's KPI row came apart, two of its tiles under the chart). VS Code
3400
+ // shoves the whole area down intact, so after the ghost takes the band
3401
+ // every tile that stood at or below it is put back at its own column,
3402
+ // exactly the band's height lower — a translation of a layout without
3403
+ // overlaps has none — and comes back the same way when the pointer leaves.
3404
+ // The layout translated is the one from BEFORE the ghost entered (the
3405
+ // leg's baseline), not the engine's mid-gesture state: the adoption
3406
+ // itself pushes tiles about, and a snapshot taken after it would carry
3407
+ // that cascade into the insert.
3408
+ let inserted = null;
3409
+ const insertRows = (cell, l) => {
3410
+ if (cell.w < columns)
3411
+ return; // a side dock has no whole rows to insert
3412
+ inserted = l.baseline();
3413
+ for (const [id, c] of inserted) {
3414
+ const it = engine.getItem(id);
3415
+ if (!it)
3416
+ continue;
3417
+ it.x = c.x;
3418
+ it.y = c.y >= cell.y ? c.y + cell.h : c.y;
3419
+ }
3420
+ };
3421
+ const undoInsertRows = () => {
3422
+ if (!inserted)
3423
+ return;
3424
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the ghost frees its rows first
3425
+ for (const [id, c] of inserted) {
3426
+ const it = engine.getItem(id);
3427
+ if (it) {
3428
+ it.x = c.x;
3429
+ it.y = c.y;
3430
+ }
3431
+ }
3432
+ inserted = null;
3433
+ project();
3434
+ };
2953
3435
  let joinEl = null;
2954
- const showJoin = (g) => {
3436
+ const showOverlay = (r) => {
2955
3437
  if (!layer)
2956
3438
  return;
2957
3439
  if (!joinEl) {
@@ -2959,37 +3441,115 @@ export function bindDashboardGrid(api, group, options = {}) {
2959
3441
  joinEl.className = 'axdb-join';
2960
3442
  layer.prepend(joinEl);
2961
3443
  }
2962
- const sz = sizeOf(g);
2963
- joinEl.style.left = `${g.position.x}px`;
2964
- joinEl.style.top = `${g.position.y}px`;
2965
- joinEl.style.width = `${sz.width}px`;
2966
- joinEl.style.height = `${sz.height}px`;
3444
+ joinEl.style.left = `${r.x}px`;
3445
+ joinEl.style.top = `${r.y}px`;
3446
+ joinEl.style.width = `${r.width}px`;
3447
+ joinEl.style.height = `${r.height}px`;
2967
3448
  };
2968
- const hideJoin = () => {
3449
+ const hideOverlay = () => {
2969
3450
  joinEl === null || joinEl === void 0 ? void 0 : joinEl.remove();
2970
3451
  joinEl = null;
2971
- plan.markDrop(null, null);
2972
3452
  };
2973
- const setOver = (g, world) => {
2974
- if (g === over)
3453
+ const undoSplitPreview = () => {
3454
+ if (!split)
2975
3455
  return;
2976
- over = g;
2977
- if (g) {
2978
- leg.leave();
2979
- showJoin(g);
2980
- }
2981
- else {
2982
- hideJoin();
2983
- leg.enter(world);
3456
+ const it = engine.getItem(split.id);
3457
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the born half must be free before the target grows back into it
3458
+ if (it) {
3459
+ if (it.x !== split.before.x || it.y !== split.before.y)
3460
+ engine.moveCheck(split.id, split.before.x, split.before.y, { gate: false });
3461
+ if (it.w !== split.before.w || it.h !== split.before.h)
3462
+ engine.resizeCheck(split.id, split.before.w, split.before.h);
3463
+ it.locked = true;
2984
3464
  }
3465
+ split = null;
3466
+ project();
2985
3467
  };
2986
- const moveChip = (cx, cy) => {
2987
- chip.style.left = `${cx + 6}px`;
2988
- chip.style.top = `${cy + 6}px`;
3468
+ const previewSplit = (z, world) => {
3469
+ const it = engine.getItem(z.target.id);
3470
+ const l = ensureLeg(world);
3471
+ if (!it || !l)
3472
+ return false;
3473
+ split = { id: z.target.id, before: { x: it.x, y: it.y, w: it.w, h: it.h }, frameBefore: frameOfGroup(z.target), keep: z.keep };
3474
+ it.locked = false; // its own gesture for the moment: it may shrink and shift
3475
+ if (it.w !== z.keep.w || it.h !== z.keep.h)
3476
+ engine.resizeCheck(z.target.id, z.keep.w, z.keep.h);
3477
+ const now = engine.getItem(z.target.id);
3478
+ if (now && (now.x !== z.keep.x || now.y !== z.keep.y))
3479
+ engine.moveCheck(z.target.id, z.keep.x, z.keep.y, { gate: false });
3480
+ const ok = l.place(z.born);
3481
+ project();
3482
+ placeholder === null || placeholder === void 0 ? void 0 : placeholder.remove(); // the accent overlay says which half; the grey placeholder under it is noise
3483
+ placeholder = null;
3484
+ return ok;
3485
+ };
3486
+ let zone = { kind: 'board' };
3487
+ let key = zoneKey(zone);
3488
+ const applyZone = (z, world) => {
3489
+ var _a;
3490
+ const k = zoneKey(z);
3491
+ const same = k === key;
3492
+ key = k;
3493
+ zone = z;
3494
+ if (same) {
3495
+ if (z.kind === 'board')
3496
+ (_a = ensureLeg(world)) === null || _a === void 0 ? void 0 : _a.move(world);
3497
+ return;
3498
+ }
3499
+ undoSplitPreview();
3500
+ undoInsertRows();
3501
+ if (z.kind !== 'root')
3502
+ relockGroups();
3503
+ plan.markDrop(null, null);
3504
+ hideOverlay();
3505
+ switch (z.kind) {
3506
+ case 'strip':
3507
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3508
+ showOverlay(frameOfGroup(z.target));
3509
+ plan.markDrop(z.target.id, z.index);
3510
+ break;
3511
+ case 'join':
3512
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3513
+ showOverlay(frameOfGroup(z.target));
3514
+ break;
3515
+ case 'split': {
3516
+ if (!previewSplit(z, world)) {
3517
+ zone = { kind: 'join', target: z.target };
3518
+ key = zoneKey(zone);
3519
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3520
+ showOverlay(frameOfGroup(z.target));
3521
+ break;
3522
+ }
3523
+ showOverlay(cellToRect(z.born, frame(), geom(), rows()));
3524
+ break;
3525
+ }
3526
+ case 'root': {
3527
+ const l = ensureLeg(world);
3528
+ if (l) {
3529
+ unlockGroups();
3530
+ l.place(z.cell);
3531
+ insertRows(z.cell, l);
3532
+ project();
3533
+ placeholder === null || placeholder === void 0 ? void 0 : placeholder.remove();
3534
+ placeholder = null;
3535
+ showOverlay(cellToRect(z.cell, frame(), geom(), rows()));
3536
+ }
3537
+ break;
3538
+ }
3539
+ case 'reorder':
3540
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3541
+ plan.markDrop(fromGroupId, z.index);
3542
+ break;
3543
+ case 'home':
3544
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3545
+ break;
3546
+ case 'board': {
3547
+ const l = ensureLeg(world);
3548
+ l === null || l === void 0 ? void 0 : l.enter(world);
3549
+ break;
3550
+ }
3551
+ }
2989
3552
  };
2990
- moveChip(ev.clientX, ev.clientY);
2991
- armGlide();
2992
- api.render();
2993
3553
  let last = { x: ev.clientX, y: ev.clientY };
2994
3554
  const detach = () => {
2995
3555
  window.removeEventListener('pointermove', onMove, true);
@@ -3003,68 +3563,98 @@ export function bindDashboardGrid(api, group, options = {}) {
3003
3563
  last = { x: e.clientX, y: e.clientY };
3004
3564
  moveChip(e.clientX, e.clientY);
3005
3565
  const world = toWorld(e.clientX, e.clientY);
3006
- const home = worldInsideGroup(from, world.x, world.y);
3007
- const target = home ? null : targetAt(world.x, world.y);
3008
- setOver(target, world);
3009
- // Over its own container it goes home: a tab dragged around its own
3010
- // strip must not tear itself out.
3011
- chip.classList.toggle('axdb-out', home || (!target && !worldInsideBoardGrace(world.x, world.y)));
3012
- if (target)
3013
- plan.markDrop(target.id, plan.dropIndex(target.id, e.clientX, e.clientY));
3014
- else if (!home)
3015
- leg.move(world);
3566
+ const z = zoneAt(e.clientX, e.clientY, world);
3567
+ applyZone(z, world);
3568
+ // Dimmed = a release here does nothing: home, off the board, or a board
3569
+ // that refused the ghost (bounded and full).
3570
+ chip.classList.toggle('axdb-out', zone.kind === 'home' || (zone.kind === 'root' && !leg) || (zone.kind === 'board' && (!leg || !worldInsideBoardGrace(world.x, world.y))));
3016
3571
  api.render();
3017
3572
  };
3573
+ const done = (changed, kind) => {
3574
+ var _a;
3575
+ disarmGlideSoon();
3576
+ enforceBoardHeight();
3577
+ persistLayouts();
3578
+ api.renderNow();
3579
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: kind, kind: 'move', nodeId: pageId, changed });
3580
+ };
3018
3581
  const finish = (commit) => {
3019
- var _a, _b, _c;
3582
+ var _a, _b;
3020
3583
  detach();
3021
3584
  chip.remove();
3022
- hideJoin();
3585
+ hideOverlay();
3586
+ plan.markDrop(null, null);
3023
3587
  tearing = null;
3024
3588
  if (disposed)
3025
3589
  return;
3026
3590
  const world = toWorld(last.x, last.y);
3027
- const home = worldInsideGroup(from, world.x, world.y);
3028
- const target = commit && !home ? targetAt(world.x, world.y) : null;
3029
- if (target) {
3591
+ const z = commit ? zoneAt(last.x, last.y, world) : { kind: 'home' };
3592
+ if (z.kind !== 'root')
3593
+ undoInsertRows();
3594
+ if (!commit || z.kind === 'home' || (z.kind === 'root' && !ensureLeg(world)) || (z.kind === 'board' && (!ensureLeg(world) || !worldInsideBoardGrace(world.x, world.y)))) {
3595
+ undoSplitPreview();
3596
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3597
+ relockGroups();
3598
+ done(false, 'cancel');
3599
+ return;
3600
+ }
3601
+ if (z.kind === 'reorder') {
3602
+ undoSplitPreview();
3603
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3604
+ const cmds = plan.reorder(z.index);
3605
+ const changed = cmds.length > 0 ? execute('Reorder tab', cmds) : false;
3606
+ done(changed, changed ? 'commit' : 'cancel');
3607
+ return;
3608
+ }
3609
+ if (z.kind === 'strip' || z.kind === 'join') {
3030
3610
  // JOIN: no cell on this board — the leg is abandoned (its displaced
3031
3611
  // tiles are already home) and the page becomes the target's tab.
3032
- const index = plan.dropIndex(target.id, last.x, last.y);
3033
- leg.abort();
3034
- const planned = plan.join(target.id, index, group.id);
3035
- const collapse = planned.collapse.length > 0 ? [...planRemovalOf(fromGroupId), ...planned.collapse] : [];
3036
- const changed = execute('Move tab', [...planned.move, ...collapse]);
3037
- disarmGlideSoon();
3038
- enforceBoardHeight();
3039
- persistLayouts();
3040
- api.renderNow();
3041
- (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: 'move', nodeId: pageId, changed });
3612
+ undoSplitPreview();
3613
+ const index = z.kind === 'strip' ? z.index : plan.dropIndex(z.target.id, last.x, last.y);
3614
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3615
+ const planned = plan.join(z.target.id, index, group.id);
3616
+ done(execute('Move tab', [...planned.move, ...planned.collapse]), 'commit');
3042
3617
  return;
3043
3618
  }
3044
- if (!commit || home || !worldInsideBoardGrace(world.x, world.y)) {
3045
- leg.abort();
3046
- disarmGlideSoon();
3047
- api.renderNow();
3048
- (_b = options.onGesture) === null || _b === void 0 ? void 0 : _b.call(options, { type: 'cancel', kind: 'move', nodeId: pageId, changed: false });
3619
+ if (z.kind === 'split') {
3620
+ // SPLIT: the target keeps one half of its cell, the page's new group the
3621
+ // other. The preview already holds both in the engine; commit them.
3622
+ if (!split || split.id !== z.target.id || zoneKey(zone) !== zoneKey(z))
3623
+ applyZone(z, world);
3624
+ const fin = (_a = leg === null || leg === void 0 ? void 0 : leg.finalize()) !== null && _a !== void 0 ? _a : null;
3625
+ const it = engine.getItem(z.target.id);
3626
+ const before = split;
3627
+ if (it)
3628
+ it.locked = true;
3629
+ split = null;
3630
+ if (!fin || !before || !it) {
3631
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3632
+ done(false, 'cancel');
3633
+ return;
3634
+ }
3635
+ const keep = { x: it.x, y: it.y, w: it.w, h: it.h };
3636
+ const planned = plan.commands(fin.cell, fin.rect, group.id);
3637
+ const changed = execute('Split group', [
3638
+ ...fin.commands,
3639
+ ...groupCommands(fin, z.target.id),
3640
+ new SetGroupCellCommand(z.target.id, before.before, keep, before.frameBefore, cellToRect(keep, frame(), geom(), rows())),
3641
+ ...planned.move,
3642
+ ...planned.collapse,
3643
+ ]);
3644
+ done(changed, 'commit');
3049
3645
  return;
3050
3646
  }
3051
- const fin = leg.finalize();
3647
+ // ROOT DOCK or a free cell on the board: the leg sits where it will land.
3648
+ if (zoneKey(zone) !== zoneKey(z))
3649
+ applyZone(z, world);
3650
+ const fin = (_b = leg === null || leg === void 0 ? void 0 : leg.finalize()) !== null && _b !== void 0 ? _b : null;
3651
+ relockGroups();
3052
3652
  if (!fin) {
3053
- disarmGlideSoon();
3054
- api.renderNow();
3653
+ done(false, 'cancel');
3055
3654
  return;
3056
3655
  }
3057
3656
  const planned = plan.commands(fin.cell, fin.rect, group.id);
3058
- // The container the page left may have been holding its last page: it
3059
- // goes too, and the tiles around its slab settle the way a removal
3060
- // settles them (VS Code closes a group whose last editor leaves).
3061
- const collapse = planned.collapse.length > 0 ? [...planRemovalOf(fromGroupId), ...planned.collapse] : [];
3062
- const changed = execute('Move tab out', [...fin.commands, ...planned.move, ...collapse]);
3063
- disarmGlideSoon();
3064
- enforceBoardHeight();
3065
- persistLayouts();
3066
- api.renderNow();
3067
- (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'commit', kind: 'move', nodeId: pageId, changed });
3657
+ done(execute(z.kind === 'root' ? 'Dock tab' : 'Move tab out', [...fin.commands, ...groupCommands(fin), ...planned.move, ...planned.collapse]), 'commit');
3068
3658
  };
3069
3659
  const onUp = () => finish(true);
3070
3660
  const onCancel = () => finish(false);
@@ -3107,6 +3697,7 @@ export function bindDashboardGrid(api, group, options = {}) {
3107
3697
  spans: { w: Math.max(1, spec.w), h: Math.max(1, spec.h) },
3108
3698
  removedFromBoard: true,
3109
3699
  leg: null,
3700
+ strip: null,
3110
3701
  lastWorld: null,
3111
3702
  lastScreen: null,
3112
3703
  hostEl: null,
@@ -3220,7 +3811,15 @@ export function bindDashboardGrid(api, group, options = {}) {
3220
3811
  return false;
3221
3812
  engine.beginGesture();
3222
3813
  const snap = snapshotAll();
3223
- if (!op()) {
3814
+ // A section is a LOCKED tile; for ITS OWN move it is the one moving.
3815
+ const self = engine.getItem(id);
3816
+ const wasLocked = !!(self === null || self === void 0 ? void 0 : self.locked);
3817
+ if (self && isGroupMember(id))
3818
+ self.locked = false;
3819
+ const ok = op();
3820
+ if (self && isGroupMember(id))
3821
+ self.locked = wasLocked;
3822
+ if (!ok) {
3224
3823
  engine.endGesture();
3225
3824
  return false;
3226
3825
  }