@grafloria/element 0.4.32 → 0.4.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafloria/element",
3
- "version": "0.4.32",
3
+ "version": "0.4.34",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -1099,6 +1099,7 @@ export function createDashboardHandle(ctx) {
1099
1099
  return { move, collapse: remaining.length > 0 ? [] : collapseOf(boardId) };
1100
1100
  },
1101
1101
  joinTargets: [...ctx.layoutOf.keys()].filter((id) => { var _a, _b; return id !== containerId && id !== pageId && ((_a = ctx.layoutOf.get(id)) !== null && _a !== void 0 ? _a : (_b = specById.get(id)) === null || _b === void 0 ? void 0 : _b.layout) === 'tabs' && !!model.getGroup(id) && !insidePage(id); }),
1102
+ stripHeight: (targetId) => tabStripReserve(ctx.tabsOf.get(targetId), Math.max(1, liveCount(targetId))),
1102
1103
  stripIndex: (targetId, cx, cy) => {
1103
1104
  const strip = ctx.tabStrips.get(targetId);
1104
1105
  if (!strip)
@@ -1322,7 +1323,7 @@ export function createDashboardHandle(ctx) {
1322
1323
  },
1323
1324
  focusWidget(id) {
1324
1325
  var _a;
1325
- const b = binders.get((_a = viewOfWidget.get(id)) !== null && _a !== void 0 ? _a : '');
1326
+ const b = binders.get((_a = boardOfWidget(id)) !== null && _a !== void 0 ? _a : '');
1326
1327
  if (!b)
1327
1328
  return false;
1328
1329
  if (b.focusWidget(id))
@@ -1343,7 +1344,7 @@ export function createDashboardHandle(ctx) {
1343
1344
  b.selectWidget(undefined);
1344
1345
  return true;
1345
1346
  }
1346
- const b = binders.get((_a = viewOfWidget.get(id)) !== null && _a !== void 0 ? _a : '');
1347
+ const b = binders.get((_a = boardOfWidget(id)) !== null && _a !== void 0 ? _a : '');
1347
1348
  return !!b && b.selectWidget(id);
1348
1349
  },
1349
1350
  getSelectedWidget() {
@@ -1627,16 +1628,39 @@ export function createDashboardHandle(ctx) {
1627
1628
  ctx.hosts.clear();
1628
1629
  },
1629
1630
  };
1631
+ /**
1632
+ * The board that HOLDS a widget NOW — live membership, the way toJSON reads
1633
+ * it. A cross-board drag moves membership through the binder's own
1634
+ * commands, which the mount-time map never sees; a handle that kept asking
1635
+ * the widget's FIRST board answered `cell: null` for a tile that had just
1636
+ * been dropped into a page. The map is the fallback for a widget that is
1637
+ * not a member yet (an add still committing).
1638
+ */
1639
+ const boardOfWidget = (id) => {
1640
+ var _a, _b, _c, _d;
1641
+ for (const [cid, g] of ctx.boardGroups)
1642
+ if ((_a = g.members) === null || _a === void 0 ? void 0 : _a.has(id))
1643
+ return cid;
1644
+ const model = (_b = ctx.apiRef) === null || _b === void 0 ? void 0 : _b.getModel();
1645
+ if (model)
1646
+ for (const vid of binders.keys())
1647
+ if ((_d = (_c = model.getGroup(vid)) === null || _c === void 0 ? void 0 : _c.members) === null || _d === void 0 ? void 0 : _d.has(id))
1648
+ return vid;
1649
+ return viewOfWidget.get(id);
1650
+ };
1630
1651
  function makeWidgetHandle(id) {
1631
1652
  const spec = specById.get(id);
1632
- const viewId = viewOfWidget.get(id);
1633
- if (!spec || !viewId)
1653
+ if (!spec || !boardOfWidget(id))
1634
1654
  return undefined;
1635
- const binder = () => binders.get(viewId);
1655
+ const board = () => { var _a; return (_a = boardOfWidget(id)) !== null && _a !== void 0 ? _a : ''; };
1656
+ const binder = () => binders.get(board());
1636
1657
  const node = () => { var _a; return (_a = ctx.apiRef) === null || _a === void 0 ? void 0 : _a.getModel().getNode(id); };
1637
1658
  return {
1638
1659
  id,
1639
- viewId,
1660
+ get viewId() {
1661
+ var _a;
1662
+ return (_a = boardOfWidget(id)) !== null && _a !== void 0 ? _a : '';
1663
+ },
1640
1664
  get node() {
1641
1665
  return node();
1642
1666
  },
@@ -1715,8 +1739,8 @@ export function createDashboardHandle(ctx) {
1715
1739
  // the parent board and the parent's re-pack, as ONE undoable batch.
1716
1740
  // Undo restores the groups as fresh GroupModels, so the history
1717
1741
  // handler re-binds the container's grid (ctx.rebindContainer).
1718
- const parentGroup = ctx.boardGroups.get(viewId);
1719
- const parentBinder = binders.get(viewId);
1742
+ const parentGroup = ctx.boardGroups.get(board());
1743
+ const parentBinder = binders.get(board());
1720
1744
  const model = (_a = ctx.apiRef) === null || _a === void 0 ? void 0 : _a.getModel();
1721
1745
  if (!parentGroup || !parentBinder || !model)
1722
1746
  return;
@@ -1750,7 +1774,7 @@ export function createDashboardHandle(ctx) {
1750
1774
  }
1751
1775
  };
1752
1776
  removeSubtree(id);
1753
- const registry = registryOf(id, viewId, spec);
1777
+ const registry = registryOf(id, board(), spec);
1754
1778
  cmds.push(...nodeRemovals, ...unregisters, new RegisterWidgetCommand(registry, 'unregister'));
1755
1779
  (_c = binders.get(id)) === null || _c === void 0 ? void 0 : _c.dispose();
1756
1780
  binders.delete(id);
@@ -1761,7 +1785,7 @@ export function createDashboardHandle(ctx) {
1761
1785
  return;
1762
1786
  }
1763
1787
  const n = node();
1764
- const group = ctx.boardGroups.get(viewId);
1788
+ const group = ctx.boardGroups.get(board());
1765
1789
  const b = binder();
1766
1790
  if (!n || !group || !b)
1767
1791
  return;
@@ -1774,11 +1798,11 @@ export function createDashboardHandle(ctx) {
1774
1798
  // The un-registration is the LAST command so that undo — which runs
1775
1799
  // the batch in reverse — re-registers the spec BEFORE the node and its
1776
1800
  // membership come back and the painter is asked to paint it (D2).
1777
- const registry = registryOf(id, viewId, spec);
1801
+ const registry = registryOf(id, board(), spec);
1778
1802
  // …and the page it emptied, if it was one, closes with it — in which
1779
1803
  // case everything rides INSIDE one sequence, or the batch could never
1780
1804
  // undo (a membership command cannot undo once its group is gone).
1781
- const closing = (_g = (_f = ctx.closePageIfEmptied) === null || _f === void 0 ? void 0 : _f.call(ctx, viewId, id)) !== null && _g !== void 0 ? _g : [];
1805
+ const closing = (_g = (_f = ctx.closePageIfEmptied) === null || _f === void 0 ? void 0 : _f.call(ctx, board(), id)) !== null && _g !== void 0 ? _g : [];
1782
1806
  const cmds = [
1783
1807
  ...survivors,
1784
1808
  new RemoveFromGroupCommand(group.id, id),
@@ -567,6 +567,8 @@ export interface TearOutPlan {
567
567
  markDrop(targetId: string | null, index: number | null): void;
568
568
  /** The tab index a CLIENT point means along `targetId`'s strip — null when the point is not over that strip. */
569
569
  stripIndex(targetId: string, clientX: number, clientY: number): number | null;
570
+ /** The strip's height on `targetId`, px — the band above its body. */
571
+ stripHeight(targetId: string): number;
570
572
  /** The commands that REORDER the page to `index` along its own strip. Empty when nothing changes. */
571
573
  reorder(index: number): Command[];
572
574
  /**
@@ -650,17 +652,34 @@ interface AdoptedLeg {
650
652
  x: number;
651
653
  y: number;
652
654
  }): void;
655
+ /** Put the tile at a PRESCRIBED cell (a split's half, a docking band): sized to it, unlocked tiles pushed. Answers whether it sits there. */
656
+ place(cell: {
657
+ x: number;
658
+ y: number;
659
+ w: number;
660
+ h: number;
661
+ }): boolean;
662
+ /** Every other tile's cell before the ghost entered — the layout a row insert translates. */
663
+ baseline(): Map<string, CellRect>;
653
664
  /** Undo the adoption: target board back to its pre-entry layout. */
654
665
  abort(): void;
655
666
  /**
656
667
  * Close the leg for commit: returns the target-side displaced commands, the
657
- * tile's final cell and its projected rect. Null when the tile is somehow
658
- * gone (treat as abort).
668
+ * tile's final cell and its projected rect and the member GROUPS the
669
+ * adoption moved (the node commands skip groups; a dock that pushed sections
670
+ * commits them through these). Null when the tile is somehow gone.
659
671
  */
660
672
  finalize(): {
661
673
  commands: Command[];
662
674
  cell: CellRect;
663
675
  rect: WorldRect;
676
+ groups: Array<{
677
+ id: string;
678
+ cellBefore: CellRect;
679
+ cellAfter: CellRect;
680
+ frameBefore: WorldRect;
681
+ frameAfter: WorldRect;
682
+ }>;
664
683
  } | null;
665
684
  }
666
685
  /**
@@ -394,7 +394,38 @@ export function bindDashboardGrid(api, group, options = {}) {
394
394
  return e;
395
395
  };
396
396
  /** The effective row bound a gesture must respect: the strip's, else the fit capacity. */
397
- const bound = () => maxRows !== null && maxRows !== void 0 ? maxRows : capacity;
397
+ const bound = () => { var _a; return (_a = squeezeRoom !== null && squeezeRoom !== void 0 ? squeezeRoom : maxRows) !== null && _a !== void 0 ? _a : capacity; };
398
+ /** Move the LIVE bound (the engine's too), the way escalation does. */
399
+ const setLiveBound = (n) => {
400
+ maxRows = n;
401
+ engine.maxRows = n;
402
+ };
403
+ /**
404
+ * Rows a SQUEEZE may use while a tile arrives by hand — the engine's bound
405
+ * only. The binder's `maxRows` also sets the GEOMETRY (`rows()`), and
406
+ * raising it to the room made every tile shrink to a 29-row grid the
407
+ * moment the ghost entered; the rows must shrink only as far as the
408
+ * content actually reaches.
409
+ */
410
+ let squeezeRoom;
411
+ const setSqueeze = (n) => {
412
+ squeezeRoom = n;
413
+ engine.maxRows = n !== null && n !== void 0 ? n : maxRows;
414
+ };
415
+ /**
416
+ * The rows a NESTED board's live frame holds at the row floor. A page's
417
+ * height is its container's and a section's is its slab's, so a full one
418
+ * cannot ask for room the way the main fit board does (`fitCapacity` reads
419
+ * the design height, which a nested board hands to its parent as 0).
420
+ */
421
+ const elasticRows = () => {
422
+ if (designRows === undefined)
423
+ return undefined;
424
+ const fh = frame().height;
425
+ if (fh <= 0)
426
+ return undefined;
427
+ return Math.max(1, Math.floor((fh - 2 * padding + gap) / (minRowHeight + gap)));
428
+ };
398
429
  /**
399
430
  * The rows the design height can hold at the row floor — what 'bounded' fit
400
431
  * enforces. A board is never bounded BELOW what it already holds: a document
@@ -2404,6 +2435,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2404
2435
  const adopt = (
2405
2436
  // Only the id is used: a tab page arriving here is a GROUP, not a node.
2406
2437
  node, world, pxSize, opts = {}) => {
2438
+ var _a;
2407
2439
  if (disposed)
2408
2440
  return null;
2409
2441
  const f = frame();
@@ -2416,8 +2448,29 @@ export function bindDashboardGrid(api, group, options = {}) {
2416
2448
  if (b !== undefined)
2417
2449
  span.h = Math.max(1, Math.min(b, span.h));
2418
2450
  engine.beginGesture(); // pre-entry snapshot — abort() restores it
2419
- const entered = engine.add({ id: node.id, x: 0, y: engine.rows(), w: span.w, h: span.h });
2451
+ // A FULL nested board SQUEEZES for a tile arriving by hand, the way a fit
2452
+ // board squeezes for its own. Its bound is the design the escalation path
2453
+ // grows through the parent, and a page has no parent tile to grow (its
2454
+ // height is its container's): so the rows shrink toward the floor instead
2455
+ // of refusing the drop with no sign of why — the fluid demo's Filters page
2456
+ // took a KPI dragged onto it and showed nothing at all. abort() puts the
2457
+ // bound back; a commit keeps what the board then holds.
2458
+ const squeezeBefore = squeezeRoom;
2459
+ let entered = engine.add({ id: node.id, x: 0, y: engine.rows(), w: span.w, h: span.h });
2460
+ // Only a board with NO parent tile to grow through squeezes — a page. A
2461
+ // full SECTION keeps refusing (grid-options s05: refused, then joined once
2462
+ // a strip was removed); growing its slab for a dropped tile is the
2463
+ // escalation path's, not a squeeze's, and is not built yet.
2464
+ if (!entered && !parentPeer()) {
2465
+ const room = elasticRows();
2466
+ if (room !== undefined && room > ((_a = bound()) !== null && _a !== void 0 ? _a : 0)) {
2467
+ setSqueeze(room);
2468
+ span.h = Math.max(1, Math.min(room, span.h));
2469
+ entered = engine.add({ id: node.id, x: 0, y: engine.rows(), w: span.w, h: span.h });
2470
+ }
2471
+ }
2420
2472
  if (!entered) {
2473
+ setSqueeze(squeezeBefore);
2421
2474
  engine.endGesture();
2422
2475
  return null; // a bounded, full board refuses the adoption
2423
2476
  }
@@ -2453,6 +2506,34 @@ export function bindDashboardGrid(api, group, options = {}) {
2453
2506
  syncPlaceholder();
2454
2507
  return {
2455
2508
  groupId: group.id,
2509
+ baseline: () => startCells,
2510
+ place: (cell) => {
2511
+ if (!engine.getItem(node.id) && !engine.add({ id: node.id, x: 0, y: engine.rows(), w: cell.w, h: cell.h }))
2512
+ return false;
2513
+ const it = engine.getItem(node.id);
2514
+ if (!it)
2515
+ return false;
2516
+ // Shrink, move, then grow. A resize is clamped at the board's edge
2517
+ // where the ghost SITS, so a cell wider than fits there (a full-width
2518
+ // dock from a ghost parked mid-board) is reached by moving first —
2519
+ // and a cell narrower than the ghost by shrinking first, so the move
2520
+ // itself fits.
2521
+ const w0 = Math.min(it.w, cell.w);
2522
+ const h0 = Math.min(it.h, cell.h);
2523
+ if (w0 !== it.w || h0 !== it.h)
2524
+ engine.resizeCheck(node.id, w0, h0);
2525
+ const moved = engine.getItem(node.id);
2526
+ if (moved && (moved.x !== cell.x || moved.y !== cell.y))
2527
+ engine.moveCheck(node.id, cell.x, cell.y, { gate: false });
2528
+ const now = engine.getItem(node.id);
2529
+ if (now && (now.w !== cell.w || now.h !== cell.h))
2530
+ engine.resizeCheck(node.id, cell.w, cell.h);
2531
+ lastWant = null;
2532
+ project();
2533
+ syncPlaceholder();
2534
+ const at = engine.getItem(node.id);
2535
+ return !!at && at.x === cell.x && at.y === cell.y && at.w === cell.w && at.h === cell.h;
2536
+ },
2456
2537
  move: (w) => {
2457
2538
  const item = engine.getItem(node.id);
2458
2539
  if (!item)
@@ -2490,6 +2571,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2490
2571
  if (engine.getItem(node.id))
2491
2572
  engine.remove(node.id);
2492
2573
  engine.cancelGesture(); // pre-entry layout, memory cleared
2574
+ setSqueeze(squeezeBefore);
2493
2575
  adoptedGhostId = null;
2494
2576
  disarmGlideSoon();
2495
2577
  project();
@@ -2506,11 +2588,34 @@ export function bindDashboardGrid(api, group, options = {}) {
2506
2588
  const cell = { x: item.x, y: item.y, w: item.w, h: item.h };
2507
2589
  const rect = cellToRect(item, frame(), geom(), rows());
2508
2590
  const commands = buildCommitCommands(deltasSince(startCells, startGeom, node.id));
2591
+ const groups = [];
2592
+ for (const [id, before] of startCells) {
2593
+ if (!isGroupMember(id))
2594
+ continue;
2595
+ const it = engine.getItem(id);
2596
+ const g0 = startGeom.get(id);
2597
+ if (!it || !g0)
2598
+ continue;
2599
+ if (it.x === before.x && it.y === before.y && it.w === before.w && it.h === before.h)
2600
+ continue;
2601
+ groups.push({
2602
+ id,
2603
+ cellBefore: before,
2604
+ cellAfter: { x: it.x, y: it.y, w: it.w, h: it.h },
2605
+ frameBefore: { x: g0.pos.x, y: g0.pos.y, width: g0.size.width, height: g0.size.height },
2606
+ frameAfter: cellToRect(it, frame(), geom(), rows()),
2607
+ });
2608
+ }
2509
2609
  engine.endGesture();
2610
+ if (squeezeRoom !== undefined) {
2611
+ // What the board now HOLDS is its bound, not the squeeze's room.
2612
+ squeezeRoom = undefined;
2613
+ setLiveBound(liveBound(engine.getItems()));
2614
+ }
2510
2615
  adoptedGhostId = null;
2511
2616
  disarmGlideSoon();
2512
2617
  syncPlaceholder();
2513
- return { commands, cell, rect };
2618
+ return { commands, cell, rect, groups };
2514
2619
  },
2515
2620
  };
2516
2621
  };
@@ -3136,69 +3241,374 @@ export function bindDashboardGrid(api, group, options = {}) {
3136
3241
  const rect = api.container.getBoundingClientRect();
3137
3242
  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 };
3138
3243
  };
3139
- const first = toWorld(ev.clientX, ev.clientY);
3140
3244
  // The board holds the NEW group, not the bare page: it is adopted under the
3141
3245
  // group's id, sized for the page plus its strip, taking the room under the
3142
3246
  // pointer with its top edge at the pointer — the tab is what the pointer
3143
- // holds, the page hangs below it.
3144
- const leg = adopt({ id: plan.arrivingId }, first, plan.size, { fit: 'shrink', anchor: 'top' });
3145
- if (!leg)
3146
- return false;
3247
+ // holds, the page hangs below it. NOT at the press: a ghost in the engine
3248
+ // costs rows, and on a fit board every tile squeezes the moment it enters,
3249
+ // so the ghost enters only when the pointer is over free board space, and
3250
+ // leaves whenever it is over a strip, a group or an edge. A full board that
3251
+ // refuses the ghost still lets the page join, reorder or split.
3252
+ let leg = null;
3253
+ const ensureLeg = (world) => {
3254
+ if (!leg)
3255
+ leg = adopt({ id: plan.arrivingId }, world, plan.size, { fit: 'shrink', anchor: 'top' });
3256
+ return leg;
3257
+ };
3258
+ const naturalSpan = (() => {
3259
+ const sp = sizeToSpan(plan.size.width, plan.size.height, frame(), geom(), rows());
3260
+ return { w: Math.max(1, Math.min(columns, sp.w)), h: Math.max(TEAR_OUT_MIN_ROWS, sp.h) };
3261
+ })();
3262
+ // A DOCKED group takes at most HALF the board — VS Code's edge drop splits
3263
+ // the area in two. A page as tall as the container it left would
3264
+ // otherwise shove the whole dashboard out of view. Half of the board as
3265
+ // it stood at the press, and of what the canvas shows, whichever is less.
3266
+ const dockSpan = (() => {
3267
+ const rows0 = rows();
3268
+ const rect = api.container.getBoundingClientRect();
3269
+ const rh = rowHeightFor(geom(), rows0) + gap;
3270
+ const visible = rect.height > 0 && rh > 0 ? Math.max(1, Math.floor((rect.height + gap) / rh)) : rows0;
3271
+ const halfRows = Math.max(TEAR_OUT_MIN_ROWS, Math.ceil(Math.min(rows0, visible) / 2));
3272
+ const halfCols = Math.max(1, Math.ceil(columns / 2));
3273
+ return { w: Math.min(naturalSpan.w, halfCols), h: Math.min(naturalSpan.h, halfRows) };
3274
+ })();
3147
3275
  tearing = pageId;
3148
3276
  const doc = (_a = api.container.ownerDocument) !== null && _a !== void 0 ? _a : document;
3149
3277
  const chip = doc.createElement('div');
3150
3278
  chip.className = 'axdb-drag-chip axdb-tab-chip';
3151
3279
  chip.textContent = plan.label;
3152
3280
  doc.body.appendChild(chip);
3153
- // Over ANOTHER tab container the page joins it instead of taking a cell:
3154
- // the tile leaves the board (its displaced tiles come home), the target's
3155
- // frame lights up and its strip marks where the tab will go.
3281
+ const moveChip = (cx, cy) => {
3282
+ chip.style.left = `${cx + 6}px`;
3283
+ chip.style.top = `${cy + 6}px`;
3284
+ };
3285
+ moveChip(ev.clientX, ev.clientY);
3286
+ armGlide();
3287
+ api.render();
3156
3288
  const layer = htmlLayer();
3289
+ const area = (g) => {
3290
+ const sz = sizeOf(g);
3291
+ return sz.width * sz.height;
3292
+ };
3157
3293
  const targets = plan.joinTargets
3158
3294
  .map((id) => diagram.getGroup(id))
3159
- .filter((g) => !!g && g.id !== fromGroupId);
3160
- const targetAt = (wx, wy) => { var _a; return (_a = targets.find((t) => worldInsideGroup(t, wx, wy))) !== null && _a !== void 0 ? _a : null; };
3161
- let over = null;
3295
+ .filter((g) => !!g && g.id !== fromGroupId)
3296
+ .sort((a, b) => area(a) - area(b));
3297
+ // GEOMETRY MUST NOT MOVE WITH THE GHOST. On a fit board the rows squeeze
3298
+ // while the ghost occupies some and relax when it leaves, so a target's
3299
+ // frame changes with the very decision being made about it: a join makes
3300
+ // the ghost leave, the frame grows, and the same pointer now reads as a
3301
+ // split. The target and its frame are therefore anchored when the pointer
3302
+ // enters it and kept until it leaves; the board frame is the one at press.
3303
+ let anchor = null;
3304
+ const inRect = (r, wx, wy) => wx >= r.x && wx <= r.x + r.width && wy >= r.y && wy <= r.y + r.height;
3305
+ const targetAt = (wx, wy) => {
3306
+ var _a;
3307
+ if (anchor && inRect(anchor.frame, wx, wy))
3308
+ return anchor.g;
3309
+ const t = (_a = targets.find((x) => worldInsideGroup(x, wx, wy))) !== null && _a !== void 0 ? _a : null;
3310
+ if (t)
3311
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the ghost leaves first, so the frame anchored is the relaxed one
3312
+ anchor = t ? { g: t, frame: frameOfGroup(t), stripH: plan.stripHeight(t.id) } : null;
3313
+ return t;
3314
+ };
3315
+ // THE BANDS ARE ON THE SCREEN. Dockview measures its container edges on
3316
+ // the element, and so must we: the camera moves during the drag (the
3317
+ // board glides, the ghost adds rows a bounded scroll answers to), so a
3318
+ // band fixed in world space at the press drifts away from the edge the
3319
+ // user sees. The frame is mapped to client space through the live
3320
+ // camera and clipped to the canvas, so a scrolled board's bands sit at
3321
+ // its VISIBLE edges.
3322
+ const ROOT_TOP = 20;
3323
+ const ROOT_BOTTOM = 20;
3324
+ const ROOT_SIDE = 40;
3325
+ const visibleFrame = () => {
3326
+ const rect = api.container.getBoundingClientRect();
3327
+ const o = toWorld(rect.left, rect.top);
3328
+ const u = toWorld(rect.left + 100, rect.top + 100);
3329
+ const sx = 100 / (u.x - o.x || 100);
3330
+ const sy = 100 / (u.y - o.y || 100);
3331
+ const f = frame();
3332
+ const left = rect.left + (f.x - o.x) * sx;
3333
+ const top = rect.top + (f.y - o.y) * sy;
3334
+ const right = left + f.width * sx;
3335
+ const bottom = top + f.height * sy;
3336
+ if (rect.width <= 0 || rect.height <= 0)
3337
+ return { left, top, right, bottom }; // unlaid-out: nothing to clip to
3338
+ 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) };
3339
+ };
3340
+ const rootAt = (cx, cy) => {
3341
+ // The bands reach a little OUTSIDE the frame too: a pointer that
3342
+ // overshoots the board's top edge by a few pixels means "the top".
3343
+ const v = visibleFrame();
3344
+ if (cx < v.left - ROOT_SIDE || cx > v.right + ROOT_SIDE || cy < v.top - ROOT_TOP || cy > v.bottom + ROOT_BOTTOM)
3345
+ return null;
3346
+ const rows = Math.max(TEAR_OUT_MIN_ROWS, rowsWithout(plan.arrivingId));
3347
+ const w = dockSpan.w;
3348
+ const h = dockSpan.h;
3349
+ if (cy - v.top <= ROOT_TOP)
3350
+ return { kind: 'root', side: 'top', cell: { x: 0, y: 0, w: columns, h } };
3351
+ if (cx - v.left <= ROOT_SIDE)
3352
+ return { kind: 'root', side: 'left', cell: { x: 0, y: 0, w, h: rows } };
3353
+ if (v.right - cx <= ROOT_SIDE)
3354
+ return { kind: 'root', side: 'right', cell: { x: Math.max(0, columns - w), y: 0, w, h: rows } };
3355
+ if (v.bottom - cy <= ROOT_BOTTOM)
3356
+ return { kind: 'root', side: 'bottom', cell: { x: 0, y: rows, w: columns, h } };
3357
+ return null;
3358
+ };
3359
+ const rowsWithout = (id) => {
3360
+ let r = 0;
3361
+ for (const it of engine.getItems())
3362
+ if (it.id !== id)
3363
+ r = Math.max(r, it.y + it.h);
3364
+ return r;
3365
+ };
3366
+ /** A split being previewed: the target shrunk to its half, to be restored when the pointer leaves. */
3367
+ let split = null;
3368
+ const halves = (target, side) => {
3369
+ // The cell to halve is the target's own — not the half it is already
3370
+ // shrunk to while a split is being previewed.
3371
+ const live = engine.getItem(target.id);
3372
+ const it = split && split.id === target.id ? split.before : live;
3373
+ if (!it || !live)
3374
+ return null; // a group on another board: no cell of ours to halve
3375
+ if (side === 'left' || side === 'right') {
3376
+ if (it.w < 2)
3377
+ return null;
3378
+ const a = Math.ceil(it.w / 2);
3379
+ const b = it.w - a;
3380
+ return side === 'right'
3381
+ ? { 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 } }
3382
+ : { 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 } };
3383
+ }
3384
+ if (it.h < 2 * TEAR_OUT_MIN_ROWS)
3385
+ return null;
3386
+ const a = Math.ceil(it.h / 2);
3387
+ const b = it.h - a;
3388
+ return side === 'bottom'
3389
+ ? { 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 } }
3390
+ : { 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 } };
3391
+ };
3392
+ const zoneAt = (cx, cy, world) => {
3393
+ const target = targetAt(world.x, world.y);
3394
+ const home = !target && worldInsideGroup(from, world.x, world.y);
3395
+ // A strip is the most precise target there is: anyone's wins outright.
3396
+ if (target) {
3397
+ const idx = plan.stripIndex(target.id, cx, cy);
3398
+ if (idx !== null)
3399
+ return { kind: 'strip', target, index: idx };
3400
+ }
3401
+ else if (home) {
3402
+ const idx = plan.stripIndex(fromGroupId, cx, cy);
3403
+ if (idx !== null)
3404
+ return { kind: 'reorder', index: idx };
3405
+ }
3406
+ // The board's own edges beat whatever sits against them — Dockview's
3407
+ // container edges over its groups — so a group at the top of the board
3408
+ // still leaves the top band to the board.
3409
+ const root = rootAt(cx, cy);
3410
+ if (root)
3411
+ return root;
3412
+ if (target) {
3413
+ const f = anchor && anchor.g === target ? anchor.frame : frameOfGroup(target);
3414
+ const stripH = anchor && anchor.g === target ? anchor.stripH : plan.stripHeight(target.id);
3415
+ const bodyH = Math.max(1, f.height - stripH);
3416
+ const rx = Math.min(1, Math.max(0, (world.x - f.x) / Math.max(1, f.width)));
3417
+ const ry = Math.min(1, Math.max(0, (world.y - f.y - stripH) / bodyH));
3418
+ if (rx >= 1 / 3 && rx <= 2 / 3 && ry >= 1 / 3 && ry <= 2 / 3)
3419
+ return { kind: 'join', target };
3420
+ const d = [['left', rx], ['right', 1 - rx], ['top', ry], ['bottom', 1 - ry]];
3421
+ d.sort((p, q) => p[1] - q[1]);
3422
+ const h = halves(target, d[0][0]);
3423
+ return h ? Object.assign({ kind: 'split', target, side: d[0][0] }, h) : { kind: 'join', target };
3424
+ }
3425
+ if (home)
3426
+ return { kind: 'home' };
3427
+ return { kind: 'board' };
3428
+ };
3429
+ const zoneKey = (z) => JSON.stringify(z, (k, v) => (k === 'target' ? v.id : v));
3430
+ // DOCKING PUSHES SECTIONS. A section is a locked tile so that a widget
3431
+ // never pushes it; a group docked against the board's edge is the one
3432
+ // gesture that must — everything below the top band moves down. For the
3433
+ // preview every member group is unlocked; they are relocked when the
3434
+ // pointer leaves the band, and their moved cells are committed with the
3435
+ // dock.
3436
+ let unlockedGroups = [];
3437
+ const unlockGroups = () => {
3438
+ if (unlockedGroups.length > 0)
3439
+ return;
3440
+ for (const it of engine.getItems()) {
3441
+ if (it.id !== plan.arrivingId && it.locked && isGroupMember(it.id)) {
3442
+ it.locked = false;
3443
+ unlockedGroups.push(it.id);
3444
+ }
3445
+ }
3446
+ };
3447
+ const relockGroups = () => {
3448
+ for (const id of unlockedGroups) {
3449
+ const it = engine.getItem(id);
3450
+ if (it)
3451
+ it.locked = true;
3452
+ }
3453
+ unlockedGroups = [];
3454
+ };
3455
+ 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));
3456
+ // A TOP DOCK INSERTS ROWS. The engine's push cascade resolves the band's
3457
+ // collisions one tile at a time and scrambles what stood beneath it (the
3458
+ // demo's KPI row came apart, two of its tiles under the chart). VS Code
3459
+ // shoves the whole area down intact, so after the ghost takes the band
3460
+ // every tile that stood at or below it is put back at its own column,
3461
+ // exactly the band's height lower — a translation of a layout without
3462
+ // overlaps has none — and comes back the same way when the pointer leaves.
3463
+ // The layout translated is the one from BEFORE the ghost entered (the
3464
+ // leg's baseline), not the engine's mid-gesture state: the adoption
3465
+ // itself pushes tiles about, and a snapshot taken after it would carry
3466
+ // that cascade into the insert.
3467
+ let inserted = null;
3468
+ const insertRows = (cell, l) => {
3469
+ if (cell.w < columns)
3470
+ return; // a side dock has no whole rows to insert
3471
+ inserted = l.baseline();
3472
+ for (const [id, c] of inserted) {
3473
+ const it = engine.getItem(id);
3474
+ if (!it)
3475
+ continue;
3476
+ it.x = c.x;
3477
+ it.y = c.y >= cell.y ? c.y + cell.h : c.y;
3478
+ }
3479
+ };
3480
+ const undoInsertRows = () => {
3481
+ if (!inserted)
3482
+ return;
3483
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the ghost frees its rows first
3484
+ for (const [id, c] of inserted) {
3485
+ const it = engine.getItem(id);
3486
+ if (it) {
3487
+ it.x = c.x;
3488
+ it.y = c.y;
3489
+ }
3490
+ }
3491
+ inserted = null;
3492
+ project();
3493
+ };
3162
3494
  let joinEl = null;
