@canvas-harness/core 0.0.2 → 0.0.3

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/dist/index.d.cts CHANGED
@@ -227,6 +227,14 @@ type Scene = {
227
227
  groups: Record<GroupId, Group>;
228
228
  camera: CameraState;
229
229
  selection: (NodeId | EdgeId)[];
230
+ /**
231
+ * Presentation order for frame-typed nodes. Auto-maintained by the
232
+ * store on `node.add` / `node.remove`; explicitly mutated via
233
+ * `setFrameOrder` (which emits a `frame.reorder` op). Optional for
234
+ * backward compat — missing on older saved scenes is treated as [].
235
+ * See ARCHITECTURE.md §3.7 frames.
236
+ */
237
+ frameOrder?: NodeId[];
230
238
  };
231
239
  /**
232
240
  * On-the-wire serialized form. Arrays gzip smaller and have predictable iteration order.
@@ -239,6 +247,7 @@ type SerializedScene = {
239
247
  groups: Group[];
240
248
  camera: CameraState;
241
249
  selection: (NodeId | EdgeId)[];
250
+ frameOrder?: NodeId[];
242
251
  };
243
252
 
244
253
  /**
@@ -276,6 +285,10 @@ type Op = {
276
285
  } | {
277
286
  type: 'group.remove';
278
287
  group: Group;
288
+ } | {
289
+ type: 'frame.reorder';
290
+ ids: NodeId[];
291
+ prev: NodeId[];
279
292
  };
280
293
  /**
281
294
  * A batch is the atomic unit of mutation (also the unit of undo/redo and sync).
@@ -1644,6 +1657,26 @@ interface CanvasStore {
1644
1657
  getEdgeCount(): number;
1645
1658
  /** O(1) count. */
1646
1659
  getGroupCount(): number;
1660
+ /**
1661
+ * Returns frame nodes (`type === 'frame'`) in presentation order.
1662
+ * Order is auto-maintained on add/remove and can be explicitly
1663
+ * mutated via {@link CanvasStore.setFrameOrder}. Used by present
1664
+ * mode + export to drive slide sequencing.
1665
+ */
1666
+ getFrames(): Node[];
1667
+ /**
1668
+ * Replace the presentation order of frames. `ids` must be a
1669
+ * permutation of the current frame ids (any unknown ids are
1670
+ * dropped; missing frames are appended to preserve invariants).
1671
+ * Emits a `frame.reorder` op; undoable, syncs over collab.
1672
+ */
1673
+ setFrameOrder(ids: NodeId[]): void;
1674
+ /**
1675
+ * Geometric containment query: returns all non-frame nodes whose
1676
+ * AABB is fully inside the given frame's AABB. Used to compute
1677
+ * "what's on this slide". Cheap — backed by the spatial index.
1678
+ */
1679
+ getNodesInFrame(id: NodeId): Node[];
1647
1680
  /**
1648
1681
  * Spatial query — ids of nodes + edges that intersect a rect or
1649
1682
  * contain a point. Backed by a uniform grid for sub-millisecond
@@ -2055,6 +2088,12 @@ type Renderer = {
2055
2088
  setBackground(bg: CanvasBackground | undefined): void;
2056
2089
  /** Update the selection chrome color. Triggers an interactive repaint. */
2057
2090
  setSelectionColor(color: string): void;
2091
+ /**
2092
+ * Toggle frame-node paint. Use during a presentation flow to drop
2093
+ * the slide border + label so only the frame contents are visible.
2094
+ * Triggers a static repaint.
2095
+ */
2096
+ setHideFrames(hidden: boolean): void;
2058
2097
  /** Per-frame timing (FPS, lastMs, avgMs, frames). */
2059
2098
  stats(): FrameStats;
2060
2099
  /** Number of items the most recent paint actually drew. */
package/dist/index.d.ts CHANGED
@@ -227,6 +227,14 @@ type Scene = {
227
227
  groups: Record<GroupId, Group>;
228
228
  camera: CameraState;
229
229
  selection: (NodeId | EdgeId)[];
230
+ /**
231
+ * Presentation order for frame-typed nodes. Auto-maintained by the
232
+ * store on `node.add` / `node.remove`; explicitly mutated via
233
+ * `setFrameOrder` (which emits a `frame.reorder` op). Optional for
234
+ * backward compat — missing on older saved scenes is treated as [].
235
+ * See ARCHITECTURE.md §3.7 frames.
236
+ */
237
+ frameOrder?: NodeId[];
230
238
  };
