@grafloria/element 0.4.18 → 0.4.20

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.18",
3
+ "version": "0.4.20",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "types": "./src/index.d.ts",
@@ -252,6 +252,12 @@ export interface DashboardOptions {
252
252
  * hook). Omit for a titled placeholder frame.
253
253
  */
254
254
  renderWidget?: (widget: DashboardWidgetSpec, host: HTMLElement) => void;
255
+ /**
256
+ * The selection changed on a board: the selected id — a widget or a SECTION
257
+ * (container) — or undefined when cleared, and the view it belongs to. A
258
+ * press on a section's empty band selects the section.
259
+ */
260
+ onSelect?: (id: string | undefined, viewId: string) => void;
255
261
  /** Fires after any committed gesture, with the view whose layout changed. */
256
262
  onLayoutChange?: (viewId: string, widgets: DashboardWidgetSpec[]) => void;
257
263
  /** Extra binder options, merged last (escape hatch to the layer below). */
@@ -1264,7 +1264,7 @@ export function dashboard(options) {
1264
1264
  if (e.type === 'commit')
1265
1265
  reportChanged();
1266
1266
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1267
- } });
1267
+ }, onSelect: (id) => { var _a; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, v.id); } });
1268
1268
  if (viewLayout === 'split') {
1269
1269
  return bindDashboardSplit(a, g, Object.assign(Object.assign(Object.assign({}, common), { columns: (_h = v.columns) !== null && _h !== void 0 ? _h : columns, baseRowHeight: rowHeight, designHeight: viewH(v) }), (v.tree !== undefined ? { tree: v.tree } : {})));
1270
1270
  }
@@ -1282,7 +1282,7 @@ export function dashboard(options) {
1282
1282
  if (e.type === 'commit')
1283
1283
  reportChanged();
1284
1284
  (_b = (_a = options.binder) === null || _a === void 0 ? void 0 : _a.onGesture) === null || _b === void 0 ? void 0 : _b.call(_a, e);
1285
- } });
1285
+ }, onSelect: (id) => { var _a, _b; return (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id, (_b = ctx.viewOfBoard.get(w.id)) !== null && _b !== void 0 ? _b : ctx.active); } });
1286
1286
  if (((_h = ctx.layoutOf.get(w.id)) !== null && _h !== void 0 ? _h : w.layout) === 'split') {
1287
1287
  // A splitter tree covering the pane; the pane's frame is the parent's
1288
1288
  // slab, so no design height of its own.
@@ -196,6 +196,8 @@ export interface DashboardGridOptions {
196
196
  changed: boolean;
197
197
  }) => void;
198
198
  /** Inject the hover-revealed corner resize handle into member hosts (default true). */
199
+ /** The selection on this board changed: the selected member — a widget or a SECTION (container) — or undefined. */
200
+ onSelect?: (id: string | undefined) => void;
199
201
  resizeHandles?: boolean;
200
202
  /**
201
203
  * FLUID board: the group's frame follows the CANVAS CONTAINER — width
@@ -436,6 +438,20 @@ interface BinderPeer {
436
438
  * boards after three clicks).
437
439
  */
438
440
  clearSelection?(): void;
441
+ /** A nested board's live row bound — what its slab must never shrink below. */
442
+ innerRows?(): number;
443
+ /**
444
+ * SECTION PRESSES. A press on a section's empty band lands on the NESTED
445
+ * board's tool (it claims its own frame), which knows nothing to select;
446
+ * the section is a member of the PARENT. The child asks the parent to
447
+ * select it and, on an edge or the corner handle, to run the section
448
+ * resize — and forwards the rest of the pointer sequence.
449
+ */
450
+ selectMember?(id: string): void;
451
+ beginSlabResize?(id: string, edges: ResizeEdges, ev: ToolPointerEvent): boolean;
452
+ slabMove?(ev: ToolPointerEvent): void;
453
+ slabUp?(): void;
454
+ slabCancel?(): void;
439
455
  /** The member's current cell in this board (undefined when absent). */
440
456
  memberCell(id: string): CellRect | undefined;
441
457
  /**
@@ -501,4 +517,10 @@ interface AdoptedLeg {
501
517
  export declare function clearOtherSelections(container: HTMLElement, self: BinderPeer | null): void;
502
518
  export declare function registerBoardPeer(container: HTMLElement, peer: BinderPeer): () => void;
503
519
  export type { BinderPeer };
520
+ interface ResizeEdges {
521
+ n: boolean;
522
+ e: boolean;
523
+ s: boolean;
524
+ w: boolean;
525
+ }
504
526
  export declare function bindDashboardGrid(api: DashboardGridApi, group: GroupModel, options?: DashboardGridOptions): DashboardGridHandle;
@@ -453,7 +453,15 @@ export function bindDashboardGrid(api, group, options = {}) {
453
453
  designHeight: sizing === 'fit' ? frame().height : designH,
454
454
  rtl,
455
455
  });
456
- const rows = () => Math.max(1, engine.rows());
456
+ /**
457
+ * The rows the board is laid out in. A BOUNDED board (a section) keeps its
458
+ * bound's rows even when its content ends higher — empty rows stay empty
459
+ * rows, as in gridstack — instead of stretching the rest to fill the slab:
460
+ * that stretch made a tile pulled shorter shrink FASTER than the pointer
461
+ * (each row it gave up made the remaining rows taller, so the same pixel
462
+ * height quantised to fewer rows on the next move — kit lab L37).
463
+ */
464
+ const rows = () => Math.max(1, engine.rows(), maxRows !== null && maxRows !== void 0 ? maxRows : 0);
457
465
  const htmlLayer = () => api.container.querySelector('.grafloria-html-layer');