3163
- const showJoin = (g) => {
3164
- if (!layer || g.id === fromGroupId)
3165
- return; // reordering along its own strip: the mark is enough
3495
+ const showOverlay = (r) => {
3496
+ if (!layer)
3497
+ return;
3166
3498
  if (!joinEl) {
3167
3499
  joinEl = doc.createElement('div');
3168
3500
  joinEl.className = 'axdb-join';
3169
3501
  layer.prepend(joinEl);
3170
3502
  }
3171
- const sz = sizeOf(g);
3172
- joinEl.style.left = `${g.position.x}px`;
3173
- joinEl.style.top = `${g.position.y}px`;
3174
- joinEl.style.width = `${sz.width}px`;
3175
- joinEl.style.height = `${sz.height}px`;
3503
+ joinEl.style.left = `${r.x}px`;
3504
+ joinEl.style.top = `${r.y}px`;
3505
+ joinEl.style.width = `${r.width}px`;
3506
+ joinEl.style.height = `${r.height}px`;
3176
3507
  };
3177
- const hideJoin = () => {
3508
+ const hideOverlay = () => {
3178
3509
  joinEl === null || joinEl === void 0 ? void 0 : joinEl.remove();
3179
3510
  joinEl = null;
3180
- plan.markDrop(null, null);
3181
3511
  };
3182
- const setOver = (g, world) => {
3183
- if (g === over)
3512
+ const undoSplitPreview = () => {
3513
+ if (!split)
3184
3514
  return;
3185
- over = g;
3186
- if (g) {
3187
- leg.leave();
3188
- showJoin(g);
3189
- }
3190
- else {
3191
- hideJoin();
3192
- leg.enter(world);
3515
+ const it = engine.getItem(split.id);
3516
+ leg === null || leg === void 0 ? void 0 : leg.leave(); // the born half must be free before the target grows back into it
3517
+ if (it) {
3518
+ if (it.x !== split.before.x || it.y !== split.before.y)
3519
+ engine.moveCheck(split.id, split.before.x, split.before.y, { gate: false });
3520
+ if (it.w !== split.before.w || it.h !== split.before.h)
3521
+ engine.resizeCheck(split.id, split.before.w, split.before.h);
3522
+ it.locked = true;
3193
3523
  }
3524
+ split = null;
3525
+ project();
3194
3526
  };
3195
- const moveChip = (cx, cy) => {
3196
- chip.style.left = `${cx + 6}px`;
3197
- chip.style.top = `${cy + 6}px`;
3527
+ const previewSplit = (z, world) => {
3528
+ const it = engine.getItem(z.target.id);
3529
+ const l = ensureLeg(world);
3530
+ if (!it || !l)
3531
+ return false;
3532
+ 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 };
3533
+ it.locked = false; // its own gesture for the moment: it may shrink and shift
3534
+ if (it.w !== z.keep.w || it.h !== z.keep.h)
3535
+ engine.resizeCheck(z.target.id, z.keep.w, z.keep.h);
3536
+ const now = engine.getItem(z.target.id);
3537
+ if (now && (now.x !== z.keep.x || now.y !== z.keep.y))
3538
+ engine.moveCheck(z.target.id, z.keep.x, z.keep.y, { gate: false });
3539
+ const ok = l.place(z.born);
3540
+ project();
3541
+ placeholder === null || placeholder === void 0 ? void 0 : placeholder.remove(); // the accent overlay says which half; the grey placeholder under it is noise
3542
+ placeholder = null;
3543
+ return ok;
3544
+ };
3545
+ let zone = { kind: 'board' };
3546
+ let key = zoneKey(zone);
3547
+ const applyZone = (z, world) => {
3548
+ var _a;
3549
+ const k = zoneKey(z);
3550
+ const same = k === key;
3551
+ key = k;
3552
+ zone = z;
3553
+ if (same) {
3554
+ if (z.kind === 'board')
3555
+ (_a = ensureLeg(world)) === null || _a === void 0 ? void 0 : _a.move(world);
3556
+ return;
3557
+ }
3558
+ undoSplitPreview();
3559
+ undoInsertRows();
3560
+ if (z.kind !== 'root')
3561
+ relockGroups();
3562
+ plan.markDrop(null, null);
3563
+ hideOverlay();
3564
+ switch (z.kind) {
3565
+ case 'strip':
3566
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3567
+ showOverlay(frameOfGroup(z.target));
3568
+ plan.markDrop(z.target.id, z.index);
3569
+ break;
3570
+ case 'join':
3571
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3572
+ showOverlay(frameOfGroup(z.target));
3573
+ break;
3574
+ case 'split': {
3575
+ if (!previewSplit(z, world)) {
3576
+ zone = { kind: 'join', target: z.target };
3577
+ key = zoneKey(zone);
3578
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3579
+ showOverlay(frameOfGroup(z.target));
3580
+ break;
3581
+ }
3582
+ showOverlay(cellToRect(z.born, frame(), geom(), rows()));
3583
+ break;
3584
+ }
3585
+ case 'root': {
3586
+ const l = ensureLeg(world);
3587
+ if (l) {
3588
+ unlockGroups();
3589
+ l.place(z.cell);
3590
+ insertRows(z.cell, l);
3591
+ project();
3592
+ placeholder === null || placeholder === void 0 ? void 0 : placeholder.remove();
3593
+ placeholder = null;
3594
+ showOverlay(cellToRect(z.cell, frame(), geom(), rows()));
3595
+ }
3596
+ break;
3597
+ }
3598
+ case 'reorder':
3599
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3600
+ plan.markDrop(fromGroupId, z.index);
3601
+ break;
3602
+ case 'home':
3603
+ leg === null || leg === void 0 ? void 0 : leg.leave();
3604
+ break;
3605
+ case 'board': {
3606
+ const l = ensureLeg(world);
3607
+ l === null || l === void 0 ? void 0 : l.enter(world);
3608
+ break;
3609
+ }
3610
+ }
3198
3611
  };
3199
- moveChip(ev.clientX, ev.clientY);
3200
- armGlide();
3201
- api.render();
3202
3612
  let last = { x: ev.clientX, y: ev.clientY };
3203
3613
  const detach = () => {
3204
3614
  window.removeEventListener('pointermove', onMove, true);
@@ -3206,85 +3616,104 @@ export function bindDashboardGrid(api, group, options = {}) {
3206
3616
  window.removeEventListener('pointercancel', onCancel, true);
3207
3617
  window.removeEventListener('keydown', onKey, true);
3208
3618
  };
3209
- let homeIndex = null;
3210
3619
  const onMove = (e) => {
3211
3620
  if (disposed)
3212
3621
  return detach();
3213
3622
  last = { x: e.clientX, y: e.clientY };
3214
3623
  moveChip(e.clientX, e.clientY);
3215
3624
  const world = toWorld(e.clientX, e.clientY);
3216
- const home = worldInsideGroup(from, world.x, world.y);
3217
- // Over its OWN strip the tab reorders: the strip marks the slot and the
3218
- // tile leaves the board. Over its own body it goes home (a cancel).
3219
- homeIndex = home ? plan.stripIndex(fromGroupId, e.clientX, e.clientY) : null;
3220
- const target = home ? null : targetAt(world.x, world.y);
3221
- setOver(target !== null && target !== void 0 ? target : (homeIndex !== null ? from : null), world);
3222
- chip.classList.toggle('axdb-out', (home && homeIndex === null) || (!target && !home && !worldInsideBoardGrace(world.x, world.y)));
3223
- if (target)
3224
- plan.markDrop(target.id, plan.dropIndex(target.id, e.clientX, e.clientY));
3225
- else if (homeIndex !== null)
3226
- plan.markDrop(fromGroupId, homeIndex);
3227
- else if (!home)
3228
- leg.move(world);
3625
+ const z = zoneAt(e.clientX, e.clientY, world);
3626
+ applyZone(z, world);
3627
+ // Dimmed = a release here does nothing: home, off the board, or a board
3628
+ // that refused the ghost (bounded and full).
3629
+ chip.classList.toggle('axdb-out', zone.kind === 'home' || (zone.kind === 'root' && !leg) || (zone.kind === 'board' && (!leg || !worldInsideBoardGrace(world.x, world.y))));
3229
3630
  api.render();
3230
3631
  };
3632
+ const done = (changed, kind) => {
3633
+ var _a;
3634
+ disarmGlideSoon();
3635
+ enforceBoardHeight();
3636
+ persistLayouts();
3637
+ api.renderNow();
3638
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: kind, kind: 'move', nodeId: pageId, changed });
3639
+ };
3231
3640
  const finish = (commit) => {
3232
- var _a, _b, _c, _d;
3641
+ var _a, _b;
3233
3642
  detach();
3234
3643
  chip.remove();
3235
- hideJoin();
3644
+ hideOverlay();
3645
+ plan.markDrop(null, null);
3236
3646
  tearing = null;
3237
3647
  if (disposed)
3238
3648
  return;
3239
3649
  const world = toWorld(last.x, last.y);
3240
- const home = worldInsideGroup(from, world.x, world.y);
3241
- if (commit && home && homeIndex !== null) {
3242
- // REORDER along its own strip.
3243
- leg.abort();
3244
- const cmds = plan.reorder(homeIndex);
3650
+ const z = commit ? zoneAt(last.x, last.y, world) : { kind: 'home' };
3651
+ if (z.kind !== 'root')
3652
+ undoInsertRows();
3653
+ if (!commit || z.kind === 'home' || (z.kind === 'root' && !ensureLeg(world)) || (z.kind === 'board' && (!ensureLeg(world) || !worldInsideBoardGrace(world.x, world.y)))) {
3654
+ undoSplitPreview();
3655
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3656
+ relockGroups();
3657
+ done(false, 'cancel');
3658
+ return;
3659
+ }
3660
+ if (z.kind === 'reorder') {
3661
+ undoSplitPreview();
3662
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3663
+ const cmds = plan.reorder(z.index);
3245
3664
  const changed = cmds.length > 0 ? execute('Reorder tab', cmds) : false;
3246
- disarmGlideSoon();
3247
- api.renderNow();
3248
- (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: changed ? 'commit' : 'cancel', kind: 'move', nodeId: pageId, changed });
3665
+ done(changed, changed ? 'commit' : 'cancel');
3249
3666
  return;
3250
3667
  }
3251
- const target = commit && !home ? targetAt(world.x, world.y) : null;
3252
- if (target) {
3668
+ if (z.kind === 'strip' || z.kind === 'join') {
3253
3669
  // JOIN: no cell on this board — the leg is abandoned (its displaced
3254
3670
  // tiles are already home) and the page becomes the target's tab.
3255
- const index = plan.dropIndex(target.id, last.x, last.y);
3256
- leg.abort();
3257
- const planned = plan.join(target.id, index, group.id);
3258
- const changed = execute('Move tab', [...planned.move, ...planned.collapse]);
3259
- disarmGlideSoon();
3260
- enforceBoardHeight();
3261
- persistLayouts();
3262
- api.renderNow();
3263
- (_b = options.onGesture) === null || _b === void 0 ? void 0 : _b.call(options, { type: 'commit', kind: 'move', nodeId: pageId, changed });
3671
+ undoSplitPreview();
3672
+ const index = z.kind === 'strip' ? z.index : plan.dropIndex(z.target.id, last.x, last.y);
3673
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3674
+ const planned = plan.join(z.target.id, index, group.id);
3675
+ done(execute('Move tab', [...planned.move, ...planned.collapse]), 'commit');
3264
3676
  return;
3265
3677
  }
3266
- if (!commit || home || !worldInsideBoardGrace(world.x, world.y)) {
3267
- leg.abort();
3268
- disarmGlideSoon();
3269
- api.renderNow();
3270
- (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'cancel', kind: 'move', nodeId: pageId, changed: false });
3678
+ if (z.kind === 'split') {
3679
+ // SPLIT: the target keeps one half of its cell, the page's new group the
3680
+ // other. The preview already holds both in the engine; commit them.
3681
+ if (!split || split.id !== z.target.id || zoneKey(zone) !== zoneKey(z))
3682
+ applyZone(z, world);
3683
+ const fin = (_a = leg === null || leg === void 0 ? void 0 : leg.finalize()) !== null && _a !== void 0 ? _a : null;
3684
+ const it = engine.getItem(z.target.id);
3685
+ const before = split;
3686
+ if (it)
3687
+ it.locked = true;
3688
+ split = null;
3689
+ if (!fin || !before || !it) {
3690
+ leg === null || leg === void 0 ? void 0 : leg.abort();
3691
+ done(false, 'cancel');
3692
+ return;
3693
+ }
3694
+ const keep = { x: it.x, y: it.y, w: it.w, h: it.h };
3695
+ const planned = plan.commands(fin.cell, fin.rect, group.id);
3696
+ const changed = execute('Split group', [
3697
+ ...fin.commands,
3698
+ ...groupCommands(fin, z.target.id),
3699
+ new SetGroupCellCommand(z.target.id, before.before, keep, before.frameBefore, cellToRect(keep, frame(), geom(), rows())),
3700
+ ...planned.move,
3701
+ ...planned.collapse,
3702
+ ]);
3703
+ done(changed, 'commit');
3271
3704
  return;
3272
3705
  }
3273
- const fin = leg.finalize();
3706
+ // ROOT DOCK or a free cell on the board: the leg sits where it will land.
3707
+ if (zoneKey(zone) !== zoneKey(z))
3708
+ applyZone(z, world);
3709
+ const fin = (_b = leg === null || leg === void 0 ? void 0 : leg.finalize()) !== null && _b !== void 0 ? _b : null;
3710
+ relockGroups();
3274
3711
  if (!fin) {
3275
- disarmGlideSoon();
3276
- api.renderNow();
3712
+ done(false, 'cancel');
3277
3713
  return;
3278
3714
  }
3279
3715
  const planned = plan.commands(fin.cell, fin.rect, group.id);
3280
- // The container the page left may have been holding its last page: it
3281
- // goes too (the plan carries the removal's settle).
3282
- const changed = execute('Move tab out', [...fin.commands, ...planned.move, ...planned.collapse]);
3283
- disarmGlideSoon();
3284
- enforceBoardHeight();
3285
- persistLayouts();
3286
- api.renderNow();
3287
- (_d = options.onGesture) === null || _d === void 0 ? void 0 : _d.call(options, { type: 'commit', kind: 'move', nodeId: pageId, changed });
3716
+ done(execute(z.kind === 'root' ? 'Dock tab' : 'Move tab out', [...fin.commands, ...groupCommands(fin), ...planned.move, ...planned.collapse]), 'commit');
3288
3717
  };
3289
3718
  const onUp = () => finish(true);
3290
3719
  const onCancel = () => finish(false);