@grafloria/element 0.4.29 → 0.4.30

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.29",
3
+ "version": "0.4.30",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -43,7 +43,7 @@
43
43
  * round-trips with no extra work — same as every other kit.
44
44
  */
45
45
  import { GroupModel, NodeModel, type GridColumnLayout } from '@grafloria/engine';
46
- import { type DashboardGridHandle, type DashboardGridOptions, type DashboardResponsiveOptions } from './grid-binder.js';
46
+ import { type DashboardGridHandle, type TearOutPlan, type DashboardGridOptions, type DashboardResponsiveOptions } from './grid-binder.js';
47
47
  import type { DragHandleOption } from './grid-binder.js';
48
48
  import type { SplitNode } from './split-layout.js';
49
49
  import type { SectionCaption } from './caption.js';
@@ -587,6 +587,16 @@ export interface DashboardHandleContext {
587
587
  tabStrips: Map<string, HTMLElement>;
588
588
  /** Set by finalize: re-lay a tab container's pages and repaint its strip. */
589
589
  syncTabs?: (containerId: string) => void;
590
+ /**
591
+ * Tab-container runtime hooks (attachTabsRuntime): a container created or
592
+ * removed AFTER boot — a page torn out into a group of its own, a container
593
+ * closed because its last page left — registers and unregisters through
594
+ * these, on execute and on undo alike.
595
+ */
596
+ attachTabsContainer?: (containerId: string) => void;
597
+ detachTabsContainer?: (containerId: string) => void;
598
+ /** The plan that makes a torn-out page a one-tab group of its own (the handle builder owns the specs and the registry). */
599
+ tearOutPlan?: (containerId: string, pageId: string) => TearOutPlan | null;
590
600
  /** Set by finalize: the user's `onTabChange`, so the handle can fire it. */
591
601
  onTabChange?: (containerId: string, pageId: string, viewId: string) => void;
592
602
  /** Set by finalize: re-bind a VIEW's board under the given layout (setLayout). */
@@ -43,7 +43,7 @@
43
43
  * round-trips with no extra work — same as every other kit.
44
44
  */
45
45
  import { __awaiter, __rest } from "tslib";
46
- import { BatchCommand, BringNodeToFrontCommand, Command, GroupModel, NodeModel, RemoveFromGroupCommand, RemoveGroupCommand, RemoveNodeCommand, SendNodeToBackCommand, } from '@grafloria/engine';
46
+ import { BatchCommand, BringNodeToFrontCommand, Command, GroupModel, NodeModel, AddGroupCommand, AddToGroupCommand, RemoveFromGroupCommand, RemoveGroupCommand, RemoveNodeCommand, SendNodeToBackCommand, } from '@grafloria/engine';
47
47
  import { bindDashboardGrid, parentPeerOf, } from './grid-binder.js';
48
48
  import { bindDashboardSplit, SPLIT_TREE_KEY } from './split-binder.js';
49
49
  import { gridItemFromCell } from './grid-mapping.js';
@@ -341,7 +341,7 @@ function assignCells(widgets, columns) {
341
341
  */
342
342
  const cssEscape = (v) => typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(v) : v.replace(/"/g, '\\"');
343
343
  export function attachTabsRuntime(ctx, model, container, handle) {
344
- var _a, _b, _c;
344
+ var _a;
345
345
  // Pages come from LIVE membership, in the authored order, with anything that
346
346
  // arrived later on the end. Reading the authored spec instead meant a page
347
347
  // that LEFT the container kept its tab — a tab pointing at nothing, which is
@@ -371,14 +371,16 @@ export function attachTabsRuntime(ctx, model, container, handle) {
371
371
  * with no pages has nothing to show.
372
372
  */
373
373
  const tearOut = (containerId, pageId, ev) => {
374
- var _a, _b, _c;
374
+ var _a;
375
375
  if (!container)
376
376
  return false;
377
- const pages = pagesOf(containerId);
378
- if (pages.length <= 1)
377
+ if (!pagesOf(containerId).some((p) => p.id === pageId))
379
378
  return false;
380
- const label = (_b = (_a = pages.find((p) => p.id === pageId)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : pageId;
381
- return ((_c = parentPeerOf(container, containerId)) === null || _c === void 0 ? void 0 : _c.tearOutMember(pageId, containerId, label, ev)) === true;
379
+ const peer = parentPeerOf(container, containerId);
380
+ const plan = (_a = ctx.tearOutPlan) === null || _a === void 0 ? void 0 : _a.call(ctx, containerId, pageId);
381
+ if (!peer || !plan)
382
+ return false;
383
+ return peer.tearOutMember(pageId, containerId, ev, plan);
382
384
  };
383
385
  const paintStrip = (id, f, h, pages, active) => {
384
386
  var _a, _b, _c;
@@ -456,13 +458,18 @@ export function attachTabsRuntime(ctx, model, container, handle) {
456
458
  };
457
459
  ctx.syncTabs = sync;
458
460
  ctx.subscriptions = (_a = ctx.subscriptions) !== null && _a !== void 0 ? _a : [];
459
- for (const [id, cg] of ctx.boardGroups) {
460
- if (!isTabs(id))
461
- continue;
461
+ const subsOf = new Map();
462
+ const attach = (id) => {
463
+ var _a, _b, _c, _d, _e, _f;
464
+ const cg = (_a = ctx.boardGroups.get(id)) !== null && _a !== void 0 ? _a : model.getGroup(id);
465
+ if (!cg || subsOf.has(id))
466
+ return;
467
+ if (((_b = ctx.layoutOf.get(id)) !== null && _b !== void 0 ? _b : (_c = ctx.specById.get(id)) === null || _c === void 0 ? void 0 : _c.layout) !== 'tabs')
468
+ return;
462
469
  const pages = pagesOf(id);
463
- if (!ctx.activeTab.has(id)) {
470
+ if (!ctx.activeTab.has(id) && pages.length > 0) {
464
471
  const meta = cg.getMetadata('containerWidget');
465
- const want = (_c = (_b = ctx.specById.get(id)) === null || _b === void 0 ? void 0 : _b.active) !== null && _c !== void 0 ? _c : meta === null || meta === void 0 ? void 0 : meta.active;
472
+ const want = (_e = (_d = ctx.specById.get(id)) === null || _d === void 0 ? void 0 : _d.active) !== null && _e !== void 0 ? _e : meta === null || meta === void 0 ? void 0 : meta.active;
466
473
  ctx.activeTab.set(id, want && pages.some((p) => p.id === want) ? want : pages[0].id);
467
474
  if ((meta === null || meta === void 0 ? void 0 : meta.tabs) && !ctx.tabsOf.has(id))
468
475
  ctx.tabsOf.set(id, meta.tabs);
@@ -470,12 +477,59 @@ export function attachTabsRuntime(ctx, model, container, handle) {
470
477
  // Re-lay the pages whenever the parent moves or resizes the container —
471
478
  // and whenever a page joins or leaves, so the strip always shows the pages
472
479
  // that are actually there.
480
+ const offs = [];
473
481
  for (const ev of ['bounds:changed', 'member:added', 'member:removed']) {
474
482
  const off = cg.on(ev, (() => sync(id)));
475
483
  if (typeof off === 'function')
476
- ctx.subscriptions.push(off);
484
+ offs.push(off);
477
485
  }
486
+ subsOf.set(id, offs);
487
+ (_f = ctx.subscriptions) === null || _f === void 0 ? void 0 : _f.push(() => detach(id));
478
488
  sync(id);
489
+ };
490
+ const detach = (id) => {
491
+ var _a, _b;
492
+ for (const off of (_a = subsOf.get(id)) !== null && _a !== void 0 ? _a : [])
493
+ off();
494
+ subsOf.delete(id);
495
+ (_b = ctx.tabStrips.get(id)) === null || _b === void 0 ? void 0 : _b.remove();
496
+ ctx.tabStrips.delete(id);
497
+ };
498
+ ctx.attachTabsContainer = attach;
499
+ ctx.detachTabsContainer = detach;
500
+ for (const id of ctx.boardGroups.keys())
501
+ if (isTabs(id))
502
+ attach(id);
503
+ }
504
+ /**
505
+ * Steps that depend on each other's RESULT — create a group, then move a page
506
+ * into it, then place the group on the board — run as one history step. A
507
+ * batch checks every member's `canExecute` before running any of them, and
508
+ * the second step's precondition (the group exists) is what the first step
509
+ * creates, so as a batch the chain is refused. This runs the chain in order
510
+ * and reverses it on undo, judging nothing up front.
511
+ */
512
+ class SequenceCommand extends Command {
513
+ constructor(name, steps) {
514
+ super(name);
515
+ this.steps = steps;
516
+ }
517
+ execute(context) {
518
+ for (const c of this.steps)
519
+ c.execute(context);
520
+ }
521
+ undo(context) {
522
+ for (let i = this.steps.length - 1; i >= 0; i--)
523
+ this.steps[i].undo(context);
524
+ }
525
+ canExecute() {
526
+ return true;
527
+ }
528
+ canUndo() {
529
+ return true;
530
+ }
531
+ serialize() {
532
+ return { id: this.id, name: this.name, timestamp: this.timestamp, data: { steps: this.steps.map((c) => c.serialize()) } };
479
533
  }
480
534
  }
481
535
  /** `setCaption` as one history step: execute re-applies the value, undo the previous one. */
@@ -605,6 +659,160 @@ export function createDashboardHandle(ctx) {
605
659
  entries.sort((p1, p2) => { var _a, _b, _c, _d; return ((_a = p1.y) !== null && _a !== void 0 ? _a : 0) - ((_b = p2.y) !== null && _b !== void 0 ? _b : 0) || ((_c = p1.x) !== null && _c !== void 0 ? _c : 0) - ((_d = p2.x) !== null && _d !== void 0 ? _d : 0); });
606
660
  return entries;
607
661
  };
662
+ /**
663
+ * TEARING A TAB OUT — what the page becomes. VS Code: a tab dragged out of
664
+ * its group makes a group of its own, still wearing its tab. So the board
665
+ * receives a NEW one-page tab container, `<page>__group`, built exactly as
666
+ * mountBoard() builds an authored one (slab cell in `gridItem`, spec fields
667
+ * in `containerWidget`, board metadata so fromDocument() rebinds it), the
668
+ * page moves into it, and the handle's own registry follows — on execute and
669
+ * on undo. A container left with no page closes, as VS Code closes a group
670
+ * whose last editor leaves; undo reopens it. The board that lands it adds
671
+ * its own displaced-tile commands around these.
672
+ */
673
+ ctx.tearOutPlan = (containerId, pageId) => {
674
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
675
+ const model = (_a = ctx.apiRef) === null || _a === void 0 ? void 0 : _a.getModel();
676
+ const from = ctx.boardGroups.get(containerId);
677
+ const pg = (_b = ctx.boardGroups.get(pageId)) !== null && _b !== void 0 ? _b : model === null || model === void 0 ? void 0 : model.getGroup(pageId);
678
+ const fromSpec = specById.get(containerId);
679
+ const pageSpec = specById.get(pageId);
680
+ if (!model || !from || !pg || !pageSpec)
681
+ return null;
682
+ const viewId = (_c = ctx.viewOfBoard.get(containerId)) !== null && _c !== void 0 ? _c : ctx.active;
683
+ const W = `${pageId}__group`;
684
+ const tabsOpts = (_d = ctx.tabsOf.get(containerId)) !== null && _d !== void 0 ? _d : {};
685
+ const label = (_e = pageSpec.title) !== null && _e !== void 0 ? _e : pageId;
686
+ const size = { width: (_g = (_f = pg.size) === null || _f === void 0 ? void 0 : _f.width) !== null && _g !== void 0 ? _g : 0, height: ((_j = (_h = pg.size) === null || _h === void 0 ? void 0 : _h.height) !== null && _j !== void 0 ? _j : 0) + tabStripReserve(tabsOpts, 1) };
687
+ const remaining = [...((_k = from.members) !== null && _k !== void 0 ? _k : [])].filter((m) => m !== pageId && !!model.getGroup(m));
688
+ return {
689
+ arrivingId: W,
690
+ label,
691
+ size,
692
+ commands: (cell, rect, boardId) => {
693
+ var _a, _b, _c, _d, _e;
694
+ const fromMeta = ((_a = from.getMetadata('containerWidget')) !== null && _a !== void 0 ? _a : {});
695
+ const fromBoard = ((_b = from.getMetadata('dashboardBoard')) !== null && _b !== void 0 ? _b : {});
696
+ const g = new GroupModel({ id: W, name: label });
697
+ g.setMetadata('frameChrome', 'none');
698
+ g.setMetadata('gridItem', gridItemFromCell(cell));
699
+ g.setMetadata('containerWidget', Object.assign({ title: label, columns: (_c = fromMeta.columns) !== null && _c !== void 0 ? _c : cell.w, maxRows: (_d = fromMeta.maxRows) !== null && _d !== void 0 ? _d : cell.h, layout: 'tabs', active: pageId }, (Object.keys(tabsOpts).length ? { tabs: tabsOpts } : {})));
700
+ g.setMetadata('dashboardBoard', Object.assign(Object.assign({}, fromBoard), { layout: 'tabs' }));
701
+ g.position = { x: rect.x, y: rect.y };
702
+ g.size = { width: rect.width, height: rect.height, depth: 0 };
703
+ const wSpec = Object.assign(Object.assign({ id: W, title: label, layout: 'tabs', active: pageId }, (Object.keys(tabsOpts).length ? { tabs: tabsOpts } : {})), { columns: (_e = fromMeta.columns) !== null && _e !== void 0 ? _e : cell.w, widgets: [pageSpec] });
704
+ let slot = -1;
705
+ const born = {
706
+ register: () => {
707
+ var _a;
708
+ const live = model.getGroup(W);
709
+ if (!live)
710
+ return;
711
+ ctx.boardGroups.set(W, live);
712
+ ctx.layoutOf.set(W, 'tabs');
713
+ ctx.activeTab.set(W, pageId);
714
+ ctx.tabsOf.set(W, tabsOpts);
715
+ specById.set(W, wSpec);
716
+ ctx.viewOfBoard.set(W, viewId);
717
+ viewOfWidget.set(W, boardId);
718
+ viewOfWidget.set(pageId, W);
719
+ if (fromSpec === null || fromSpec === void 0 ? void 0 : fromSpec.widgets) {
720
+ const i = fromSpec.widgets.findIndex((p) => p.id === pageId);
721
+ if (i >= 0) {
722
+ slot = i;
723
+ fromSpec.widgets.splice(i, 1);
724
+ }
725
+ }
726
+ const arr = ctx.boardWidgets.get(boardId);
727
+ if (arr && !arr.some((w) => w.id === W))
728
+ arr.push(wSpec);
729
+ ctx.boardWidgets.set(W, wSpec.widgets);
730
+ (_a = ctx.attachTabsContainer) === null || _a === void 0 ? void 0 : _a.call(ctx, W);
731
+ },
732
+ unregister: () => {
733
+ var _a;
734
+ (_a = ctx.detachTabsContainer) === null || _a === void 0 ? void 0 : _a.call(ctx, W);
735
+ ctx.boardGroups.delete(W);
736
+ ctx.layoutOf.delete(W);
737
+ ctx.activeTab.delete(W);
738
+ ctx.tabsOf.delete(W);
739
+ specById.delete(W);
740
+ ctx.viewOfBoard.delete(W);
741
+ viewOfWidget.delete(W);
742
+ viewOfWidget.set(pageId, containerId);
743
+ if ((fromSpec === null || fromSpec === void 0 ? void 0 : fromSpec.widgets) && !fromSpec.widgets.some((p) => p.id === pageId)) {
744
+ fromSpec.widgets.splice(slot < 0 ? fromSpec.widgets.length : Math.min(slot, fromSpec.widgets.length), 0, pageSpec);
745
+ }
746
+ const arr = ctx.boardWidgets.get(boardId);
747
+ if (arr) {
748
+ const i = arr.findIndex((w) => w.id === W);
749
+ if (i >= 0)
750
+ arr.splice(i, 1);
751
+ }
752
+ ctx.boardWidgets.delete(W);
753
+ },
754
+ };
755
+ const move = [
756
+ new SequenceCommand('Move tab out', [
757
+ new AddGroupCommand(g),
758
+ new RemoveFromGroupCommand(containerId, pageId),
759
+ new AddToGroupCommand(W, pageId),
760
+ new AddToGroupCommand(boardId, W),
761
+ new RegisterWidgetCommand(born, 'register'),
762
+ ]),
763
+ ];
764
+ if (remaining.length > 0)
765
+ return { move, collapse: [] };
766
+ // The last page left: the container closes. Its registry goes FIRST
767
+ // so that on undo it comes back AFTER the group and its slab do.
768
+ let fromSlot = -1;
769
+ const gone = {
770
+ register: () => {
771
+ var _a;
772
+ const live = model.getGroup(containerId);
773
+ if (!live)
774
+ return;
775
+ ctx.boardGroups.set(containerId, live);
776
+ ctx.layoutOf.set(containerId, 'tabs');
777
+ ctx.viewOfBoard.set(containerId, viewId);
778
+ if (fromSpec)
779
+ specById.set(containerId, fromSpec);
780
+ const arr = ctx.boardWidgets.get(boardId);
781
+ if (arr && fromSpec && !arr.some((w) => w.id === containerId)) {
782
+ arr.splice(fromSlot < 0 ? arr.length : Math.min(fromSlot, arr.length), 0, fromSpec);
783
+ }
784
+ (_a = ctx.attachTabsContainer) === null || _a === void 0 ? void 0 : _a.call(ctx, containerId);
785
+ },
786
+ unregister: () => {
787
+ var _a;
788
+ (_a = ctx.detachTabsContainer) === null || _a === void 0 ? void 0 : _a.call(ctx, containerId);
789
+ ctx.boardGroups.delete(containerId);
790
+ ctx.layoutOf.delete(containerId);
791
+ ctx.viewOfBoard.delete(containerId);
792
+ specById.delete(containerId);
793
+ const arr = ctx.boardWidgets.get(boardId);
794
+ if (arr) {
795
+ const i = arr.findIndex((w) => w.id === containerId);
796
+ if (i >= 0) {
797
+ fromSlot = i;
798
+ arr.splice(i, 1);
799
+ }
800
+ }
801
+ },
802
+ };
803
+ return {
804
+ move,
805
+ collapse: [
806
+ new SequenceCommand('Close empty tab container', [
807
+ new RegisterWidgetCommand(gone, 'unregister'),
808
+ new RemoveFromGroupCommand(boardId, containerId),
809
+ new RemoveGroupCommand(containerId),
810
+ ]),
811
+ ],
812
+ };
813
+ },
814
+ };
815
+ };
608
816
  /** Bookkeeping closures for one widget, shared by the add and remove
609
817
  * commands (and applied synchronously by the handle — idempotent). */
610
818
  const registryOf = (id, boardId, spec) => {
@@ -493,13 +493,64 @@ interface BinderPeer {
493
493
  }, pxSize: {
494
494
  width: number;
495
495
  height: number;
496
- }): AdoptedLeg | null;
496
+ }, opts?: AdoptOptions): AdoptedLeg | null;
497
497
  /**
498
498
  * Tear a tab page out of its container and onto THIS board, driven by the
499
499
  * press that started on its tab. Answers false when the board will not take
500
500
  * it, so the strip can leave the press alone.
501
501
  */
502
- tearOutMember(pageId: string, fromGroupId: string, label: string, ev: PointerEvent): boolean;
502
+ tearOutMember(pageId: string, fromGroupId: string, ev: PointerEvent, plan: TearOutPlan): boolean;
503
+ }
504
+ /**
505
+ * What a torn-out page becomes, decided by the dashboard handle (which owns
506
+ * the specs and the registry) and carried out by the board it lands on. VS
507
+ * Code's rule: a tab dragged out of its group makes a GROUP of its own — so
508
+ * the board receives a new one-page tab container, not the bare page, and the
509
+ * page keeps its tab.
510
+ */
511
+ export interface TearOutPlan {
512
+ /** The id the board will hold: the new one-page tab container around the page. */
513
+ arrivingId: string;
514
+ /** What the chip that follows the pointer says. */
515
+ label: string;
516
+ /** Pixel size to arrive at — the page plus the strip the new group needs. */
517
+ size: {
518
+ width: number;
519
+ height: number;
520
+ };
521
+ /**
522
+ * The model and registry commands for a landing at `cell`/`rect` on
523
+ * `boardId`. `move` creates the group, moves the page in and registers it;
524
+ * `collapse` (possibly empty) removes the container the page left when it
525
+ * was its last page — VS Code closes an empty group.
526
+ */
527
+ commands(cell: {
528
+ x: number;
529
+ y: number;
530
+ w: number;
531
+ h: number;
532
+ }, rect: {
533
+ x: number;
534
+ y: number;
535
+ width: number;
536
+ height: number;
537
+ }, boardId: string): {
538
+ move: Command[];
539
+ collapse: Command[];
540
+ };
541
+ }
542
+ /** A torn-out page never arrives shorter than this: a strip with no room under it is not a group. */
543
+ export declare const TEAR_OUT_MIN_ROWS = 2;
544
+ interface AdoptOptions {
545
+ /**
546
+ * 'shrink': take the cell under the pointer at the height that FITS there
547
+ * (down to TEAR_OUT_MIN_ROWS) before looking for another row — a torn-out
548
+ * page takes the room where it lands, the way a torn-out editor does. The
549
+ * default keeps the tile's size and finds the nearest cell that takes it.
550
+ */
551
+ fit?: 'shrink';
552
+ /** Anchor the tile's TOP edge at the pointer instead of centring it (the pointer holds a tab, the page hangs below it). */
553
+ anchor?: 'top';
503
554
  }
504
555
  interface AdoptedLeg {
505
556
  groupId: string;
@@ -154,6 +154,8 @@ export function pressOnDragHandle(sel, target, hostEl, clientX, clientY) {
154
154
  }
155
155
  /** The caption strip of a host with no kit header, px from the card's top. */
156
156
  export const CAPTION_BAND = 28;
157
+ /** A torn-out page never arrives shorter than this: a strip with no room under it is not a group. */
158
+ export const TEAR_OUT_MIN_ROWS = 2;
157
159
  /**
158
160
  * Binders on the same canvas find each other here. A WeakMap: a canvas that
159
161
  * is gone takes its (empty) peer set with it — the strong Map it replaced kept
@@ -2130,6 +2132,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2130
2132
  }
2131
2133
  return best;
2132
2134
  };
2135
+ /** `handle.planRemoval` for local use before the handle object exists. */
2136
+ const planRemovalOf = (id) => handle.planRemoval(id);
2133
2137
  /** This binder's side of an adoption: enter gateless, then live-push. */
2134
2138
  /**
2135
2139
  * Put an ARRIVING tile as close to the pointer as the board allows. The
@@ -2139,37 +2143,40 @@ export function bindDashboardGrid(api, group, options = {}) {
2139
2143
  * I put it". Slide along the row instead, nearest first, so a tile that
2140
2144
  * cannot take the cell under the pointer still lands beside it.
2141
2145
  */
2142
- const placeNear = (id, x, y, w) => {
2143
- // `changed:false` means BOTH "refused" and "it is already there", and
2144
- // reading the second as the first made the search walk straight past the
2145
- // right answer: parked on the nearest legal cell, the next pointer move
2146
- // re-tried the wanted cell (refused), re-tried the cell it was ON (no
2147
- // change, read as refused) and then took a FARTHER one — so the tile
2148
- // oscillated for the whole drag and settled wherever the last swing left
2149
- // it. Ask where the tile IS, not whether the call moved it.
2150
- const at = (cx, cy) => {
2146
+ /**
2147
+ * Slide along ONE row, nearest column first. `changed:false` means BOTH
2148
+ * "refused" and "it is already there", and reading the second as the first
2149
+ * made the search walk straight past the right answer: parked on the nearest
2150
+ * legal cell, the next pointer move re-tried the wanted cell (refused),
2151
+ * re-tried the cell it was ON (no change, read as refused) and then took a
2152
+ * FARTHER one — so the tile oscillated for the whole drag and settled
2153
+ * wherever the last swing left it. Ask where the tile IS, not whether the
2154
+ * call moved it.
2155
+ */
2156
+ const placeOnRow = (id, x, y, w) => {
2157
+ const at = (cx) => {
2151
2158
  const i = engine.getItem(id);
2152
- return !!i && i.x === cx && i.y === cy;
2159
+ return !!i && i.x === cx && i.y === y;
2153
2160
  };
2154
2161
  const maxX = Math.max(0, columns - w);
2155
- const scanRow = (cy) => {
2156
- if (at(x, cy))
2157
- return true;
2158
- if (engine.moveCheck(id, x, cy, { gate: false }).changed)
2159
- return true;
2160
- for (let d = 1; d <= columns; d++) {
2161
- for (const cx of [x - d, x + d]) {
2162
- if (cx < 0 || cx > maxX)
2163
- continue;
2164
- if (at(cx, cy))
2165
- return true;
2166
- if (engine.moveCheck(id, cx, cy, { gate: false }).changed)
2167
- return true;
2168
- }
2162
+ if (at(x))
2163
+ return true;
2164
+ if (engine.moveCheck(id, x, y, { gate: false }).changed)
2165
+ return true;
2166
+ for (let d = 1; d <= columns; d++) {
2167
+ for (const cx of [x - d, x + d]) {
2168
+ if (cx < 0 || cx > maxX)
2169
+ continue;
2170
+ if (at(cx))
2171
+ return true;
2172
+ if (engine.moveCheck(id, cx, y, { gate: false }).changed)
2173
+ return true;
2169
2174
  }
2170
- return false;
2171
- };
2172
- if (scanRow(y))
2175
+ }
2176
+ return false;
2177
+ };
2178
+ const placeNear = (id, x, y, w) => {
2179
+ if (placeOnRow(id, x, y, w))
2173
2180
  return true;
2174
2181
  // EVERY column on that row refused — which is what a locked section
2175
2182
  // spanning the full width does, and there are plenty of those. Sliding
@@ -2181,15 +2188,70 @@ export function bindDashboardGrid(api, group, options = {}) {
2181
2188
  for (const cy of [y - d, y + d]) {
2182
2189
  if (cy < 0)
2183
2190
  continue;
2184
- if (scanRow(cy))
2191
+ if (placeOnRow(id, x, cy, w))
2185
2192
  return true;
2186
2193
  }
2187
2194
  }
2188
2195
  return false;
2189
2196
  };
2197
+ /**
2198
+ * Rows free at (x, y) across `w` columns before the nearest LOCKED tile
2199
+ * below — a section is a locked tile nothing can push, so that is the hard
2200
+ * ceiling on what can land there — capped at the natural height. 0 when the
2201
+ * row itself lies under a locked tile.
2202
+ */
2203
+ const fitHeightAt = (id, x, y, w, hNatural) => {
2204
+ let limit = hNatural;
2205
+ for (const it of engine.getItems()) {
2206
+ if (it.id === id || !it.locked)
2207
+ continue;
2208
+ if (!(it.x < x + w && x < it.x + it.w))
2209
+ continue;
2210
+ if (it.y <= y && it.y + it.h > y)
2211
+ return 0;
2212
+ if (it.y > y)
2213
+ limit = Math.min(limit, it.y - y);
2214
+ }
2215
+ const b = bound();
2216
+ if (b !== undefined)
2217
+ limit = Math.min(limit, Math.max(0, b - y));
2218
+ return limit;
2219
+ };
2220
+ /**
2221
+ * A TEAR-OUT takes the cell under the pointer at the height that fits there
2222
+ * — a page dragged out of a full-height panel onto a busy board would
2223
+ * otherwise have exactly one legal cell, below the lowest section, and the
2224
+ * drop would land a screen away from the pointer with its placeholder out of
2225
+ * sight. Only when nothing fits there does it fall back to the nearest row
2226
+ * at its natural height.
2227
+ */
2228
+ const placeFitting = (id, x, y, w, hNatural) => {
2229
+ var _a, _b;
2230
+ const it = engine.getItem(id);
2231
+ if (!it)
2232
+ return false;
2233
+ const hFit = fitHeightAt(id, x, y, w, hNatural);
2234
+ if (hFit >= TEAR_OUT_MIN_ROWS) {
2235
+ if (it.h !== hFit)
2236
+ engine.resizeCheck(id, w, hFit);
2237
+ if (((_a = engine.getItem(id)) === null || _a === void 0 ? void 0 : _a.h) === hFit && placeOnRow(id, x, y, w))
2238
+ return true;
2239
+ }
2240
+ const now = engine.getItem(id);
2241
+ if (now && now.h !== hNatural) {
2242
+ // Growing back where it sits may be refused; the entry row at the bottom
2243
+ // always has room, so grow there and let placeNear bring it up.
2244
+ engine.resizeCheck(id, w, hNatural);
2245
+ if (((_b = engine.getItem(id)) === null || _b === void 0 ? void 0 : _b.h) !== hNatural) {
2246
+ engine.moveCheck(id, 0, engine.rows(), { gate: false });
2247
+ engine.resizeCheck(id, w, hNatural);
2248
+ }
2249
+ }
2250
+ return placeNear(id, x, y, w);
2251
+ };
2190
2252
  const adopt = (
2191
2253
  // Only the id is used: a tab page arriving here is a GROUP, not a node.
2192
- node, world, pxSize) => {
2254
+ node, world, pxSize, opts = {}) => {
2193
2255
  if (disposed)
2194
2256
  return null;
2195
2257
  const f = frame();
@@ -2224,9 +2286,16 @@ export function bindDashboardGrid(api, group, options = {}) {
2224
2286
  }
2225
2287
  }
2226
2288
  adoptedGhostId = node.id;
2227
- const tl = centredTopLeft(world.x, world.y, span);
2228
- const cell0 = pointToCell(tl.x, tl.y, f, gg, rows(), span.w);
2229
- placeNear(node.id, cell0.x, cell0.y, span.w);
2289
+ const hNatural = span.h;
2290
+ const wantedCell = (wx, wy, itemW, itemH) => {
2291
+ const tl = centredTopLeft(wx, wy, { w: itemW, h: opts.anchor === 'top' ? 0 : itemH });
2292
+ return pointToCell(tl.x, tl.y, frame(), geom(), rows(), itemW);
2293
+ };
2294
+ const place = (cell, itemW) => opts.fit === 'shrink' ? placeFitting(node.id, cell.x, cell.y, itemW, hNatural) : placeNear(node.id, cell.x, cell.y, itemW);
2295
+ let lastWant = null;
2296
+ const cell0 = wantedCell(world.x, world.y, span.w, span.h);
2297
+ lastWant = cell0;
2298
+ place(cell0, span.w);
2230
2299
  armGlide();
2231
2300
  project();
2232
2301
  syncPlaceholder();
@@ -2236,9 +2305,12 @@ export function bindDashboardGrid(api, group, options = {}) {
2236
2305
  const item = engine.getItem(node.id);
2237
2306
  if (!item)
2238
2307
  return;
2239
- const tlm = centredTopLeft(w.x, w.y, { w: item.w, h: item.h });
2240
- const cell = pointToCell(tlm.x, tlm.y, frame(), geom(), rows(), item.w);
2241
- if (placeNear(node.id, cell.x, cell.y, item.w))
2308
+ const cell = wantedCell(w.x, w.y, item.w, opts.fit === 'shrink' ? hNatural : item.h);
2309
+ // The search is worth running once per wanted cell, not per pixel.
2310
+ if (lastWant && lastWant.x === cell.x && lastWant.y === cell.y)
2311
+ return;
2312
+ lastWant = cell;
2313
+ if (place(cell, item.w))
2242
2314
  project();
2243
2315
  syncPlaceholder();
2244
2316
  },
@@ -2271,7 +2343,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2271
2343
  };
2272
2344
  const selfPeer = {
2273
2345
  group,
2274
- tearOutMember: (pageId, fromGroupId, label, ev) => beginTearOut(pageId, fromGroupId, label, ev),
2346
+ tearOutMember: (pageId, fromGroupId, ev, plan) => beginTearOut(pageId, fromGroupId, ev, plan),
2275
2347
  clearSelection: () => {
2276
2348
  var _a;
2277
2349
  if (selectedId === undefined)
@@ -2822,36 +2894,31 @@ export function bindDashboardGrid(api, group, options = {}) {
2822
2894
  * Like VS Code, the page itself does not follow the pointer; a chip carrying
2823
2895
  * the tab's label does, and the placeholder shows where the drop will land.
2824
2896
  */
2825
- const beginTearOut = (pageId, fromGroupId, label, ev) => {
2826
- var _a, _b, _c;
2897
+ const beginTearOut = (pageId, fromGroupId, ev, plan) => {
2898
+ var _a;
2827
2899
  if (disposed || gesture || slabGesture || isStatic || tearing)
2828
2900
  return false;
2829
- const pg = diagram.getGroup(pageId);
2830
2901
  const from = diagram.getGroup(fromGroupId);
2831
- if (!pg || !from || !engine.getItem(fromGroupId))
2902
+ if (!from || !diagram.getGroup(pageId) || !engine.getItem(fromGroupId))
2832
2903
  return false;
2833
- const size = sizeOf(pg);
2834
- const frameBefore = frameOfGroup(pg);
2835
- const cellBefore = (_b = cellFromGridItem((_a = pg.getMetadata) === null || _a === void 0 ? void 0 : _a.call(pg, 'gridItem'))) !== null && _b !== void 0 ? _b : {
2836
- x: 0,
2837
- y: 0,
2838
- w: 1,
2839
- h: 1,
2840
- };
2841
2904
  const toWorld = (cx, cy) => {
2842
2905
  var _a;
2843
2906
  const rect = api.container.getBoundingClientRect();
2844
2907
  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 };
2845
2908
  };
2846
2909
  const first = toWorld(ev.clientX, ev.clientY);
2847
- const leg = adopt({ id: pageId }, first, { width: size.width, height: size.height });
2910
+ // The board holds the NEW group, not the bare page: it is adopted under the
2911
+ // group's id, sized for the page plus its strip, taking the room under the
2912
+ // pointer with its top edge at the pointer — the tab is what the pointer
2913
+ // holds, the page hangs below it.
2914
+ const leg = adopt({ id: plan.arrivingId }, first, plan.size, { fit: 'shrink', anchor: 'top' });
2848
2915
  if (!leg)
2849
2916
  return false;
2850
2917
  tearing = pageId;
2851
- const doc = (_c = api.container.ownerDocument) !== null && _c !== void 0 ? _c : document;
2918
+ const doc = (_a = api.container.ownerDocument) !== null && _a !== void 0 ? _a : document;
2852
2919
  const chip = doc.createElement('div');
2853
2920
  chip.className = 'axdb-drag-chip axdb-tab-chip';
2854
- chip.textContent = label;
2921
+ chip.textContent = plan.label;
2855
2922
  doc.body.appendChild(chip);
2856
2923
  const moveChip = (cx, cy) => {
2857
2924
  chip.style.left = `${cx + 6}px`;
@@ -2903,12 +2970,12 @@ export function bindDashboardGrid(api, group, options = {}) {
2903
2970
  api.renderNow();
2904
2971
  return;
2905
2972
  }
2906
- const changed = execute('Move tab out', [
2907
- ...fin.commands,
2908
- new SetGroupCellCommand(pageId, cellBefore, fin.cell, frameBefore, fin.rect),
2909
- new RemoveFromGroupCommand(fromGroupId, pageId),
2910
- new AddToGroupCommand(group.id, pageId),
2911
- ]);
2973
+ const planned = plan.commands(fin.cell, fin.rect, group.id);
2974
+ // The container the page left may have been holding its last page: it
2975
+ // goes too, and the tiles around its slab settle the way a removal
2976
+ // settles them (VS Code closes a group whose last editor leaves).
2977
+ const collapse = planned.collapse.length > 0 ? [...planRemovalOf(fromGroupId), ...planned.collapse] : [];
2978
+ const changed = execute('Move tab out', [...fin.commands, ...planned.move, ...collapse]);
2912
2979
  disarmGlideSoon();
2913
2980
  enforceBoardHeight();
2914
2981
  persistLayouts();
@@ -3296,7 +3363,18 @@ export function bindDashboardGrid(api, group, options = {}) {
3296
3363
  const it = engine.getItem(id);
3297
3364
  if (!it)
3298
3365
  return [];
3299
- const clone = new GridPackEngine(engine.getItems().map((i) => (Object.assign({}, i))), { columns, float });
3366
+ // A float:false engine PACKS as it is built, in ARRAY order — so a tile
3367
+ // that entered the engine last (one just adopted by a drop) is packed
3368
+ // last, and everything above it in the array floats up through the room
3369
+ // it occupies. The plan then "moved" tiles the removal never touched and
3370
+ // undid the drop's own push (L64: B back onto the cell the torn-out
3371
+ // group had just taken, and the group auto-placed into the hole the
3372
+ // closed container left). Packing in row-then-column order is gravity,
3373
+ // whatever order the tiles arrived in.
3374
+ const clone = new GridPackEngine(engine
3375
+ .getItems()
3376
+ .map((i) => (Object.assign({}, i)))
3377
+ .sort((a, b) => a.y - b.y || a.x - b.x), { columns, float });
3300
3378
  clone.remove(id);
3301
3379
  const f = frame();
3302
3380
  const g = geom();
@@ -1253,7 +1253,7 @@ export function bindDashboardSplit(api, group, options = {}) {
1253
1253
  resizeMemberBy: () => ({ changed: false }),
1254
1254
  // A split board has no cells to drop a torn-out page into: it refuses,
1255
1255
  // and the press stays a plain tab click.
1256
- tearOutMember: () => false,
1256
+ tearOutMember: (_pageId, _fromGroupId, _ev, _plan) => false,
1257
1257
  containsWorld: (x, y) => worldInsideBoard(x, y),
1258
1258
  containsWorldExtended: (x, y) => worldInsideBoard(x, y),
1259
1259
  frameArea: () => {