458
466
  const hostOf = (id) => {
459
467
  const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"');
@@ -465,6 +473,19 @@ export function bindDashboardGrid(api, group, options = {}) {
465
473
  /** The engine item a member should enter as (cells from GridItemConfig /
466
474
  * group metadata; spans falling back to metadata columnSpan/rowSpan; last
467
475
  * resort: adopt from the current pixel geometry). */
476
+ /**
477
+ * A SECTION's floor: its nested board's live rows (the peer), else the rows
478
+ * it was mounted with — a section resized by hand never squeezes its
479
+ * children below their cells.
480
+ */
481
+ const innerRowsOf = (id) => {
482
+ var _a, _b, _c, _d;
483
+ for (const p of (_a = BOARD_REGISTRY.get(api.container)) !== null && _a !== void 0 ? _a : [])
484
+ if (p.group.id === id && p.innerRows)
485
+ return Math.max(1, p.innerRows());
486
+ const meta = (_c = (_b = diagram.getGroup(id)) === null || _b === void 0 ? void 0 : _b.getMetadata) === null || _c === void 0 ? void 0 : _c.call(_b, 'containerWidget');
487
+ return Math.max(1, (_d = meta === null || meta === void 0 ? void 0 : meta.maxRows) !== null && _d !== void 0 ? _d : 1);
488
+ };
468
489
  const itemFor = (id) => {
469
490
  var _a, _b, _c, _d, _e, _f, _g;
470
491
  const node = diagram.getNode(id);
@@ -708,23 +729,27 @@ export function bindDashboardGrid(api, group, options = {}) {
708
729
  */
709
730
  let selectedId;
710
731
  const selectWidget = (id) => {
732
+ var _a;
711
733
  if (id !== undefined)
712
734
  clearOtherSelections(api.container, selfPeerRef);
713
735
  if (id === selectedId)
714
736
  return;
715
737
  selectedId = id;
716
738
  syncA11y();
739
+ syncSlabs();
740
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id);
717
741
  };
718
742
  /** Set once the peer object exists (below); `selectWidget` runs before that only on boot. */
719
743
  let selfPeerRef = null;
720
744
  const syncA11y = (only) => {
721
- var _a, _b;
745
+ var _a, _b, _c;
722
746
  if (disposed)
723
747
  return;
724
748
  const members = [...((_a = group.members) !== null && _a !== void 0 ? _a : [])].filter((id) => !!diagram.getNode(id));
725
749
  if (focusedId && !members.includes(focusedId))
726
750
  focusedId = undefined;
727
- if (selectedId && !members.includes(selectedId))
751
+ // A selected SECTION is a group member, not a node: keep it as long as it is a member.
752
+ if (selectedId && !members.includes(selectedId) && !(((_b = group.members) === null || _b === void 0 ? void 0 : _b.has(selectedId)) && memberEntity(selectedId)))
728
753
  selectedId = undefined;
729
754
  const stop = focusedId !== null && focusedId !== void 0 ? focusedId : members[0];
730
755
  for (const id of members) {
@@ -738,7 +763,7 @@ export function bindDashboardGrid(api, group, options = {}) {
738
763
  const bits = [nameOf(node)];
739
764
  if (cell)
740
765
  bits.push(describeCell(cell));
741
- if (((_b = node.state) === null || _b === void 0 ? void 0 : _b.locked) === true)
766
+ if (((_c = node.state) === null || _c === void 0 ? void 0 : _c.locked) === true)
742
767
  bits.push('pinned');
743
768
  host.setAttribute('role', 'group');
744
769
  host.setAttribute('aria-roledescription', 'dashboard widget');
@@ -759,6 +784,7 @@ export function bindDashboardGrid(api, group, options = {}) {
759
784
  if (disposed)
760
785
  return;
761
786
  ensureStaticGuard();
787
+ syncSlabs();
762
788
  const grip = gripOf(dragHandle);
763
789
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
764
790
  if (only && !only.has(id))
@@ -975,6 +1001,7 @@ export function bindDashboardGrid(api, group, options = {}) {
975
1001
  finally {
976
1002
  writing = false;
977
1003
  }
1004
+ syncSlabs();
978
1005
  };
979
1006
  const applyColumns = (n, layout) => {
980
1007
  var _a;
@@ -1044,6 +1071,80 @@ export function bindDashboardGrid(api, group, options = {}) {
1044
1071
  const band = rowHeightFor(geom(), rows()) + gap;
1045
1072
  return x >= f.x && x <= f.x + f.width && y >= f.y && y <= f.y + boardVisualHeight() + band;
1046
1073
  };
1074
+ /** The member SECTION whose frame holds the world point, if any. */
1075
+ const memberGroupAt = (x, y) => {
1076
+ var _a;
1077
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
1078
+ const grp = diagram.getGroup(id);
1079
+ if (!grp)
1080
+ continue;
1081
+ const p = grp.position;
1082
+ const s = sizeOf(grp);
1083
+ if (x >= p.x && x <= p.x + s.width && y >= p.y && y <= p.y + s.height)
1084
+ return id;
1085
+ }
1086
+ return null;
1087
+ };
1088
+ /** Which of a section frame's edges a world point is within EDGE_GRIP of. */
1089
+ const slabEdgesNear = (grp, x, y) => {
1090
+ const p = grp.position;
1091
+ const s = sizeOf(grp);
1092
+ return { n: y - p.y <= EDGE_GRIP, s: p.y + s.height - y <= EDGE_GRIP, w: x - p.x <= EDGE_GRIP, e: p.x + s.width - x <= EDGE_GRIP };
1093
+ };
1094
+ /**
1095
+ * SECTION CHROME. A section (member group) paints no card of its own, so it
1096
+ * had nothing to press: a click on its empty band cleared the selection and
1097
+ * its frame had no handle and no edge — a section with many children could
1098
+ * not be selected at all, and could only be resized by pulling a child
1099
+ * (Quantia, Groups page). Every section gets a pointer-transparent overlay
1100
+ * in the HTML layer that wears the selection ring and, while selected, the
1101
+ * corner handle; its frame edges answer the resize cursor.
1102
+ */
1103
+ const slabEls = new Map();
1104
+ let slabLayer = null;
1105
+ const syncSlabs = () => {
1106
+ var _a, _b;
1107
+ if (disposed)
1108
+ return;
1109
+ const layer = (slabLayer === null || slabLayer === void 0 ? void 0 : slabLayer.isConnected) ? slabLayer : (slabLayer = htmlLayer());
1110
+ if (!layer)
1111
+ return;
1112
+ const seen = new Set();
1113
+ for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
1114
+ const grp = diagram.getGroup(id);
1115
+ if (!grp || diagram.getNode(id))
1116
+ continue;
1117
+ seen.add(id);
1118
+ let el = slabEls.get(id);
1119
+ if (!el || el.parentElement !== layer) {
1120
+ el === null || el === void 0 ? void 0 : el.remove();
1121
+ el = document.createElement('div');
1122
+ el.className = 'axdb-slab';
1123
+ el.setAttribute('data-slab-id', id);
1124
+ const rs = document.createElement('div');
1125
+ rs.className = 'axdb-rs';
1126
+ rs.setAttribute('title', 'Resize section');
1127
+ el.appendChild(rs);
1128
+ layer.appendChild(el);
1129
+ slabEls.set(id, el);
1130
+ }
1131
+ const p = grp.position;
1132
+ const sz = sizeOf(grp);
1133
+ el.style.left = `${p.x}px`;
1134
+ el.style.top = `${p.y}px`;
1135
+ el.style.width = `${sz.width}px`;
1136
+ el.style.height = `${sz.height}px`;
1137
+ el.classList.toggle('axdb-slab--selected', selectedId === id);
1138
+ el.classList.toggle('axdb-slab--static', isStatic);
1139
+ (_b = el.querySelector(':scope > .axdb-rs')) === null || _b === void 0 ? void 0 : _b.classList.toggle('axdb-rs--rtl', rtl);
1140
+ }
1141
+ for (const [id, el] of slabEls) {
1142
+ if (!seen.has(id)) {
1143
+ el.remove();
1144
+ slabEls.delete(id);
1145
+ }
1146
+ }
1147
+ };
1047
1148
  const insideMemberGroupFrame = (x, y) => {
1048
1149
  var _a;
1049
1150
  for (const id of (_a = group.members) !== null && _a !== void 0 ? _a : []) {
@@ -1057,6 +1158,131 @@ export function bindDashboardGrid(api, group, options = {}) {
1057
1158
  }
1058
1159
  return false;
1059
1160
  };
1161
+ let slabGesture = null;
1162
+ /** The PARENT running a resize of OUR section from a press this tool claimed. */
1163
+ let forwardSlab = null;
1164
+ const frameOfGroup = (grp) => ({ x: grp.position.x, y: grp.position.y, width: sizeOf(grp).width, height: sizeOf(grp).height });
1165
+ const beginSlabResize = (id, edges, ev) => {
1166
+ const grp = diagram.getGroup(id);
1167
+ const it = engine.getItem(id);
1168
+ if (!grp || !it || gesture || slabGesture)
1169
+ return;
1170
+ engine.beginGesture();
1171
+ const snap = snapshotAll();
1172
+ slabGesture = {
1173
+ id,
1174
+ edges,
1175
+ pointerId: typeof PointerEvent !== 'undefined' && ev.source instanceof PointerEvent ? ev.source.pointerId : null,
1176
+ started: false,
1177
+ downScreen: { x: ev.screen.x, y: ev.screen.y },
1178
+ startCells: snap.cells,
1179
+ startGeom: snap.geoms,
1180
+ cellBefore: { x: it.x, y: it.y, w: it.w, h: it.h },
1181
+ frameBefore: frameOfGroup(grp),
1182
+ grab: {
1183
+ dx: edges.e ? grp.position.x + sizeOf(grp).width - ev.world.x : edges.w ? grp.position.x - ev.world.x : 0,
1184
+ dy: edges.s ? grp.position.y + sizeOf(grp).height - ev.world.y : edges.n ? grp.position.y - ev.world.y : 0,
1185
+ },
1186
+ };
1187
+ capturePointer(slabGesture.pointerId);
1188
+ api.container.style.cursor = cursorFor(edges);
1189
+ };
1190
+ const slabMove = (ev) => {
1191
+ const g = slabGesture;
1192
+ if (!g)
1193
+ return;
1194
+ if (!g.started) {
1195
+ if (Math.abs(ev.screen.x - g.downScreen.x) + Math.abs(ev.screen.y - g.downScreen.y) < DRAG_THRESHOLD)
1196
+ return;
1197
+ g.started = true;
1198
+ armGlide();
1199
+ }
1200
+ const it = engine.getItem(g.id);
1201
+ if (!it)
1202
+ return;
1203
+ const f = frame();
1204
+ const gg = geom();
1205
+ const cu = columnUnitFor(gg, f.width);
1206
+ const rh = rowHeightFor(gg, rows());
1207
+ // The grid line nearest the pointer, in cells (mirrored on RTL).
1208
+ const colAt = (wx) => Math.round((rtl ? f.x + f.width - padding - wx : wx - f.x - padding) / (cu + gap));
1209
+ const rowAt = (wy) => Math.round((wy - f.y - padding) / (rh + gap));
1210
+ let { x, y, w, h } = it;
1211
+ const px = ev.world.x + g.grab.dx;
1212
+ const py = ev.world.y + g.grab.dy;
1213
+ if (g.edges.e)
1214
+ w = Math.max(1, colAt(px) - x);
1215
+ if (g.edges.s)
1216
+ h = Math.max(1, rowAt(py) - y);
1217
+ if (g.edges.w) {
1218
+ const nx = Math.max(0, Math.min(x + w - 1, colAt(px)));
1219
+ w = x + w - nx;
1220
+ x = nx;
1221
+ }
1222
+ if (g.edges.n) {
1223
+ const ny = Math.max(0, Math.min(y + h - 1, rowAt(py)));
1224
+ h = y + h - ny;
1225
+ y = ny;
1226
+ }
1227
+ w = Math.max(1, Math.min(w, columns - x));
1228
+ // Never below the children's rows (the nested board's live bound).
1229
+ const floor = innerRowsOf(g.id);
1230
+ if (h < floor) {
1231
+ if (g.edges.n)
1232
+ y = y + h - floor;
1233
+ h = floor;
1234
+ }
1235
+ let changed = false;
1236
+ if (x !== it.x || y !== it.y)
1237
+ changed = engine.moveCheck(g.id, x, y, { gate: false }).changed || changed;
1238
+ if (w !== it.w || h !== it.h)
1239
+ changed = engine.resizeCheck(g.id, w, h).changed || changed;
1240
+ if (changed)
1241
+ project();
1242
+ };
1243
+ const slabUp = () => {
1244
+ var _a;
1245
+ const g = slabGesture;
1246
+ if (!g)
1247
+ return;
1248
+ slabGesture = null;
1249
+ releasePointer(g.pointerId);
1250
+ api.container.style.cursor = '';
1251
+ if (!g.started) {
1252
+ engine.endGesture();
1253
+ return;
1254
+ }
1255
+ engine.endGesture();
1256
+ project();
1257
+ const it = engine.getItem(g.id);
1258
+ const grp = diagram.getGroup(g.id);
1259
+ const commands = buildCommitCommands(deltasSince(g.startCells, g.startGeom, g.id));
1260
+ const b = g.cellBefore;
1261
+ if (it && grp && (b.x !== it.x || b.y !== it.y || b.w !== it.w || b.h !== it.h)) {
1262
+ commands.push(new SetGroupCellCommand(g.id, b, { x: it.x, y: it.y, w: it.w, h: it.h }, g.frameBefore, frameOfGroup(grp)));
1263
+ }
1264
+ const changed = execute('Resize section', commands);
1265
+ disarmGlideSoon();
1266
+ syncHandles();
1267
+ api.renderNow();
1268
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: 'resize', nodeId: g.id, changed });
1269
+ };
1270
+ const slabCancel = () => {
1271
+ var _a;
1272
+ const g = slabGesture;
1273
+ if (!g)
1274
+ return;
1275
+ slabGesture = null;
1276
+ releasePointer(g.pointerId);
1277
+ api.container.style.cursor = '';
1278
+ if (g.started)
1279
+ engine.cancelGesture();
1280
+ else
1281
+ engine.endGesture();
1282
+ project();
1283
+ disarmGlideSoon();
1284
+ (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'cancel', kind: 'resize', nodeId: g.id, changed: false });
1285
+ };
1060
1286
  const capturePointer = (pointerId) => {
1061
1287
  var _a, _b;
1062
1288
  if (pointerId === null)
@@ -1163,7 +1389,13 @@ export function bindDashboardGrid(api, group, options = {}) {
1163
1389
  (_a = options.onGesture) === null || _a === void 0 ? void 0 : _a.call(options, { type: 'commit', kind: g.kind, nodeId: g.id, changed });
1164
1390
  };
1165
1391
  const cancelActiveGesture = (notify = true) => {
1166
- var _a, _b, _c;
1392
+ var _a, _b, _c, _d;
1393
+ if (forwardSlab) {
1394
+ (_a = forwardSlab.slabCancel) === null || _a === void 0 ? void 0 : _a.call(forwardSlab);
1395
+ forwardSlab = null;
1396
+ }
1397
+ if (slabGesture)
1398
+ slabCancel();
1167
1399
  const g = gesture;
1168
1400
  if (!g)
1169
1401
  return;
@@ -1184,7 +1416,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1184
1416
  engine.endGesture();
1185
1417
  const items = [];
1186
1418
  for (const [id, c] of g.startCells) {
1187
- const lockedNode = ((_b = (_a = diagram.getNode(id)) === null || _a === void 0 ? void 0 : _a.state) === null || _b === void 0 ? void 0 : _b.locked) === true;
1419
+ const lockedNode = ((_c = (_b = diagram.getNode(id)) === null || _b === void 0 ? void 0 : _b.state) === null || _c === void 0 ? void 0 : _c.locked) === true;
1188
1420
  items.push(Object.assign(Object.assign({ id }, c), { locked: lockedNode || isGroupMember(id) }));
1189
1421
  }
1190
1422
  engine = engineFrom(items);
@@ -1232,7 +1464,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1232
1464
  enforceBoardHeight();
1233
1465
  api.renderNow();
1234
1466
  if (notify) {
1235
- (_c = options.onGesture) === null || _c === void 0 ? void 0 : _c.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1467
+ (_d = options.onGesture) === null || _d === void 0 ? void 0 : _d.call(options, { type: 'cancel', kind: g.kind, nodeId: g.id, changed: false });
1236
1468
  }
1237
1469
  };
1238
1470
  /** Centre a w×h-span tile's top-left under the cursor, in world px. */
@@ -1468,6 +1700,10 @@ export function bindDashboardGrid(api, group, options = {}) {
1468
1700
  touched = true;
1469
1701
  }
1470
1702
  }
1703
+ else if (!E.s) {
1704
+ // A top-edge pull needs the row ABOVE: the anchored resize path
1705
+ // decides it (refused when that row is taken) — never the section.
1706
+ }
1471
1707
  else if (!engine.resizeCheck(g.id, pulled.w, pulled.h + 1).changed) {
1472
1708
  const res = parent.resizeMemberBy(group.id, +1);
1473
1709
  if (res.changed) {
@@ -1496,15 +1732,22 @@ export function bindDashboardGrid(api, group, options = {}) {
1496
1732
  touched = true;
1497
1733
  }
1498
1734
  }
1499
- else if (pulled.h > 1) {
1500
- engine.resizeCheck(g.id, pulled.w, pulled.h - 1);
1735
+ else if (pulled.h > 1 && E.s) {
1736
+ // Shrink to the row the pointer asks for (one row per move lagged
1737
+ // a fast pull and overshot a slow one), then hand every row the
1738
+ // layout no longer needs back to the board.
1739
+ engine.resizeCheck(g.id, pulled.w, Math.max(1, wantRows));
1501
1740
  const floor = Math.max(designRows, extentOf(engine.getItems()));
1502
- if (slabRows > floor) {
1741
+ let slab = slabRows;
1742
+ let bound = inner;
1743
+ while (slab > floor) {
1503
1744
  const res = parent.resizeMemberBy(group.id, -1);
1504
- if (res.changed) {
1505
- record(res, -1);
1506
- setInnerRows(inner - 1);
1507
- }
1745
+ if (!res.changed)
1746
+ break;
1747
+ record(res, -1);
1748
+ slab -= 1;
1749
+ bound -= 1;
1750
+ setInnerRows(bound);
1508
1751
  }
1509
1752
  touched = true;
1510
1753
  }
@@ -1573,9 +1816,23 @@ export function bindDashboardGrid(api, group, options = {}) {
1573
1816
  const moves = tx !== itemNow.x || ty !== itemNow.y;
1574
1817
  const growing = span.w > itemNow.w || span.h > itemNow.h;
1575
1818
  let changed = false;
1576
- if (moves && growing)
1577
- changed = engine.moveCheck(g.id, tx, ty, { gate: false }).changed || changed;
1578
- changed = engine.resizeCheck(g.id, span.w, span.h).changed || changed;
1819
+ // A north or west pull grows by MOVING the anchor first; when that move
1820
+ // is refused (the row above is taken) the growth is refused with it —
1821
+ // the first version fell through to a plain resize and grew the tile at
1822
+ // the OPPOSITE edge, so pulling a control's top edge up made it taller
1823
+ // at the bottom and pushed its section a row (kit lab L37).
1824
+ let anchored = true;
1825
+ if (moves && growing) {
1826
+ // The anchor may move only into FREE cells: a move that pushes would
1827
+ // relocate the tile (a top-edge pull sent a control to row 0 and its
1828
+ // neighbours below it) instead of growing it.
1829
+ const probe = { x: tx, y: ty, w: itemNow.w, h: itemNow.h };
1830
+ const blocked = engine.getItems().some((o) => o.id !== g.id && o.x < probe.x + probe.w && probe.x < o.x + o.w && o.y < probe.y + probe.h && probe.y < o.y + o.h);
1831
+ anchored = !blocked && engine.moveCheck(g.id, tx, ty, { gate: false }).changed;
1832
+ changed = anchored || changed;
1833
+ }
1834
+ if (anchored)
1835
+ changed = engine.resizeCheck(g.id, span.w, span.h).changed || changed;
1579
1836
  if (moves && !growing)
1580
1837
  changed = engine.moveCheck(g.id, tx, ty, { gate: false }).changed || changed;
1581
1838
  if (changed)
@@ -1802,11 +2059,31 @@ export function bindDashboardGrid(api, group, options = {}) {
1802
2059
  const selfPeer = {
1803
2060
  group,
1804
2061
  clearSelection: () => {
2062
+ var _a;
1805
2063
  if (selectedId === undefined)
1806
2064
  return;
1807
2065
  selectedId = undefined;
1808
2066
  syncA11y();
2067
+ syncSlabs();
2068
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, undefined);
2069
+ },
2070
+ innerRows: () => maxRows !== null && maxRows !== void 0 ? maxRows : rows(),
2071
+ selectMember: (id) => {
2072
+ var _a;
2073
+ if (((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id)) {
2074
+ selectWidget(id);
2075
+ api.render();
2076
+ }
1809
2077
  },
2078
+ beginSlabResize: (id, edges, ev) => {
2079
+ if (isStatic)
2080
+ return false;
2081
+ beginSlabResize(id, edges, ev);
2082
+ return (slabGesture === null || slabGesture === void 0 ? void 0 : slabGesture.id) === id;
2083
+ },
2084
+ slabMove: (ev) => slabMove(ev),
2085
+ slabUp: () => slabUp(),
2086
+ slabCancel: () => slabCancel(),
1810
2087
  hasItem: (id) => !!engine.getItem(id),
1811
2088
  memberCell: (id) => {
1812
2089
  const it = engine.getItem(id);
@@ -1853,7 +2130,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1853
2130
  var _a, _b;
1854
2131
  if (disposed)
1855
2132
  return false;
1856
- if (gesture)
2133
+ if (gesture || slabGesture || forwardSlab)
1857
2134
  return true; // own the rest of an in-flight gesture
1858
2135
  if (!ownsPress(api.container, diagram, ev, hit))
1859
2136
  return false;
@@ -1882,7 +2159,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1882
2159
  return insideMemberGroupFrame(ev.world.x, ev.world.y) || worldInsideBoard(ev.world.x, ev.world.y);
1883
2160
  },
1884
2161
  onPointerDown(ev, hit) {
1885
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
2162
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
1886
2163
  if (gesture)
1887
2164
  return; // mid-palette
1888
2165
  const target = ((_b = (_a = ev.source) === null || _a === void 0 ? void 0 : _a.target) !== null && _b !== void 0 ? _b : null);
@@ -1892,10 +2169,47 @@ export function bindDashboardGrid(api, group, options = {}) {
1892
2169
  const gripHost = gripHostOf(target);
1893
2170
  const gripId = (_c = gripHost === null || gripHost === void 0 ? void 0 : gripHost.getAttribute('data-node-id')) !== null && _c !== void 0 ? _c : null;
1894
2171
  const onGrip = !!gripId && ((_d = group.members) !== null && _d !== void 0 ? _d : new Set()).has(gripId);
1895
- if (!hit.node && !onGrip) {
2172
+ // A SECTION's corner handle sits on top of whatever tile shares that
2173
+ // corner; the DOM target names the section, the hit test the tile.
2174
+ const sectionHandle = (_e = target === null || target === void 0 ? void 0 : target.closest) === null || _e === void 0 ? void 0 : _e.call(target, '.axdb-slab > .axdb-rs');
2175
+ if ((!hit.node && !onGrip) || sectionHandle) {
2176
+ // A press on a SECTION — its empty band, its corner handle or its
2177
+ // frame edge — selects the section; the handle or an edge resizes it.
2178
+ const slabHandle = (_f = target === null || target === void 0 ? void 0 : target.closest) === null || _f === void 0 ? void 0 : _f.call(target, '.axdb-slab > .axdb-rs');
2179
+ const slabId = (_h = (_g = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _g === void 0 ? void 0 : _g.getAttribute('data-slab-id')) !== null && _h !== void 0 ? _h : memberGroupAt(ev.world.x, ev.world.y);
2180
+ const grp = slabId && ((_j = group.members) !== null && _j !== void 0 ? _j : new Set()).has(slabId) ? diagram.getGroup(slabId) : undefined;
2181
+ if (slabId && grp) {
2182
+ selectWidget(slabId);
2183
+ api.render();
2184
+ if (isStatic)
2185
+ return;
2186
+ const edges = slabHandle
2187
+ ? rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false }
2188
+ : slabEdgesNear(grp, ev.world.x, ev.world.y);
2189
+ if (anyEdge(edges))
2190
+ beginSlabResize(slabId, edges, ev);
2191
+ return;
2192
+ }
2193
+ // OUR OWN empty band, and we are a section of a parent board: the
2194
+ // press selects the section there, and its edge or corner handle
2195
+ // starts the section resize, which the parent runs while this tool
2196
+ // forwards the pointer sequence.
2197
+ const parent = parentPeer();
2198
+ if ((parent === null || parent === void 0 ? void 0 : parent.selectMember) && worldInsideBoard(ev.world.x, ev.world.y)) {
2199
+ const ownHandle = ((_k = slabHandle === null || slabHandle === void 0 ? void 0 : slabHandle.parentElement) === null || _k === void 0 ? void 0 : _k.getAttribute('data-slab-id')) === group.id;
2200
+ parent.selectMember(group.id);
2201
+ if (!isStatic && parent.beginSlabResize) {
2202
+ const edges = ownHandle
2203
+ ? rtl ? { n: false, e: false, s: true, w: true } : { n: false, e: true, s: true, w: false }
2204
+ : slabEdgesNear(group, ev.world.x, ev.world.y);
2205
+ if (anyEdge(edges) && parent.beginSlabResize(group.id, edges, ev))
2206
+ forwardSlab = parent;
2207
+ }
2208
+ return;
2209
+ }
1896
2210
  // The board's own empty area: a void click. Nothing to drag, and the
1897
2211
  // selection clears exactly as a click outside any board would.
1898
- (_f = (_e = diagram).clearSelection) === null || _f === void 0 ? void 0 : _f.call(_e);
2212
+ (_m = (_l = diagram).clearSelection) === null || _m === void 0 ? void 0 : _m.call(_l);
1899
2213
  selectWidget(undefined);
1900
2214
  api.render();
1901
2215
  return;
@@ -1904,17 +2218,17 @@ export function bindDashboardGrid(api, group, options = {}) {
1904
2218
  if (!node)
1905
2219
  return;
1906
2220
  selectWidget(node.id); // a press selects, whether or not it starts a gesture
1907
- if (((_g = node.state) === null || _g === void 0 ? void 0 : _g.locked) === true)
2221
+ if (((_o = node.state) === null || _o === void 0 ? void 0 : _o.locked) === true)
1908
2222
  return; // pinned: refuse; click still focuses
1909
2223
  if (isStatic)
1910
2224
  return; // a static board: claimed and deadened, click still focuses
1911
2225
  // Which edges did the press take? The corner handle names its own (s+e,
1912
2226
  // or s+w on RTL); a bare press within EDGE_GRIP of the tile's border
1913
2227
  // takes that border; anywhere else is a move. A grip press is a move.
1914
- const onHandle = !!((_h = target === null || target === void 0 ? void 0 : target.closest) === null || _h === void 0 ? void 0 : _h.call(target, '.axdb-rs'));
2228
+ const onHandle = !!((_p = target === null || target === void 0 ? void 0 : target.closest) === null || _p === void 0 ? void 0 : _p.call(target, '.axdb-rs'));
1915
2229
  const hostEl = hostOf(node.id);
1916
- const resizable = ((_j = node.getMetadata) === null || _j === void 0 ? void 0 : _j.call(node, 'widgetResizable')) !== false;
1917
- const movable = ((_k = node.getMetadata) === null || _k === void 0 ? void 0 : _k.call(node, 'widgetMovable')) !== false;
2230
+ const resizable = ((_q = node.getMetadata) === null || _q === void 0 ? void 0 : _q.call(node, 'widgetResizable')) !== false;
2231
+ const movable = ((_r = node.getMetadata) === null || _r === void 0 ? void 0 : _r.call(node, 'widgetMovable')) !== false;
1918
2232
  // `ev.screen` is ELEMENT-LOCAL px; host rects are in client px. Compare
1919
2233
  // like with like — the source event's clientX/Y when there is one, else
1920
2234
  // the container's origin plus the local offset.
@@ -1962,7 +2276,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1962
2276
  startSize: { width: node.size.width, height: node.size.height },
1963
2277
  startPos: { x: node.position.x, y: node.position.y },
1964
2278
  edges,
1965
- spans: { w: (_l = it === null || it === void 0 ? void 0 : it.w) !== null && _l !== void 0 ? _l : 1, h: (_m = it === null || it === void 0 ? void 0 : it.h) !== null && _m !== void 0 ? _m : 1 },
2279
+ spans: { w: (_s = it === null || it === void 0 ? void 0 : it.w) !== null && _s !== void 0 ? _s : 1, h: (_t = it === null || it === void 0 ? void 0 : it.h) !== null && _t !== void 0 ? _t : 1 },
1966
2280
  removedFromBoard: false,
1967
2281
  leg: null,
1968
2282
  lastWorld: null,
@@ -1973,10 +2287,24 @@ export function bindDashboardGrid(api, group, options = {}) {
1973
2287
  };
1974
2288
  },
1975
2289
  onPointerMove(ev) {
1976
- onToolMove(ev);
2290
+ var _a;
2291
+ if (forwardSlab)
2292
+ (_a = forwardSlab.slabMove) === null || _a === void 0 ? void 0 : _a.call(forwardSlab, ev);
2293
+ else if (slabGesture)
2294
+ slabMove(ev);
2295
+ else
2296
+ onToolMove(ev);
1977
2297
  },
1978
2298
  onPointerUp() {
1979
- onToolUp();
2299
+ var _a;
2300
+ if (forwardSlab) {
2301
+ (_a = forwardSlab.slabUp) === null || _a === void 0 ? void 0 : _a.call(forwardSlab);
2302
+ forwardSlab = null;
2303
+ }
2304
+ else if (slabGesture)
2305
+ slabUp();
2306
+ else
2307
+ onToolUp();
1980
2308
  },
1981
2309
  onCancel() {
1982
2310
  cancelActiveGesture();
@@ -1990,7 +2318,7 @@ export function bindDashboardGrid(api, group, options = {}) {
1990
2318
  */
1991
2319
  let hoverHost = null;
1992
2320
  const onHover = (e) => {
1993
- var _a, _b, _c, _d, _e, _f, _g;
2321
+ var _a, _b, _c, _d, _e, _f, _g, _h;
1994
2322
  if (disposed || gesture)
1995
2323
  return;
1996
2324
  // The event may target the host, its content, or (when a host's content
@@ -2014,13 +2342,23 @@ export function bindDashboardGrid(api, group, options = {}) {
2014
2342
  hoverHost.removeAttribute('data-axdb-edge'); // the affordance follows the pointer off a tile
2015
2343
  }
2016
2344
  hoverHost = host;
2017
- if (!host)
2345
+ if (!host) {
2346
+ // No tile under the pointer: a SECTION's frame edge still says resize.
2347
+ const wpt = ((_d = api.viewport) === null || _d === void 0 ? void 0 : _d.clientToWorld) ? api.viewport.clientToWorld(e.clientX, e.clientY, api.container.getBoundingClientRect()) : null;
2348
+ const sid = wpt ? memberGroupAt(wpt.x, wpt.y) : null;
2349
+ const grp = sid ? diagram.getGroup(sid) : undefined;
2350
+ const c = grp && wpt && !isStatic ? cursorFor(slabEdgesNear(grp, wpt.x, wpt.y)) : '';
2351
+ if (!slabGesture)
2352
+ api.container.style.cursor = c;
2018
2353
  return;
2019
- const id = (_d = host.getAttribute('data-node-id')) !== null && _d !== void 0 ? _d : '';
2020
- if (!((_e = group.members) !== null && _e !== void 0 ? _e : new Set()).has(id))
2354
+ }
2355
+ if (!slabGesture && api.container.style.cursor)
2356
+ api.container.style.cursor = '';
2357
+ const id = (_e = host.getAttribute('data-node-id')) !== null && _e !== void 0 ? _e : '';
2358
+ if (!((_f = group.members) !== null && _f !== void 0 ? _f : new Set()).has(id))
2021
2359
  return;
2022
2360
  const node = diagram.getNode(id);
2023
- const resizable = !!node && !isStatic && ((_f = node.state) === null || _f === void 0 ? void 0 : _f.locked) !== true && ((_g = node.getMetadata) === null || _g === void 0 ? void 0 : _g.call(node, 'widgetResizable')) !== false;
2361
+ const resizable = !!node && !isStatic && ((_g = node.state) === null || _g === void 0 ? void 0 : _g.locked) !== true && ((_h = node.getMetadata) === null || _h === void 0 ? void 0 : _h.call(node, 'widgetResizable')) !== false;
2024
2362
  const cursor = resizable ? cursorFor(edgesNear(host, e.clientX, e.clientY)) : '';
2025
2363
  // The affordance rides on an ATTRIBUTE, not the inline style: a repaint
2026
2364
  // rewrites the host's style and used to clear the cursor mid-hover, and
@@ -2354,6 +2692,17 @@ export function bindDashboardGrid(api, group, options = {}) {
2354
2692
  writing = false;
2355
2693
  }
2356
2694
  const commands = buildCommitCommands(deltasSince(snap.cells, snap.geoms));
2695
+ if (isGroupMember(id)) {
2696
+ // A SECTION: its cell lives in group metadata and its frame is written
2697
+ // by project(); the node commands above skip groups (D5 of the review).
2698
+ const it = engine.getItem(id);
2699
+ const grp = diagram.getGroup(id);
2700
+ const cb = snap.cells.get(id);
2701
+ const gb = snap.geoms.get(id);
2702
+ if (it && grp && cb && gb && (cb.x !== it.x || cb.y !== it.y || cb.w !== it.w || cb.h !== it.h)) {
2703
+ commands.push(new SetGroupCellCommand(id, cb, { x: it.x, y: it.y, w: it.w, h: it.h }, { x: gb.pos.x, y: gb.pos.y, width: gb.size.width, height: gb.size.height }, frameOfGroup(grp)));
2704
+ }
2705
+ }
2357
2706
  engine.endGesture();
2358
2707
  disarmGlideSoon();
2359
2708
  enforceBoardHeight();
@@ -2452,7 +2801,7 @@ export function bindDashboardGrid(api, group, options = {}) {
2452
2801
  var _a;
2453
2802
  if (disposed)
2454
2803
  return false;
2455
- if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !diagram.getNode(id)))
2804
+ if (id !== undefined && (!((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id) || !memberEntity(id)))
2456
2805
  return false;
2457
2806
  selectWidget(id);
2458
2807
  return true;
@@ -2581,7 +2930,8 @@ export function bindDashboardGrid(api, group, options = {}) {
2581
2930
  return programmatic('Move widget', id, () => engine.moveCheck(id, x, y).changed);
2582
2931
  },
2583
2932
  resizeTo(id, w, h) {
2584
- return programmatic('Resize widget', id, () => engine.resizeCheck(id, w, h).changed);
2933
+ const hh = isGroupMember(id) ? Math.max(h, innerRowsOf(id)) : h; // a section: never below its children
2934
+ return programmatic('Resize widget', id, () => engine.resizeCheck(id, w, hh).changed);
2585
2935
  },
2586
2936
  beginPaletteDrag,
2587
2937
  dispose() {
@@ -2608,6 +2958,9 @@ export function bindDashboardGrid(api, group, options = {}) {
2608
2958
  placeholder = null;
2609
2959
  if (glideTimer)
2610
2960
  clearTimeout(glideTimer);
2961
+ for (const el of slabEls.values())
2962
+ el.remove();
2963
+ slabEls.clear();
2611
2964
  // A rebind inside the 60 ms window (a layout switch right after an undo)
2612
2965
  // disposed this binder with the timer pending — the host kept its
2613
2966
  // lifted ghost for good (visual gate, nested-containers ⑤).
@@ -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'> {
30
+ export interface DashboardSplitOptions extends Pick<DashboardGridOptions, 'columns' | 'gap' | 'padding' | 'rtl' | 'fluid' | 'static' | 'dragHandle' | 'squeeze' | 'designHeight' | 'baseRowHeight' | 'dragOut' | 'removeZone' | 'onRemoveRequest' | 'onDropIn' | 'onGesture' | 'onSelect'> {
31
31
  /** An authored tree. Default: the persisted one, else derived from the members' cells. */
32
32
  tree?: SplitNode | null;
33
33
  }
@@ -372,12 +372,14 @@ export function bindDashboardSplit(api, group, options = {}) {
372
372
  /** The selected widget (see grid-binder): stamped `axdb-selected`, shows the grip. */
373
373
  let selectedId;
374
374
  const selectWidget = (id) => {
375
+ var _a;
375
376
  if (id !== undefined)
376
377
  clearOtherSelections(api.container, selfPeerRef);
377
378
  if (id === selectedId)
378
379
  return;
379
380
  selectedId = id;
380
381
  syncA11y();
382
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, id);
381
383
  };
382
384
  let selfPeerRef = null;
383
385
  // Static boards let content be clicked — see grid-binder's staticGuard.
@@ -1193,10 +1195,12 @@ export function bindDashboardSplit(api, group, options = {}) {
1193
1195
  const selfPeer = {
1194
1196
  group,
1195
1197
  clearSelection: () => {
1198
+ var _a;
1196
1199
  if (selectedId === undefined)
1197
1200
  return;
1198
1201
  selectedId = undefined;
1199
1202
  syncA11y();
1203
+ (_a = options.onSelect) === null || _a === void 0 ? void 0 : _a.call(options, undefined);
1200
1204
  },
1201
1205
  hasItem: (id) => { var _a; return ((_a = group.members) !== null && _a !== void 0 ? _a : new Set()).has(id); },
1202
1206
  memberCell: (id) => handle.cellOf(id),
@@ -68,7 +68,7 @@ const CSS = `
68
68
  }
69
69
 
70
70
  /* ===== corner resize handle (hover-revealed, radius-matched) ===== */
71
- .grafloria-node-host > .axdb-rs {
71
+ :is(.grafloria-node-host, .axdb-slab) > .axdb-rs {
72
72
  position: absolute;
73
73
  right: 0;
74
74
  bottom: 0;
@@ -88,7 +88,7 @@ const CSS = `
88
88
  @media (hover: none) { .grafloria-node-host > .axdb-rs { opacity: .8; } }
89
89
  .grafloria-node-host > .axdb-rs:hover { border-color: #3b52d9; }
90
90
  /* RTL boards grow leftwards, so the grab corner mirrors with them. */
91
- .grafloria-node-host > .axdb-rs.axdb-rs--rtl {
91
+ :is(.grafloria-node-host, .axdb-slab) > .axdb-rs.axdb-rs--rtl {
92
92
  right: auto;
93
93
  left: 0;
94
94
  cursor: nesw-resize;
@@ -355,6 +355,16 @@ const CSS = `
355
355
  .axdb-tier-2 .axdb-xt, .axdb-tier-2 .axdb-vt,
356
356
  .axdb-lg--off { display: none; }
357
357
 
358
+ /* SECTION CHROME: a pointer-transparent overlay on every member group. It
359
+ wears the selection ring and, while selected, the corner handle. */
360
+ .grafloria-html-layer > .axdb-slab { position: absolute; pointer-events: none; border-radius: var(--axdb-rs-radius, 3px); z-index: 4; }
361
+ /* Selected, the overlay rises above the tiles so ITS corner handle wins a
362
+ corner it shares with a child's; unselected, its handle takes no presses. */
363
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected { z-index: 6; box-shadow: 0 0 0 1.5px var(--axdb-accent-ring, rgba(59, 82, 217, .55)); }
364
+ .grafloria-html-layer > .axdb-slab > .axdb-rs { pointer-events: none; opacity: 0; }
365
+ .grafloria-html-layer > .axdb-slab.axdb-slab--selected > .axdb-rs { pointer-events: auto; opacity: 1; }
366
+ .grafloria-html-layer > .axdb-slab.axdb-slab--static > .axdb-rs { display: none; }
367
+
358
368
  /* legend chips, shared by line and donut */
359
369
  .axdb-lg { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 9px; }
360
370
  .axdb-lg--col { flex-direction: column; flex-wrap: nowrap; gap: 6px; margin-top: 0; }