231
239
  /**
232
240
  * On-the-wire serialized form. Arrays gzip smaller and have predictable iteration order.
@@ -239,6 +247,7 @@ type SerializedScene = {
239
247
  groups: Group[];
240
248
  camera: CameraState;
241
249
  selection: (NodeId | EdgeId)[];
250
+ frameOrder?: NodeId[];
242
251
  };
243
252
 
244
253
  /**
@@ -276,6 +285,10 @@ type Op = {
276
285
  } | {
277
286
  type: 'group.remove';
278
287
  group: Group;
288
+ } | {
289
+ type: 'frame.reorder';
290
+ ids: NodeId[];
291
+ prev: NodeId[];
279
292
  };
280
293
  /**
281
294
  * A batch is the atomic unit of mutation (also the unit of undo/redo and sync).
@@ -1644,6 +1657,26 @@ interface CanvasStore {
1644
1657
  getEdgeCount(): number;
1645
1658
  /** O(1) count. */
1646
1659
  getGroupCount(): number;
1660
+ /**
1661
+ * Returns frame nodes (`type === 'frame'`) in presentation order.
1662
+ * Order is auto-maintained on add/remove and can be explicitly
1663
+ * mutated via {@link CanvasStore.setFrameOrder}. Used by present
1664
+ * mode + export to drive slide sequencing.
1665
+ */
1666
+ getFrames(): Node[];
1667
+ /**
1668
+ * Replace the presentation order of frames. `ids` must be a
1669
+ * permutation of the current frame ids (any unknown ids are
1670
+ * dropped; missing frames are appended to preserve invariants).
1671
+ * Emits a `frame.reorder` op; undoable, syncs over collab.
1672
+ */
1673
+ setFrameOrder(ids: NodeId[]): void;
1674
+ /**
1675
+ * Geometric containment query: returns all non-frame nodes whose
1676
+ * AABB is fully inside the given frame's AABB. Used to compute
1677
+ * "what's on this slide". Cheap — backed by the spatial index.
1678
+ */
1679
+ getNodesInFrame(id: NodeId): Node[];
1647
1680
  /**
1648
1681
  * Spatial query — ids of nodes + edges that intersect a rect or
1649
1682
  * contain a point. Backed by a uniform grid for sub-millisecond
@@ -2055,6 +2088,12 @@ type Renderer = {
2055
2088
  setBackground(bg: CanvasBackground | undefined): void;
2056
2089
  /** Update the selection chrome color. Triggers an interactive repaint. */
2057
2090
  setSelectionColor(color: string): void;
2091
+ /**
2092
+ * Toggle frame-node paint. Use during a presentation flow to drop
2093
+ * the slide border + label so only the frame contents are visible.
2094
+ * Triggers a static repaint.
2095
+ */
2096
+ setHideFrames(hidden: boolean): void;
2058
2097
  /** Per-frame timing (FPS, lastMs, avgMs, frames). */
2059
2098
  stats(): FrameStats;
2060
2099
  /** Number of items the most recent paint actually drew. */
package/dist/index.js CHANGED
@@ -3131,6 +3131,8 @@ var inverseOp = (op) => {
3131
3131
  return { type: "group.remove", group: op.group };
3132
3132
  case "group.remove":
3133
3133
  return { type: "group.upsert", group: op.group };
3134
+ case "frame.reorder":
3135
+ return { type: "frame.reorder", ids: op.prev, prev: op.ids };
3134
3136
  }
3135
3137
  };
