@grafloria/element 0.4.68 → 0.4.69

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.68",
3
+ "version": "0.4.69",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -52,7 +52,7 @@
52
52
  import { Command, type DiagramModel, type GridColumnLayout, type GroupModel, type NodeModel } from '@grafloria/engine';
53
53
  import { type ToolPointerEvent } from '@grafloria/renderer';
54
54
  import { type CellRect, type WorldRect } from './grid-mapping.js';
55
- import { type BesideSide } from './zones.js';
55
+ import { type BesideSide, type ZoneBoard } from './zones.js';
56
56
  import { type ResizeEdges } from './edges.js';
57
57
  import { type DragHandleOption } from './grip.js';
58
58
  export { TEAR_OUT_MIN_ROWS } from './tear-out.js';
@@ -683,6 +683,24 @@ export declare function parentPeerOf(container: HTMLElement, groupId: string): B
683
683
  export declare function clearOtherSelections(container: HTMLElement, self: BinderPeer | null): void;
684
684
  export declare function registerBoardPeer(container: HTMLElement, peer: BinderPeer): () => void;
685
685
  export type { BinderPeer };
686
+ /** The peers registered on a canvas (the set is created on first ask): the boards the zone walk sees. */
687
+ export declare function peersOnCanvasOf(container: HTMLElement): Set<BinderPeer>;
688
+ /** Client pixels per world unit on a canvas: a zoomed camera makes a 9 px stay fewer world units. */
689
+ export declare function clientPerWorldOf(api: Pick<DashboardGridApi, 'container' | 'viewport'>): {
690
+ x: number;
691
+ y: number;
692
+ };
693
+ /**
694
+ * The boards of a canvas as the zone walk sees them (tile first, step 2): a
695
+ * ROOT is a board whose group has no parent group (a view); a board's children
696
+ * are its member groups that are containers, each with the board a descent
697
+ * enters — a tab container's ACTIVE page, a section's own board — one level
698
+ * deeper. Built from the peers and the model on every move: a handful of
699
+ * groups, and the frames are live. One builder for the grid AND the split
700
+ * board (0.4.69): what a hand means over a tab group must not depend on which
701
+ * layout the board under it runs.
702
+ */
703
+ export declare function zoneRootsOf(peers: Iterable<BinderPeer>, diagram: DiagramModel): ZoneBoard[];
686
704
  /**
687
705
  * THE STRIP KEEPS THE HAND IT HAS (CSS px). A tab strip is ~30 px tall and
688
706
  * the zone right under it moves the whole container out of the way, so at the
@@ -56,7 +56,7 @@ import { buildCommitCommands, cellFromGridItem, cellToRect, columnUnitFor, gridI
56
56
  import { ensureDashboardKitStyles } from './styles.js';
57
57
  import { captionOfGroup, captionPassThrough, sectionCaptionReserve } from './caption.js';
58
58
  import { TAB_STRIP_HEIGHT } from './tabs.js';
59
- import { BESIDE_BAND, resolve as resolveZone, stripUnder } from './zones.js';
59
+ import { BESIDE_BAND, resolve as resolveZone, stripCrossing, stripUnder } from './zones.js';
60
60
  import { SequenceCommand, SetGroupCellCommand, tileCommands } from './commit.js';
61
61
  import { EDGE_GRACE } from './board-ctx.js';
62
62
  import { createProjection } from './project.js';
@@ -148,6 +148,84 @@ export function registerBoardPeer(container, peer) {
148
148
  s.delete(peer);
149
149
  };
150
150
  }
151
+ /** The peers registered on a canvas (the set is created on first ask): the boards the zone walk sees. */
152
+ export function peersOnCanvasOf(container) {
153
+ let set = BOARD_REGISTRY.get(container);
154
+ if (!set) {
155
+ set = new Set();
156
+ BOARD_REGISTRY.set(container, set);
157
+ }
158
+ return set;
159
+ }
160
+ /** Client pixels per world unit on a canvas: a zoomed camera makes a 9 px stay fewer world units. */
161
+ export function clientPerWorldOf(api) {
162
+ const rect = api.container.getBoundingClientRect();
163
+ const toWorld = (cx, cy) => { var _a; 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 }; };
164
+ const o = toWorld(rect.left, rect.top);
165
+ const u = toWorld(rect.left + 100, rect.top + 100);
166
+ return { x: 100 / (u.x - o.x || 100), y: 100 / (u.y - o.y || 100) };
167
+ }
168
+ /**
169
+ * The boards of a canvas as the zone walk sees them (tile first, step 2): a
170
+ * ROOT is a board whose group has no parent group (a view); a board's children
171
+ * are its member groups that are containers, each with the board a descent
172
+ * enters — a tab container's ACTIVE page, a section's own board — one level
173
+ * deeper. Built from the peers and the model on every move: a handful of
174
+ * groups, and the frames are live. One builder for the grid AND the split
175
+ * board (0.4.69): what a hand means over a tab group must not depend on which
176
+ * layout the board under it runs.
177
+ */
178
+ export function zoneRootsOf(peers, diagram) {
179
+ const list = [...peers];
180
+ const byGroup = new Map(list.map((p) => [p.group.id, p]));
181
+ const frameOf = (grp) => { var _a, _b, _c, _d; return ({ x: grp.position.x, y: grp.position.y, width: (_b = (_a = grp.size) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : 0, height: (_d = (_c = grp.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0 }); };
182
+ const boardRef = (p, depth) => ({
183
+ id: p.group.id,
184
+ depth,
185
+ ref: p,
186
+ contains: (x, y) => p.containsWorld(x, y),
187
+ containsExtended: (x, y) => p.containsWorldExtended(x, y),
188
+ children: () => {
189
+ var _a, _b, _c, _d, _e;
190
+ const out = [];
191
+ for (const id of (_a = p.group.members) !== null && _a !== void 0 ? _a : []) {
192
+ const grp = diagram.getGroup(id);
193
+ if (!grp || diagram.getNode(id))
194
+ continue;
195
+ const cw = ((_b = grp.getMetadata('containerWidget')) !== null && _b !== void 0 ? _b : {});
196
+ const layout = cw.layout === 'tabs' ? 'tabs' : cw.layout === 'split' ? 'split' : 'grid';
197
+ let innerPeer;
198
+ if (layout === 'tabs') {
199
+ const pageId = cw.active && byGroup.has(cw.active) ? cw.active : [...((_c = grp.members) !== null && _c !== void 0 ? _c : [])].find((m) => byGroup.has(m));
200
+ innerPeer = pageId ? byGroup.get(pageId) : undefined;
201
+ }
202
+ else
203
+ innerPeer = byGroup.get(id);
204
+ out.push({
205
+ id,
206
+ layout,
207
+ static: (_e = (_d = innerPeer === null || innerPeer === void 0 ? void 0 : innerPeer.isStatic) === null || _d === void 0 ? void 0 : _d.call(innerPeer)) !== null && _e !== void 0 ? _e : false,
208
+ frame: frameOf(grp),
209
+ stripHeight: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
210
+ band: layout === 'tabs' ? BESIDE_BAND : 0, // a section's whole body is "into" (Quantia's Groups page)
211
+ // The top and bottom are a FIXED depth — one strip's worth, under
212
+ // the strip — not a fifth of the body, which grew with the panel
213
+ // until 216 px of the fluid demo's page meant "above the whole
214
+ // panel" (0.4.62). The sides keep the fifth.
215
+ bandY: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
216
+ // …and the TOP band hangs ABOVE the frame, where a hand looking
217
+ // for "above this panel" actually goes (0.4.65). A panel holding
218
+ // the board's first row has no row above it to point at, and the
219
+ // lane under its header is the last place anyone would try.
220
+ topOutside: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
221
+ inner: innerPeer ? boardRef(innerPeer, depth + 1) : null,
222
+ });
223
+ }
224
+ return out;
225
+ },
226
+ });
227
+ return list.filter((p) => !p.group.parentGroupId).map((p) => boardRef(p, 0));
228
+ }
151
229
  const DRAG_THRESHOLD = 4;
152
230
  /**
153
231
  * THE STRIP KEEPS THE HAND IT HAS (CSS px). A tab strip is ~30 px tall and
@@ -2081,72 +2159,9 @@ export function bindDashboardGrid(api, group, options = {}) {
2081
2159
  const f = frame();
2082
2160
  return f.width * boardVisualHeight();
2083
2161
  };
2084
- const peersOnCanvas = () => {
2085
- let set = BOARD_REGISTRY.get(api.container);
2086
- if (!set) {
2087
- set = new Set();
2088
- BOARD_REGISTRY.set(api.container, set);
2089
- }
2090
- return set;
2091
- };
2092
- /**
2093
- * The boards of this canvas as the zone walk sees them (tile first, step
2094
- * 2): a ROOT is a board whose group has no parent group (a view); a board's
2095
- * children are its member groups that are containers, each with the board a
2096
- * descent enters — a tab container's ACTIVE page, a section's own board —
2097
- * one level deeper. Built from the peers and the model on every move: a
2098
- * handful of groups, and the frames are live.
2099
- */
2100
- const zoneRoots = () => {
2101
- const peers = [...peersOnCanvas()];
2102
- const byGroup = new Map(peers.map((p) => [p.group.id, p]));
2103
- const boardRef = (p, depth) => ({
2104
- id: p.group.id,
2105
- depth,
2106
- ref: p,
2107
- contains: (x, y) => p.containsWorld(x, y),
2108
- containsExtended: (x, y) => p.containsWorldExtended(x, y),
2109
- children: () => {
2110
- var _a, _b, _c, _d, _e;
2111
- const out = [];
2112
- for (const id of (_a = p.group.members) !== null && _a !== void 0 ? _a : []) {
2113
- const grp = diagram.getGroup(id);
2114
- if (!grp || diagram.getNode(id))
2115
- continue;
2116
- const cw = ((_b = grp.getMetadata('containerWidget')) !== null && _b !== void 0 ? _b : {});
2117
- const layout = cw.layout === 'tabs' ? 'tabs' : cw.layout === 'split' ? 'split' : 'grid';
2118
- let innerPeer;
2119
- if (layout === 'tabs') {
2120
- const pageId = cw.active && byGroup.has(cw.active) ? cw.active : [...((_c = grp.members) !== null && _c !== void 0 ? _c : [])].find((m) => byGroup.has(m));
2121
- innerPeer = pageId ? byGroup.get(pageId) : undefined;
2122
- }
2123
- else
2124
- innerPeer = byGroup.get(id);
2125
- out.push({
2126
- id,
2127
- layout,
2128
- static: (_e = (_d = innerPeer === null || innerPeer === void 0 ? void 0 : innerPeer.isStatic) === null || _d === void 0 ? void 0 : _d.call(innerPeer)) !== null && _e !== void 0 ? _e : false,
2129
- frame: frameOfGroup(grp),
2130
- stripHeight: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
2131
- band: layout === 'tabs' ? BESIDE_BAND : 0, // a section's whole body is "into" (Quantia's Groups page)
2132
- // The top and bottom are a FIXED depth — one strip's worth, under
2133
- // the strip — not a fifth of the body, which grew with the panel
2134
- // until 216 px of the fluid demo's page meant "above the whole
2135
- // panel" (0.4.62). The sides keep the fifth.
2136
- bandY: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
2137
- // …and the TOP band hangs ABOVE the frame, where a hand looking
2138
- // for "above this panel" actually goes (0.4.65). A panel holding
2139
- // the board's first row has no row above it to point at, and the
2140
- // lane under its header is the last place anyone would try.
2141
- topOutside: layout === 'tabs' ? TAB_STRIP_HEIGHT : 0,
2142
- inner: innerPeer ? boardRef(innerPeer, depth + 1) : null,
2143
- });
2144
- }
2145
- return out;
2146
- },
2147
- });
2148
- return peers.filter((p) => !p.group.parentGroupId).map((p) => boardRef(p, 0));
2149
- };
2162
+ const peersOnCanvas = () => peersOnCanvasOf(api.container);
2163
+ /** The boards of this canvas as the zone walk sees them — see `zoneRootsOf`. */
2164
+ const zoneRoots = () => zoneRootsOf(peersOnCanvas(), diagram);
2150
2165
  /** The ghost takes the cell under the hand on THIS board: re-entering at the bottom edge first (collision-free), then gatelessly; a tile already here moves through the gate. */
2151
2166
  const placeOnSelf = (g, desired, pushSolid = false) => {
2152
2167
  var _a, _b;
@@ -2198,7 +2213,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2198
2213
  };
2199
2214
  /** What the pointer means for the dragged tile: the resolve over the live tree, with the beside the hand holds. */
2200
2215
  const resolveTileZone = (g, ev) => {
2201
- var _a, _b, _c, _d, _e, _f, _g, _h;
2216
+ var _a, _b, _c, _d, _e, _f;
2202
2217
  const roots = zoneRoots();
2203
2218
  // THE CONTAINERS THIS GESTURE HAS MOVED, at the frames they rest in. A
2204
2219
  // drag's own pushes must never change what the hand means — settled for a
@@ -2212,44 +2227,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2212
2227
  // A group never becomes a tab: over a container's strip it is over the
2213
2228
  // container's margin — a cell on the parent board, pushing with intent.
2214
2229
  const scaleY = clientPerWorld().y || 1;
2215
- let hit = stripUnder({ x: ev.world.x, y: ev.world.y, roots, held: (_c = (_b = g.strip) === null || _b === void 0 ? void 0 : _b.containerId) !== null && _c !== void 0 ? _c : null, stay: STRIP_STAY / scaleY, restFrames: rests });
2216
- if (!hit && prevWorld && !g.strip) {
2217
- // A HAND MOVES FASTER THAN A STRIP IS TALL. (Only for a hand ARRIVING:
2218
- // one already holding the strip leaves it by the stay, which reaches
2219
- // down and never up — the band above must stay reachable.) The strip is 30 px and a
2220
- // hand covers 40 to 80 px between events, so testing only where the
2221
- // pointer LANDS skips it — the user, coming down from above the panel:
2222
- // "it's not passing by the tab header, it drops directly to inside or
2223
- // outside." The segment it travelled is tested too, in steps of half a
2224
- // strip: crossing the rows and landing within one strip's height of
2225
- // them means the tabs. Flying far past them does not — a fast drag
2226
- // into the page must never snag on the header.
2227
- const dx = ev.world.x - prevWorld.x;
2228
- const dy = ev.world.y - prevWorld.y;
2229
- const n = Math.ceil(Math.hypot(dx, dy) / (TAB_STRIP_HEIGHT / 2));
2230
- let crossed = null;
2231
- for (let i = 1; i < n && !crossed; i++)
2232
- crossed = (_e = (_d = stripUnder({ x: prevWorld.x + (dx * i) / n, y: prevWorld.y + (dy * i) / n, roots, held: null, stay: 0, restFrames: rests })) === null || _d === void 0 ? void 0 : _d.containerId) !== null && _e !== void 0 ? _e : null;
2233
- if (crossed) {
2234
- const grp = diagram.getGroup(crossed);
2235
- const f = (_f = rests.get(crossed)) !== null && _f !== void 0 ? _f : (grp ? frameOfGroup(grp) : null);
2236
- if (f) {
2237
- const top = f.y;
2238
- const bottom = f.y + TAB_STRIP_HEIGHT;
2239
- // A CROSSING is the two events on OPPOSITE sides of the rows — a
2240
- // hand that swept along the tabs from beside the panel and ended
2241
- // above it did not cross them, it went past them. And the sides
2242
- // still take the corners (0.4.47): a hand landing in the outer
2243
- // fifth meant "after it", whatever it crossed on the way.
2244
- const through = (prevWorld.y < top && ev.world.y > bottom) || (prevWorld.y > bottom && ev.world.y < top);
2245
- const away = ev.world.y < top ? top - ev.world.y : ev.world.y > bottom ? ev.world.y - bottom : 0;
2246
- const rx = (ev.world.x - f.x) / Math.max(1, f.width);
2247
- const inSideBand = rx < BESIDE_BAND || rx > 1 - BESIDE_BAND;
2248
- if (through && !inSideBand && ev.world.x >= f.x && ev.world.x <= f.x + f.width && away <= TAB_STRIP_HEIGHT / scaleY)
2249
- hit = { containerId: crossed };
2250
- }
2251
- }
2252
- }
2230
+ // …and the segment the hand TRAVELLED, for a hand arriving faster than a strip is tall (0.4.67; `stripCrossing`).
2231
+ const hit = (_d = stripUnder({ x: ev.world.x, y: ev.world.y, roots, held: (_c = (_b = g.strip) === null || _b === void 0 ? void 0 : _b.containerId) !== null && _c !== void 0 ? _c : null, stay: STRIP_STAY / scaleY, restFrames: rests })) !== null && _d !== void 0 ? _d : (prevWorld && !g.strip ? stripCrossing({ prev: prevWorld, cur: ev.world, roots, restFrames: rests, stripHeight: TAB_STRIP_HEIGHT, band: BESIDE_BAND, reach: TAB_STRIP_HEIGHT / scaleY }) : null);
2253
2232
  if (hit) {
2254
2233
  // The SLOT is the painted strip's business — its tabs are laid out by
2255
2234
  // the browser. A container the gesture shifted sideways is painted
@@ -2270,7 +2249,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2270
2249
  strip,
2271
2250
  prev: beside
2272
2251
  ? { containerId: beside.id, side: beside.side, frame0: beside.frame0, vacated: cellToRect({ x: beside.vacated.x, y: beside.vacated.y, w: g.spans.w, h: g.spans.h }, frame(), geom(), rows()) }
2273
- : ((_h = (_g = g.leg) === null || _g === void 0 ? void 0 : _g.adopted.besideState()) !== null && _h !== void 0 ? _h : null), // a beside another board holds for the ghost, through its leg
2252
+ : ((_f = (_e = g.leg) === null || _e === void 0 ? void 0 : _e.adopted.besideState()) !== null && _f !== void 0 ? _f : null), // a beside another board holds for the ghost, through its leg
2274
2253
  maxDepth: nesting,
2275
2254
  ghostDepth: g.subject === 'group' ? 1 + levelsInside(g.id) : 0, // a group's widgets sit one board deeper than wherever it lands
2276
2255
  restFrames: rests,
@@ -2330,13 +2309,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2330
2309
  return deepest;
2331
2310
  };
2332
2311
  /** Client pixels per world unit — the camera's scale, measured the way the tear-out measures its bands. */
2333
- const clientPerWorld = () => {
2334
- const rect = api.container.getBoundingClientRect();
2335
- const toWorld = (cx, cy) => { var _a; 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 }; };
2336
- const o = toWorld(rect.left, rect.top);
2337
- const u = toWorld(rect.left + 100, rect.top + 100);
2338
- return { x: 100 / (u.x - o.x || 100), y: 100 / (u.y - o.y || 100) };
2339
- };
2312
+ const clientPerWorld = () => clientPerWorldOf(api);
2340
2313
  /** The groups this board sits in, all the way up: their bands never apply to a tile of this board. */
2341
2314
  const homeChain = () => {
2342
2315
  const out = new Set();
@@ -27,7 +27,7 @@ import type { DashboardGridApi, DashboardGridHandle, DashboardGridOptions } from
27
27
  import { type SplitNode } from './split-layout.js';
28
28
  /** Group metadata key the tree persists under. */
29
29
  export declare const SPLIT_TREE_KEY = "dashboardTree";
30
- export interface DashboardSplitOptions extends Pick<DashboardGridOptions, 'columns' | 'gap' | 'padding' | 'rtl' | 'fluid' | 'static' | 'dragHandle' | 'squeeze' | 'designHeight' | 'baseRowHeight' | 'dragOut' | 'removeZone' | 'onRemoveRequest' | 'onDropIn' | 'onGesture' | 'onSelect' | 'renderCaption' | 'onCaptionAction'> {
30
+ export interface DashboardSplitOptions extends Pick<DashboardGridOptions, 'columns' | 'gap' | 'padding' | 'rtl' | 'fluid' | 'static' | 'dragHandle' | 'squeeze' | 'designHeight' | 'baseRowHeight' | 'dragOut' | 'removeZone' | 'onRemoveRequest' | 'onDropIn' | 'tabDrop' | 'onMemberLeaving' | 'nesting' | 'onGesture' | 'onSelect' | 'renderCaption' | 'onCaptionAction'> {
31
31
  /** An authored tree. Default: the persisted one, else derived from the members' cells. */
32
32
  tree?: SplitNode | null;
33
33
  }
@@ -23,11 +23,13 @@
23
23
  * the authored size.
24
24
  */
25
25
  import { __awaiter } from "tslib";
26
- import { BESIDE_BAND, resolveTabZone } from './zones.js';
27
- import { BatchCommand, Command } from '@grafloria/engine';
26
+ import { BESIDE_BAND, resolveTabZone, resolve as resolveZone, stripCrossing, stripUnder } from './zones.js';
27
+ import { AddToGroupCommand, BatchCommand, Command, RemoveFromGroupCommand } from '@grafloria/engine';
28
28
  import { LiveRegionController, registerTool } from '@grafloria/renderer';
29
- import { anyEdge, clearOtherSelections, dragHandleSelector, gripHostOf, gripOf, normalizeDragHandle, ownsPress, parentPeerOf, pressOnDragHandle, registerBoardPeer, syncGrip, DRAG_HANDLE_CLASS, EDGE_GRIP } from './grid-binder.js';
30
- import { cellFromGridItem } from './grid-mapping.js';
29
+ import { anyEdge, clearOtherSelections, clientPerWorldOf, dragHandleSelector, gripHostOf, gripOf, normalizeDragHandle, ownsPress, parentPeerOf, peersOnCanvasOf, pressOnDragHandle, registerBoardPeer, syncGrip, zoneRootsOf, DRAG_HANDLE_CLASS, EDGE_GRIP, STRIP_STAY } from './grid-binder.js';
30
+ import { buildCommitCommands, cellFromGridItem } from './grid-mapping.js';
31
+ import { SequenceCommand } from './commit.js';
32
+ import { TAB_STRIP_HEIGHT } from './tabs.js';
31
33
  import { addSplitLeaf, cellsFromSplit, cloneSplit, dividersOf, groupRectsOf, insertSplitLeaf, moveSplitDivider, normalizeSplit, pathToLeaf, projectSplit, removeSplitLeaf, splitFromCells, splitLeaves, } from './split-layout.js';
32
34
  import { ensureDashboardKitStyles } from './styles.js';
33
35
  import { captionKey, captionOfGroup, captionPainted, paintCaptionBand, sectionCaptionReserve, sizeCaptionBand } from './caption.js';
@@ -509,6 +511,75 @@ export function bindDashboardSplit(api, group, options = {}) {
509
511
  const f = frame();
510
512
  return x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + f.height;
511
513
  };
514
+ // -- the tab-group zones on a split board (0.4.69) ----------------------------
515
+ const frameOfGroupW = (grp) => { var _a, _b, _c, _d; return ({ x: grp.position.x, y: grp.position.y, width: (_b = (_a = grp.size) === null || _a === void 0 ? void 0 : _a.width) !== null && _b !== void 0 ? _b : 0, height: (_d = (_c = grp.size) === null || _c === void 0 ? void 0 : _c.height) !== null && _d !== void 0 ? _d : 0 }); };
516
+ const EMPTY_SUBTREE = new Set();
517
+ /** This board's group and its ancestors: a container's band never applies to a widget that lives inside it. */
518
+ const homeChain = () => {
519
+ const out = new Set();
520
+ let cur = group;
521
+ for (let i = 0; cur && i < 32; i++) {
522
+ out.add(cur.id);
523
+ cur = cur.parentGroupId ? diagram.getGroup(cur.parentGroupId) : undefined;
524
+ }
525
+ return out;
526
+ };
527
+ /**
528
+ * The zone under the hand for a widget of this board — the SAME walk the
529
+ * grid board runs (zones.ts), over the same tree of boards: a strip slot
530
+ * (with the crossing rule for a hand arriving faster than a strip is
531
+ * tall), a band beside a container, a plain cell on the deepest board the
532
+ * pointer may enter, or off. Nothing on a split board is pushed, so no
533
+ * container is read at rest.
534
+ */
535
+ const tileZone = (g, ev, prev) => {
536
+ var _a, _b, _c, _d, _e, _f, _g, _h;
537
+ const roots = zoneRootsOf(peersOnCanvasOf(api.container), diagram);
538
+ let strip = null;
539
+ if (((_a = options.tabDrop) === null || _a === void 0 ? void 0 : _a.tabIndexAt) && !isStatic && g.kind === 'move' && g.node) {
540
+ const scaleY = clientPerWorldOf(api).y || 1;
541
+ const hit = (_d = stripUnder({ x: ev.world.x, y: ev.world.y, roots, held: (_c = (_b = g.strip) === null || _b === void 0 ? void 0 : _b.containerId) !== null && _c !== void 0 ? _c : null, stay: STRIP_STAY / scaleY })) !== null && _d !== void 0 ? _d : (prev && !g.strip ? stripCrossing({ prev, cur: ev.world, roots, stripHeight: TAB_STRIP_HEIGHT, band: BESIDE_BAND, reach: TAB_STRIP_HEIGHT / scaleY }) : null);
542
+ if (hit) {
543
+ const idx = options.tabDrop.tabIndexAt(hit.containerId, api.container.getBoundingClientRect().left + ev.screen.x);
544
+ if (idx !== null)
545
+ strip = { containerId: hit.containerId, index: idx };
546
+ }
547
+ }
548
+ return resolveZone({
549
+ x: ev.world.x,
550
+ y: ev.world.y,
551
+ roots,
552
+ strip,
553
+ prev: (_g = (_e = g.beside) !== null && _e !== void 0 ? _e : (_f = g.leg) === null || _f === void 0 ? void 0 : _f.adopted.besideState()) !== null && _g !== void 0 ? _g : null,
554
+ maxDepth: (_h = options.nesting) !== null && _h !== void 0 ? _h : 2,
555
+ ghostDepth: 0,
556
+ ghostSubtree: EMPTY_SUBTREE,
557
+ gap,
558
+ homeChain: homeChain(),
559
+ });
560
+ };
561
+ /** The widget's NATURAL size for a board that adopts it: its authored cell at this board's units, not the pane it was stretched over. */
562
+ const pxSizeOf = (g) => {
563
+ const cell = persistedCell(g.id);
564
+ const f = frame();
565
+ const colW = Math.max(1, (f.width - 2 * padding - (columns - 1) * gap) / columns);
566
+ if (cell)
567
+ return { width: Math.max(1, cell.w * (colW + gap) - gap), height: Math.max(1, cell.h * (baseRowHeight + gap) - gap) };
568
+ return g.node ? { width: g.node.size.width, height: g.node.size.height } : { width: colW, height: baseRowHeight };
569
+ };
570
+ const endStrip = (g) => {
571
+ var _a;
572
+ if (!g.strip)
573
+ return;
574
+ (_a = options.tabDrop) === null || _a === void 0 ? void 0 : _a.markDrop(null, null);
575
+ g.strip = null;
576
+ };
577
+ const endLeg = (g) => {
578
+ if (!g.leg)
579
+ return;
580
+ g.leg.adopted.abort();
581
+ g.leg = null;
582
+ };
512
583
  // -- a11y -------------------------------------------------------------------
513
584
  const nameOf = (id) => {
514
585
  var _a, _b, _c, _d;
@@ -543,6 +614,8 @@ export function bindDashboardSplit(api, group, options = {}) {
543
614
  (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id);
544
615
  };
545
616
  let selfPeerRef = null;
617
+ /** The previous move event's world point: the segment a hand travelled, for a strip it stepped over. */
618
+ let prevWorld = null;
546
619
  // Static boards let content be clicked — see grid-binder's staticGuard.
547
620
  const staticGuard = (e) => {
548
621
  var _a, _b, _c, _d;
@@ -679,6 +752,7 @@ export function bindDashboardSplit(api, group, options = {}) {
679
752
  const beginMoveVisuals = (g) => {
680
753
  var _a;
681
754
  g.started = true;
755
+ prevWorld = null;
682
756
  g.liveTree = removeSplitLeaf(g.startTree, g.id); // the siblings take the slot at once
683
757
  g.hostEl = hostOf(g.id);
684
758
  (_a = g.hostEl) === null || _a === void 0 ? void 0 : _a.classList.add('axdb-ghost');
@@ -686,7 +760,7 @@ export function bindDashboardSplit(api, group, options = {}) {
686
760
  api.render();
687
761
  };
688
762
  const onToolMove = (ev) => {
689
- var _a, _b;
763
+ var _a, _b, _c, _d, _e;
690
764
  const g = gesture;
691
765
  if (!g || g.kind === 'palette')
692
766
  return;
@@ -711,24 +785,83 @@ export function bindDashboardSplit(api, group, options = {}) {
711
785
  api.render();
712
786
  return;
713
787
  }
714
- // MOVE: the ghost follows the pointer; the target is the nearest edge under it.
788
+ // MOVE: the ghost follows the pointer. What the hand means is the ZONE
789
+ // WALK's answer first (0.4.69, the grid board's since 0.4.50): a tab
790
+ // container's strip makes the widget a tab, its page takes the widget
791
+ // through a leg, its outer fifth means a pane beside it — and only then
792
+ // the split board's own rule, the nearest edge of the pane under the
793
+ // pointer. Measured before this on the fluid demo in split mode: the
794
+ // header was never a tab target, the page never took a widget, and
795
+ // "above" claimed the upper half of the panel's body.
715
796
  if (g.node) {
716
797
  const x = ev.world.x - g.grab.dx;
717
798
  const y = ev.world.y - g.grab.dy;
718
799
  diagram.runSystemWrite(() => g.node.setPosition(x, y));
719
800
  }
720
801
  const inside = worldInsideBoard(ev.world.x, ev.world.y);
721
- const t = inside ? dropTargetAt(g.liveTree, ev.world.x, ev.world.y, g.id) : null;
802
+ const prev = prevWorld;
803
+ prevWorld = { x: ev.world.x, y: ev.world.y };
804
+ const z = tileZone(g, ev, prev);
805
+ let t = null;
806
+ if (z.kind === 'strip') {
807
+ // -- INTO A STRIP: the strip marks the slot; nothing else is painted.
808
+ endLeg(g);
809
+ g.beside = null;
810
+ if (!g.strip || g.strip.containerId !== z.containerId || g.strip.index !== z.index)
811
+ (_b = options.tabDrop) === null || _b === void 0 ? void 0 : _b.markDrop(z.containerId, z.index);
812
+ g.strip = { containerId: z.containerId, index: z.index };
813
+ }
814
+ else {
815
+ endStrip(g);
816
+ const peer = z.kind === 'plain' ? (_c = z.board.ref) !== null && _c !== void 0 ? _c : null : null;
817
+ if (z.kind === 'beside') {
818
+ // -- BESIDE a container: a pane on that side of it — the split board's own line.
819
+ endLeg(g);
820
+ const grp = diagram.getGroup(z.containerId);
821
+ const rect = (_d = rectsOf(g.liveTree).get(z.containerId)) !== null && _d !== void 0 ? _d : (grp ? frameOfGroupW(grp) : null);
822
+ if (rect) {
823
+ g.beside = z.kept && g.beside ? g.beside : { containerId: z.containerId, side: z.side, frame0: rect };
824
+ t = { id: z.containerId, side: z.side, rect };
825
+ }
826
+ }
827
+ else if (peer && peer !== selfPeerRef) {
828
+ // -- INTO a nested board (a page, a section) — or out onto the board
829
+ // that holds this one: that board holds the widget through a leg and
830
+ // paints its own placeholder. A board that refuses (full, fit) leaves
831
+ // the split rule to answer.
832
+ g.beside = null;
833
+ if (g.leg && g.leg.peer !== peer)
834
+ endLeg(g);
835
+ if (!g.leg) {
836
+ const adopted = peer.adopt({ id: g.id }, ev.world, pxSizeOf(g), {});
837
+ if (adopted)
838
+ g.leg = { peer, adopted };
839
+ }
840
+ if (g.leg)
841
+ g.leg.adopted.move(ev.world);
842
+ else
843
+ t = inside ? dropTargetAt(g.liveTree, ev.world.x, ev.world.y, g.id) : null;
844
+ }
845
+ else {
846
+ g.beside = null;
847
+ endLeg(g);
848
+ t = inside ? dropTargetAt(g.liveTree, ev.world.x, ev.world.y, g.id) : null;
849
+ }
850
+ }
722
851
  g.target = t ? targetOf(t) : null;
723
852
  showInsertion(t ? insertionRect(t.rect, t.side) : null);
724
853
  const out = !inside &&
854
+ !t &&
855
+ !g.strip &&
856
+ !g.leg &&
725
857
  options.dragOut === 'remove' &&
726
858
  (!options.removeZone || options.removeZone({ x: ev.screen.x, y: ev.screen.y }, { x: ev.world.x, y: ev.world.y }));
727
859
  g.out = out;
728
- (_b = g.hostEl) === null || _b === void 0 ? void 0 : _b.classList.toggle('axdb-out', out);
860
+ (_e = g.hostEl) === null || _e === void 0 ? void 0 : _e.classList.toggle('axdb-out', out);
729
861
  api.render();
730
862
  };
731
863
  const onToolUp = () => {
864
+ var _a, _b, _c, _d;
732
865
  const g = gesture;
733
866
  if (!g || g.kind === 'palette')
734
867
  return;
@@ -747,6 +880,89 @@ export function bindDashboardSplit(api, group, options = {}) {
747
880
  fire({ type: changed ? 'commit' : 'cancel', kind: 'resize', nodeId: g.id, changed });
748
881
  return;
749
882
  }
883
+ const paint = () => {
884
+ if (disposed)
885
+ return;
886
+ project(readTree());
887
+ api.renderNow();
888
+ };
889
+ const settle = () => {
890
+ paint();
891
+ void Promise.resolve(pendingBatch).then(paint, () => undefined);
892
+ };
893
+ if (g.strip && options.tabDrop) {
894
+ // -- INTO A STRIP: the widget becomes a new tab of that container and
895
+ // its pane leaves this tree — the tree-without-it rides as the source's
896
+ // "displaced", the way a grid's survivors do (0.4.69). One history step.
897
+ const target = g.strip;
898
+ g.strip = null;
899
+ options.tabDrop.markDrop(null, null);
900
+ const without = new SetSplitTreeCommand(group.id, g.startTree, normalizeSplit(g.liveTree));
901
+ const cmds = options.tabDrop.dropIntoStrip(g.id, target.containerId, target.index, group.id, [without]);
902
+ if (cmds.length === 0) {
903
+ project(g.startTree);
904
+ api.renderNow();
905
+ fire({ type: 'cancel', kind: 'move', nodeId: g.id, changed: false });
906
+ return;
907
+ }
908
+ execute('Move widget into a new tab', cmds);
909
+ settle();
910
+ live.announce(`${nameOf(g.id)} became a tab of ${nameOf(target.containerId)}`, 'polite', true);
911
+ fire({ type: 'commit', kind: 'move', nodeId: g.id, changed: true });
912
+ return;
913
+ }
914
+ if (g.leg) {
915
+ // -- ONTO ANOTHER BOARD (a page, a section, the board holding this one):
916
+ // one batch across both — this tree without the pane, the target's
917
+ // displaced tiles, the membership, the widget's cell and frame there.
918
+ const leg = g.leg;
919
+ g.leg = null;
920
+ const fin = leg.adopted.finalize();
921
+ if (!fin) {
922
+ project(g.startTree);
923
+ api.renderNow();
924
+ fire({ type: 'cancel', kind: 'move', nodeId: g.id, changed: false });
925
+ return;
926
+ }
927
+ const node = g.node;
928
+ if (node) {
929
+ diagram.runSystemWrite(() => {
930
+ var _a;
931
+ node.setPosition(fin.rect.x, fin.rect.y);
932
+ node.setSize(fin.rect.width, fin.rect.height, (_a = node.size.depth) !== null && _a !== void 0 ? _a : 0);
933
+ });
934
+ }
935
+ const geom0 = (_a = g.startGeom) !== null && _a !== void 0 ? _a : { pos: { x: fin.rect.x, y: fin.rect.y }, size: { width: fin.rect.width, height: fin.rect.height } };
936
+ const own = buildCommitCommands([
937
+ {
938
+ id: g.id,
939
+ locked: false,
940
+ isGroup: false,
941
+ cellBefore: (_b = persistedCell(g.id)) !== null && _b !== void 0 ? _b : fin.cell,
942
+ cellAfter: fin.cell,
943
+ posBefore: geom0.pos,
944
+ posAfter: { x: fin.rect.x, y: fin.rect.y },
945
+ sizeBefore: geom0.size,
946
+ sizeAfter: { width: fin.rect.width, height: fin.rect.height },
947
+ },
948
+ ]);
949
+ const crossing = [
950
+ new SetSplitTreeCommand(group.id, g.startTree, normalizeSplit(g.liveTree)),
951
+ ...fin.commands,
952
+ new RemoveFromGroupCommand(group.id, g.id),
953
+ new AddToGroupCommand(leg.adopted.groupId, g.id),
954
+ ...own,
955
+ ];
956
+ // …and whatever follows a member out of this board: an emptied split
957
+ // page closes — inside one sequence with the move, or the batch could
958
+ // never undo (its group is gone).
959
+ const leaving = (_d = (_c = options.onMemberLeaving) === null || _c === void 0 ? void 0 : _c.call(options, g.id)) !== null && _d !== void 0 ? _d : [];
960
+ execute('Move widget', leaving.length > 0 ? [new SequenceCommand('Move widget', [...crossing, ...leaving])] : crossing);
961
+ settle();
962
+ live.announce(`${nameOf(g.id)} moved into ${nameOf(leg.adopted.groupId)}`, 'polite', true);
963
+ fire({ type: 'commit', kind: 'move', nodeId: g.id, changed: true });
964
+ return;
965
+ }
750
966
  if (g.out && options.onRemoveRequest) {
751
967
  const without = g.liveTree;
752
968
  void options.onRemoveRequest(g.id, [new SetSplitTreeCommand(group.id, g.startTree, normalizeSplit(without))]);
@@ -779,6 +995,8 @@ export function bindDashboardSplit(api, group, options = {}) {
779
995
  return;
780
996
  gesture = null;
781
997
  teardownGesture(g);
998
+ endStrip(g);
999
+ endLeg(g);
782
1000
  if (g.kind === 'palette') {
783
1001
  api.renderNow();
784
1002
  fire({ type: 'cancel', kind: 'palette', nodeId: g.id, changed: false });
@@ -835,6 +1053,10 @@ export function bindDashboardSplit(api, group, options = {}) {
835
1053
  out: false,
836
1054
  chip: null,
837
1055
  esc: null,
1056
+ strip: null,
1057
+ leg: null,
1058
+ beside: null,
1059
+ startGeom: null,
838
1060
  hostEl: null,
839
1061
  pointerId: typeof PointerEvent !== 'undefined' && ev.source instanceof PointerEvent ? ev.source.pointerId : null,
840
1062
  };
@@ -934,6 +1156,10 @@ export function bindDashboardSplit(api, group, options = {}) {
934
1156
  out: false,
935
1157
  chip: null,
936
1158
  esc: null,
1159
+ strip: null,
1160
+ leg: null,
1161
+ beside: null,
1162
+ startGeom: { pos: { x: node.position.x, y: node.position.y }, size: { width: node.size.width, height: node.size.height } },
937
1163
  hostEl: null,
938
1164
  pointerId: typeof PointerEvent !== 'undefined' && ev.source instanceof PointerEvent ? ev.source.pointerId : null,
939
1165
  };
@@ -1314,6 +1540,10 @@ export function bindDashboardSplit(api, group, options = {}) {
1314
1540
  out: false,
1315
1541
  chip,
1316
1542
  esc: null,
1543
+ strip: null,
1544
+ leg: null,
1545
+ beside: null,
1546
+ startGeom: null,
1317
1547
  hostEl: null,
1318
1548
  pointerId: null,
1319
1549
  };
@@ -190,6 +190,44 @@ export interface StripProbe {
190
190
  export declare function stripUnder(p: StripProbe): {
191
191
  containerId: string;
192
192
  } | null;
193
+ export interface CrossingProbe {
194
+ /** The previous pointer event's point and this one's, in world units. */
195
+ prev: {
196
+ x: number;
197
+ y: number;
198
+ };
199
+ cur: {
200
+ x: number;
201
+ y: number;
202
+ };
203
+ roots: ZoneBoard[];
204
+ /** Containers the gesture has displaced, at the frames they rest in. */
205
+ restFrames?: ReadonlyMap<string, ZoneRect>;
206
+ /** A strip's rows, in world units. */
207
+ stripHeight: number;
208
+ /** The outer fraction of a container that is its side bands: those keep their corners. Default BESIDE_BAND. */
209
+ band?: number;
210
+ /** How far past the rows a landing may be and still mean the tabs, in world units. Default: one strip's height. */
211
+ reach?: number;
212
+ }
213
+ /**
214
+ * A HAND MOVES FASTER THAN A STRIP IS TALL. The strip is 30 px and a hand
215
+ * covers 40 to 80 px between pointer events, so testing only where the pointer
216
+ * LANDS skips it — the user, coming down from above the panel: "it's not
217
+ * passing by the tab header, it drops directly to inside or outside." The
218
+ * segment the hand travelled is tested too, in steps of half a strip: a
219
+ * CROSSING — the two events on opposite sides of a strip's rows — that lands
220
+ * within one strip's height of them means the tabs. Flying far past them does
221
+ * not (a fast drag into the page must never snag on the header), a sweep
222
+ * ALONG the rows is not a crossing, and the sides still take the corners
223
+ * (0.4.47): a hand landing in the outer fifth meant "after it", whatever it
224
+ * crossed on the way. Only for a hand ARRIVING: one already holding a strip
225
+ * leaves it by the stay. The grid board had this inline (0.4.67); the split
226
+ * board asks the same question, so it is one helper (0.4.69).
227
+ */
228
+ export declare function stripCrossing(p: CrossingProbe): {
229
+ containerId: string;
230
+ } | null;
193
231
  export declare function resolve(input: ResolveInput): Zone;
194
232
  /** A join target as the walk sees it: another tab container, its frame anchored by the binder while the pointer is inside it. */
195
233
  export interface TabTarget {
@@ -125,6 +125,45 @@ export function stripUnder(p) {
125
125
  visit(r, 0, 0);
126
126
  return bestId === null ? null : { containerId: bestId };
127
127
  }
128
+ /**
129
+ * A HAND MOVES FASTER THAN A STRIP IS TALL. The strip is 30 px and a hand
130
+ * covers 40 to 80 px between pointer events, so testing only where the pointer
131
+ * LANDS skips it — the user, coming down from above the panel: "it's not
132
+ * passing by the tab header, it drops directly to inside or outside." The
133
+ * segment the hand travelled is tested too, in steps of half a strip: a
134
+ * CROSSING — the two events on opposite sides of a strip's rows — that lands
135
+ * within one strip's height of them means the tabs. Flying far past them does
136
+ * not (a fast drag into the page must never snag on the header), a sweep
137
+ * ALONG the rows is not a crossing, and the sides still take the corners
138
+ * (0.4.47): a hand landing in the outer fifth meant "after it", whatever it
139
+ * crossed on the way. Only for a hand ARRIVING: one already holding a strip
140
+ * leaves it by the stay. The grid board had this inline (0.4.67); the split
141
+ * board asks the same question, so it is one helper (0.4.69).
142
+ */
143
+ export function stripCrossing(p) {
144
+ var _a, _b, _c, _d, _e, _f, _g;
145
+ const dx = p.cur.x - p.prev.x;
146
+ const dy = p.cur.y - p.prev.y;
147
+ const n = Math.ceil(Math.hypot(dx, dy) / Math.max(1, p.stripHeight / 2));
148
+ let crossed = null;
149
+ for (let i = 1; i < n && !crossed; i++) {
150
+ crossed = (_b = (_a = stripUnder({ x: p.prev.x + (dx * i) / n, y: p.prev.y + (dy * i) / n, roots: p.roots, held: null, stay: 0, restFrames: p.restFrames })) === null || _a === void 0 ? void 0 : _a.containerId) !== null && _b !== void 0 ? _b : null;
151
+ }
152
+ if (!crossed)
153
+ return null;
154
+ const f = (_d = (_c = p.restFrames) === null || _c === void 0 ? void 0 : _c.get(crossed)) !== null && _d !== void 0 ? _d : (_e = containerOf(p.roots, crossed)) === null || _e === void 0 ? void 0 : _e.frame;
155
+ if (!f)
156
+ return null;
157
+ const top = f.y;
158
+ const bottom = f.y + p.stripHeight;
159
+ const through = (p.prev.y < top && p.cur.y > bottom) || (p.prev.y > bottom && p.cur.y < top);
160
+ const away = p.cur.y < top ? top - p.cur.y : p.cur.y > bottom ? p.cur.y - bottom : 0;
161
+ const band = (_f = p.band) !== null && _f !== void 0 ? _f : BESIDE_BAND;
162
+ const rx = (p.cur.x - f.x) / Math.max(1, f.width);
163
+ const inSideBand = rx < band || rx > 1 - band;
164
+ const inside = p.cur.x >= f.x && p.cur.x <= f.x + f.width;
165
+ return through && !inSideBand && inside && away <= ((_g = p.reach) !== null && _g !== void 0 ? _g : p.stripHeight) ? { containerId: crossed } : null;
166
+ }
128
167
  /**
129
168
  * The band a point is in for a TILE drag: the sides and the bottom as
130
169
  * `bandOf` gives them, and "top" ONLY from the band hanging above the frame.