3136
3138
  var inverseBatch = (batch) => {
@@ -3198,6 +3200,7 @@ var createCanvasStore = (opts = {}) => {
3198
3200
  const groupIdsAtom = atom("groupIds", []);
3199
3201
  const cameraAtom = atom("camera", initial.camera);
3200
3202
  const selectionAtom = atom("selection", initial.selection);
3203
+ const frameOrderAtom = atom("frameOrder", initial.frameOrder ?? []);
3201
3204
  const interactionAtom = atom("interaction", idleInteractionState());
3202
3205
  const localPresenceAtom = atom("presence", emptyPresenceState(clientId));
3203
3206
  const remotePresence = /* @__PURE__ */ new Map();
@@ -3304,6 +3307,9 @@ var createCanvasStore = (opts = {}) => {
3304
3307
  nodeAtoms.set(op.node.id, a);
3305
3308
  nodeIdsAtom.update((ids) => [...ids, op.node.id]);
3306
3309
  reindexNode(op.node);
3310
+ if (op.node.type === "frame") {
3311
+ frameOrderAtom.update((ids) => ids.includes(op.node.id) ? ids : [...ids, op.node.id]);
3312
+ }
3307
3313
  break;
3308
3314
  }
3309
3315
  case "node.update": {
@@ -3328,6 +3334,9 @@ var createCanvasStore = (opts = {}) => {
3328
3334
  nodeIdsAtom.update((ids) => ids.filter((x) => x !== id));
3329
3335
  unindexNode(id);
3330
3336
  incidentEdges.delete(id);
3337
+ if (op.node.type === "frame") {
3338
+ frameOrderAtom.update((ids) => ids.filter((x) => x !== id));
3339
+ }
3331
3340
  break;
3332
3341
  }
3333
3342
  case "edge.add": {
@@ -3377,6 +3386,10 @@ var createCanvasStore = (opts = {}) => {
3377
3386
  groupIdsAtom.update((ids) => ids.filter((x) => x !== id));
3378
3387
  break;
3379
3388
  }
3389
+ case "frame.reorder": {
3390
+ frameOrderAtom.set([...op.ids]);
3391
+ break;
3392
+ }
3380
3393
  }
3381
3394
  };
3382
3395
  const enqueueOp = (op) => {
@@ -3399,6 +3412,7 @@ var createCanvasStore = (opts = {}) => {
3399
3412
  return prev;
3400
3413
  };
3401
3414
  const populateInitial = (scene) => {
3415
+ const seededFrameOrder = [];
3402
3416
  for (const id of Object.keys(scene.nodes)) {
3403
3417
  const node = scene.nodes[id];
3404
3418
  if (!node) continue;
@@ -3406,7 +3420,9 @@ var createCanvasStore = (opts = {}) => {
3406
3420
  nodeAtoms.set(node.id, a);
3407
3421
  nodeIdsAtom.update((ids) => [...ids, node.id]);
3408
3422
  reindexNode(node);
3423
+ if (node.type === "frame") seededFrameOrder.push(node.id);
3409
3424
  }
3425
+ if (!scene.frameOrder) frameOrderAtom.set(seededFrameOrder);
3410
3426
  for (const id of Object.keys(scene.edges)) {
3411
3427
  const edge = scene.edges[id];
3412
3428
  if (!edge) continue;
@@ -3708,6 +3724,53 @@ var createCanvasStore = (opts = {}) => {
3708
3724
  getNodeCount: () => nodeIdsAtom.value.length,
3709
3725
  getEdgeCount: () => edgeIdsAtom.value.length,
3710
3726
  getGroupCount: () => groupIdsAtom.value.length,
3727
+ getFrames: () => {
3728
+ const out = [];
3729
+ for (const id of frameOrderAtom.value) {
3730
+ const n = nodeAtoms.get(id)?.value;
3731
+ if (n && n.type === "frame") out.push(n);
3732
+ }
3733
+ return out;
3734
+ },
3735
+ setFrameOrder(ids) {
3736
+ const valid = /* @__PURE__ */ new Set();
3737
+ for (const a of nodeAtoms.values()) {
3738
+ if (a.value.type === "frame") valid.add(a.value.id);
3739
+ }
3740
+ const filtered = [];
3741
+ const seen = /* @__PURE__ */ new Set();
3742
+ for (const id of ids) {
3743
+ if (valid.has(id) && !seen.has(id)) {
3744
+ filtered.push(id);
3745
+ seen.add(id);
3746
+ }
3747
+ }
3748
+ for (const id of valid) {
3749
+ if (!seen.has(id)) filtered.push(id);
3750
+ }
3751
+ const prev = [...frameOrderAtom.value];
3752
+ if (filtered.length === prev.length && filtered.every((id, i) => id === prev[i])) {
3753
+ return;
3754
+ }
3755
+ enqueueOp({ type: "frame.reorder", ids: filtered, prev });
3756
+ },
3757
+ getNodesInFrame(id) {
3758
+ const frame = nodeAtoms.get(id)?.value;
3759
+ if (!frame || frame.type !== "frame") return [];
3760
+ const frameAabb = nodeAABB(frame);
3761
+ const candidates = nodeIndex.queryRect(frameAabb);
3762
+ const out = [];
3763
+ for (const cid of candidates) {
3764
+ if (cid === id) continue;
3765
+ const node = nodeAtoms.get(cid)?.value;
3766
+ if (!node || node.type === "frame") continue;
3767
+ const a = nodeAABB(node);
3768
+ if (a.x >= frameAabb.x && a.y >= frameAabb.y && a.x + a.w <= frameAabb.x + frameAabb.w && a.y + a.h <= frameAabb.y + frameAabb.h) {
3769
+ out.push(node);
3770
+ }
3771
+ }
3772
+ return out;
3773
+ },
3711
3774
  getEdgeGeometry(id) {
3712
3775
  const edge = edgeAtoms.get(id)?.value;
3713
3776
  if (!edge) return void 0;
@@ -3869,7 +3932,8 @@ var toSerialized = (scene) => ({
3869
3932
  edges: Object.values(scene.edges),
3870
3933
  groups: Object.values(scene.groups),
3871
3934
  camera: scene.camera,
3872
- selection: scene.selection
3935
+ selection: scene.selection,
3936
+ ...scene.frameOrder && scene.frameOrder.length > 0 ? { frameOrder: scene.frameOrder } : {}
3873
3937
  });
3874
3938
  var fromSerialized = (raw) => {
3875
3939
  let working = raw;
@@ -3891,7 +3955,8 @@ var fromSerialized = (raw) => {
3891
3955
  edges: Object.fromEntries(ser.edges.map((e) => [asEdgeId(e.id), e])),
3892
3956
  groups: Object.fromEntries(ser.groups.map((g) => [asGroupId(g.id), g])),
3893
3957
  camera: ser.camera,
3894
- selection: ser.selection
3958
+ selection: ser.selection,
3959
+ ...ser.frameOrder ? { frameOrder: ser.frameOrder } : {}
3895
3960
  };
3896
3961
  };
3897
3962
  var storeToJSON = (store) => ({
@@ -4436,6 +4501,40 @@ var drawMarquee = (ctx, rect, scale, color) => {
4436
4501
  ctx.restore();
4437
4502
  };
4438
4503
 
4504
+ // src/render/paint-frame.ts
4505
+ var FRAME_BORDER_PX = 1.5;
4506
+ var FRAME_BORDER_COLOR_DEFAULT = "#94a3b8";
4507
+ var FRAME_FILL_DEFAULT = "rgba(148, 163, 184, 0.06)";
4508
+ var FRAME_LABEL_FONT_PX = 12;
4509
+ var FRAME_LABEL_GAP_PX = 6;
4510
+ var FRAME_LABEL_COLOR = "#64748b";
4511
+ var paintFrameNode = (ctx, node, scale, theme) => {
4512
+ if (node.w <= 0 || node.h <= 0) return;
4513
+ const opacity = resolveOpacity(node.style, theme);
4514
+ const needsScope = opacity !== 1;
4515
+ if (needsScope) {
4516
+ ctx.save();
4517
+ ctx.globalAlpha = opacity;
4518
+ }
4519
+ const fill = node.style?.backgroundColor ?? (theme ? theme("frame.background") : void 0) ?? FRAME_FILL_DEFAULT;
4520
+ ctx.fillStyle = fill;
4521
+ ctx.fillRect(0, 0, node.w, node.h);
4522
+ const stroke = resolveColor(node.style, "strokeColor", FRAME_BORDER_COLOR_DEFAULT, theme);
4523
+ ctx.strokeStyle = stroke;
4524
+ ctx.lineWidth = FRAME_BORDER_PX / scale;
4525
+ ctx.setLineDash([]);
4526
+ ctx.strokeRect(0, 0, node.w, node.h);
4527
+ const labelPx = FRAME_LABEL_FONT_PX / scale;
4528
+ const gapPx = FRAME_LABEL_GAP_PX / scale;
4529
+ const label = node.content?.trim() || "Frame";
4530
+ ctx.fillStyle = FRAME_LABEL_COLOR;
4531
+ ctx.textBaseline = "bottom";
4532
+ ctx.textAlign = "left";
4533
+ ctx.font = `500 ${labelPx}px system-ui, -apple-system, sans-serif`;
4534
+ ctx.fillText(label, 0, -gapPx);
4535
+ if (needsScope) ctx.restore();
4536
+ };
4537
+
4439
4538
  // src/render/shapes/content-bounds.ts
4440
4539
  var SQRT2_INV = 1 / Math.SQRT2;
4441
4540
  var contentBounds = (node) => {
@@ -4509,6 +4608,7 @@ var createRenderer = (opts) => {
4509
4608
  const interactiveSurface = setupSurface(opts.interactiveCanvas);
4510
4609
  let background = opts.background;
4511
4610
  let selectionColor = opts.selectionColor ?? DEFAULT_SELECTION_COLOR;
4611
+ let hideFrames = false;
4512
4612
  sizeSurface(staticSurface, opts.width, opts.height);
4513
4613
  sizeSurface(interactiveSurface, opts.width, opts.height);
4514
4614
  let staticDirty = true;
@@ -4558,7 +4658,18 @@ var createRenderer = (opts) => {
4558
4658
  const cameraIsMoving = interaction.mode === "panning" || interaction.mode === "zooming";
4559
4659
  const movingNodeCount = excludedNodes?.size ?? 0;
4560
4660
  const roughEnabled = !cameraIsMoving && movingNodeCount <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM && visible.length <= ROUGH_MAX_NODES;
4661
+ if (!hideFrames) {
4662
+ for (const node of visible) {
4663
+ if (node.type !== "frame") continue;
4664
+ if (excludedNodes?.has(node.id)) continue;
4665
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4666
+ paintFrameNode(staticSurface.ctx, node, scale, theme);
4667
+ });
4668
+ drawn++;
4669
+ }
4670
+ }
4561
4671
  for (const node of visible) {
4672
+ if (node.type === "frame") continue;
4562
4673
  if (excludedNodes?.has(node.id)) continue;
4563
4674
  const isEditingThis = editingNodeId === node.id;
4564
4675
  if (isDrawablePrimitive(node.type)) {
@@ -4761,9 +4872,13 @@ var createRenderer = (opts) => {
4761
4872
  isMoving: true};
4762
4873
  const dragRoughEnabled = inDragMap.size <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM;
4763
4874
  for (const node of inDragMap.values()) {
4764
- if (!isDrawablePrimitive(node.type) && node.type !== "text" && node.type !== "image" && node.type !== "icon")
4875
+ if (!isDrawablePrimitive(node.type) && node.type !== "text" && node.type !== "image" && node.type !== "icon" && node.type !== "frame")
4765
4876
  continue;
4766
4877
  drawWithNodeTransform(ctx, node, () => {
4878
+ if (node.type === "frame") {
4879
+ paintFrameNode(ctx, node, scale, theme);
4880
+ return;
4881
+ }
4767
4882
  if (node.type === "image") {
4768
4883
  paintImageNode(ctx, node, assetCache, theme);
4769
4884
  return;
@@ -4965,6 +5080,11 @@ var createRenderer = (opts) => {
4965
5080
  interactiveDirty = true;
4966
5081
  loop.requestFrame();
4967
5082
  },
5083
+ setHideFrames(hidden) {
5084
+ hideFrames = hidden;
5085
+ staticDirty = true;
5086
+ loop.requestFrame();
5087
+ },
4968
5088
  stats: () => loop.stats(),
4969
5089
  lastDrawCount: () => lastDrawn,
4970
5090
  getOverlaySet: () => [...overlaySet],
@@ -4995,6 +5115,7 @@ var sceneBounds = (store) => {
4995
5115
  let maxY = Number.NEGATIVE_INFINITY;
4996
5116
  for (const n of nodes) {
4997
5117
  if (n.hidden) continue;
5118
+ if (n.type === "frame") continue;
4998
5119
  const r = nodeAABB(n);
4999
5120
  if (r.x < minX) minX = r.x;
5000
5121
  if (r.y < minY) minY = r.y;
@@ -5025,6 +5146,7 @@ var renderMinimapContent = (ctx, store, mapWidth, mapHeight, opts = {}) => {
5025
5146
  const defaultColor = opts.defaultNodeColor ?? "#94a3b8";
5026
5147
  for (const node of store.getAllNodes()) {
5027
5148
  if (node.hidden) continue;
5149
+ if (node.type === "frame") continue;
5028
5150
  const r = nodeAABB(node);
5029
5151
  const x = offX + (r.x - bx) * scale;
5030
5152
  const y = offY + (r.y - by) * scale;