@canvas-harness/core 0.0.1 → 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.cjs CHANGED
@@ -2908,6 +2908,155 @@ var createDefaultTextareaEditor = ({
2908
2908
  };
2909
2909
  };
2910
2910
 
2911
+ // src/assets/image.ts
2912
+ var MAX_IMAGE_BYTES = 2 * 1024 * 1024;
2913
+ var ACCEPTED_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg"]);
2914
+ var validateImageInput = (input) => {
2915
+ if (typeof input === "string") {
2916
+ if (!input.startsWith("data:")) {
2917
+ throw new Error(
2918
+ "addImage: external URL strings are not supported. Pass a File, Blob, or `data:image/(png|jpeg)` URI."
2919
+ );
2920
+ }
2921
+ const mimeMatch = /^data:([^;,]+)/.exec(input);
2922
+ const mime = mimeMatch?.[1] ?? "";
2923
+ if (!ACCEPTED_MIME.has(mime)) {
2924
+ throw new Error(
2925
+ `addImage: unsupported MIME "${mime || "(unknown)"}". Only image/png and image/jpeg are supported.`
2926
+ );
2927
+ }
2928
+ const comma = input.indexOf(",");
2929
+ if (comma < 0) throw new Error("addImage: malformed data URI (missing payload separator)");
2930
+ const decodedBytes = Math.floor((input.length - comma - 1) * 3 / 4);
2931
+ if (decodedBytes > MAX_IMAGE_BYTES) {
2932
+ throw new Error(
2933
+ `addImage: image exceeds the 2 MB limit (${Math.round(decodedBytes / 1024)} KB).`
2934
+ );
2935
+ }
2936
+ return;
2937
+ }
2938
+ if (!ACCEPTED_MIME.has(input.type)) {
2939
+ throw new Error(
2940
+ `addImage: unsupported file type "${input.type || "(unknown)"}". Only image/png and image/jpeg are supported.`
2941
+ );
2942
+ }
2943
+ if (input.size > MAX_IMAGE_BYTES) {
2944
+ throw new Error(`addImage: file exceeds the 2 MB limit (${Math.round(input.size / 1024)} KB).`);
2945
+ }
2946
+ };
2947
+ var toImageBlob = async (input) => {
2948
+ if (typeof input === "string") {
2949
+ const res = await fetch(input);
2950
+ return res.blob();
2951
+ }
2952
+ return input;
2953
+ };
2954
+ var downscaleImageBlob = async (blob, maxDim) => {
2955
+ const bitmap = await createImageBitmap(blob);
2956
+ const naturalW = bitmap.width;
2957
+ const naturalH = bitmap.height;
2958
+ const maxSide = Math.max(naturalW, naturalH);
2959
+ if (maxDim <= 0 || maxSide <= maxDim) {
2960
+ bitmap.close?.();
2961
+ return { blob, naturalW, naturalH };
2962
+ }
2963
+ const scale = maxDim / maxSide;
2964
+ const w = Math.max(1, Math.round(naturalW * scale));
2965
+ const h = Math.max(1, Math.round(naturalH * scale));
2966
+ const canvas = new OffscreenCanvas(w, h);
2967
+ const ctx = canvas.getContext("2d");
2968
+ if (!ctx) throw new Error("addImage: failed to acquire OffscreenCanvas 2d context");
2969
+ ctx.drawImage(bitmap, 0, 0, w, h);
2970
+ bitmap.close?.();
2971
+ const outType = blob.type === "image/png" ? "image/png" : "image/jpeg";
2972
+ const outBlob = await canvas.convertToBlob({ type: outType, quality: 0.9 });
2973
+ return { blob: outBlob, naturalW: w, naturalH: h };
2974
+ };
2975
+ var blobToDataUri = (blob) => {
2976
+ return new Promise((resolve, reject) => {
2977
+ const reader = new FileReader();
2978
+ reader.onload = () => {
2979
+ if (typeof reader.result === "string") resolve(reader.result);
2980
+ else reject(new Error("FileReader returned non-string result"));
2981
+ };
2982
+ reader.onerror = () => reject(reader.error ?? new Error("FileReader failed"));
2983
+ reader.readAsDataURL(blob);
2984
+ });
2985
+ };
2986
+
2987
+ // src/assets/svg.ts
2988
+ var MAX_SVG_BYTES = 2 * 1024 * 1024;
2989
+ var DEFAULT_SVG_SIZE = 24;
2990
+ var validateSvgMarkup = (markup) => {
2991
+ if (typeof markup !== "string") {
2992
+ throw new Error("addSvg: src must be a string of SVG markup");
2993
+ }
2994
+ const byteLen = new Blob([markup]).size;
2995
+ if (byteLen > MAX_SVG_BYTES) {
2996
+ throw new Error(`addSvg: SVG markup exceeds the 2 MB limit (${Math.round(byteLen / 1024)} KB).`);
2997
+ }
2998
+ if (!/<svg[\s>]/i.test(markup)) {
2999
+ throw new Error("addSvg: src does not look like SVG markup (no <svg> tag found)");
3000
+ }
3001
+ };
3002
+ var sanitizeSvg = (markup) => {
3003
+ const parser = new DOMParser();
3004
+ const doc = parser.parseFromString(markup, "image/svg+xml");
3005
+ const root = doc.documentElement;
3006
+ if (root.nodeName === "parsererror" || root.querySelector("parsererror")) {
3007
+ throw new Error("addSvg: malformed SVG (parser error)");
3008
+ }
3009
+ const removable = [];
3010
+ const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT);
3011
+ let n = walker.nextNode();
3012
+ while (n) {
3013
+ const el = n;
3014
+ const tag = el.tagName.toLowerCase();
3015
+ if (tag === "script" || tag === "foreignobject") {
3016
+ removable.push(el);
3017
+ } else {
3018
+ for (const attr of [...el.attributes]) {
3019
+ const name = attr.name.toLowerCase();
3020
+ const value = attr.value.trim().toLowerCase();
3021
+ if (name.startsWith("on")) {
3022
+ el.removeAttribute(attr.name);
3023
+ } else if ((name === "href" || name === "xlink:href" || name === "src") && value.startsWith("javascript:")) {
3024
+ el.removeAttribute(attr.name);
3025
+ }
3026
+ }
3027
+ }
3028
+ n = walker.nextNode();
3029
+ }
3030
+ for (const el of removable) el.remove();
3031
+ return new XMLSerializer().serializeToString(doc);
3032
+ };
3033
+ var extractSvgDimensions = (markup) => {
3034
+ const parser = new DOMParser();
3035
+ const doc = parser.parseFromString(markup, "image/svg+xml");
3036
+ const svg = doc.documentElement;
3037
+ if (svg.nodeName.toLowerCase() !== "svg") {
3038
+ return { w: DEFAULT_SVG_SIZE, h: DEFAULT_SVG_SIZE };
3039
+ }
3040
+ const widthAttr = svg.getAttribute("width");
3041
+ const heightAttr = svg.getAttribute("height");
3042
+ if (widthAttr && heightAttr) {
3043
+ const w = Number.parseFloat(widthAttr);
3044
+ const h = Number.parseFloat(heightAttr);
3045
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return { w, h };
3046
+ }
3047
+ const viewBox = svg.getAttribute("viewBox");
3048
+ if (viewBox) {
3049
+ const parts = viewBox.split(/[\s,]+/).map(Number.parseFloat);
3050
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
3051
+ return { w: parts[2], h: parts[3] };
3052
+ }
3053
+ }
3054
+ return { w: DEFAULT_SVG_SIZE, h: DEFAULT_SVG_SIZE };
3055
+ };
3056
+ var applySvgColor = (markup, color) => {
3057
+ return markup.replace(/currentColor/gi, color);
3058
+ };
3059
+
2911
3060
  // src/store/conflict.ts
2912
3061
  var detectConflicts = (batch, getNode, getEdge) => {
2913
3062
  const out = [];
@@ -2984,6 +3133,8 @@ var inverseOp = (op) => {
2984
3133
  return { type: "group.remove", group: op.group };
2985
3134
  case "group.remove":
2986
3135
  return { type: "group.upsert", group: op.group };
3136
+ case "frame.reorder":
3137
+ return { type: "frame.reorder", ids: op.prev, prev: op.ids };
2987
3138
  }
2988
3139
  };
2989
3140
  var inverseBatch = (batch) => {
@@ -3051,6 +3202,7 @@ var createCanvasStore = (opts = {}) => {
3051
3202
  const groupIdsAtom = signia.atom("groupIds", []);
3052
3203
  const cameraAtom = signia.atom("camera", initial.camera);
3053
3204
  const selectionAtom = signia.atom("selection", initial.selection);
3205
+ const frameOrderAtom = signia.atom("frameOrder", initial.frameOrder ?? []);
3054
3206
  const interactionAtom = signia.atom("interaction", idleInteractionState());
3055
3207
  const localPresenceAtom = signia.atom("presence", emptyPresenceState(clientId));
3056
3208
  const remotePresence = /* @__PURE__ */ new Map();
@@ -3157,6 +3309,9 @@ var createCanvasStore = (opts = {}) => {
3157
3309
  nodeAtoms.set(op.node.id, a);
3158
3310
  nodeIdsAtom.update((ids) => [...ids, op.node.id]);
3159
3311
  reindexNode(op.node);
3312
+ if (op.node.type === "frame") {
3313
+ frameOrderAtom.update((ids) => ids.includes(op.node.id) ? ids : [...ids, op.node.id]);
3314
+ }
3160
3315
  break;
3161
3316
  }
3162
3317
  case "node.update": {
@@ -3181,6 +3336,9 @@ var createCanvasStore = (opts = {}) => {
3181
3336
  nodeIdsAtom.update((ids) => ids.filter((x) => x !== id));
3182
3337
  unindexNode(id);
3183
3338
  incidentEdges.delete(id);
3339
+ if (op.node.type === "frame") {
3340
+ frameOrderAtom.update((ids) => ids.filter((x) => x !== id));
3341
+ }
3184
3342
  break;
3185
3343
  }
3186
3344
  case "edge.add": {
@@ -3230,6 +3388,10 @@ var createCanvasStore = (opts = {}) => {
3230
3388
  groupIdsAtom.update((ids) => ids.filter((x) => x !== id));
3231
3389
  break;
3232
3390
  }
3391
+ case "frame.reorder": {
3392
+ frameOrderAtom.set([...op.ids]);
3393
+ break;
3394
+ }
3233
3395
  }
3234
3396
  };
3235
3397
  const enqueueOp = (op) => {
@@ -3252,6 +3414,7 @@ var createCanvasStore = (opts = {}) => {
3252
3414
  return prev;
3253
3415
  };
3254
3416
  const populateInitial = (scene) => {
3417
+ const seededFrameOrder = [];
3255
3418
  for (const id of Object.keys(scene.nodes)) {
3256
3419
  const node = scene.nodes[id];
3257
3420
  if (!node) continue;
@@ -3259,7 +3422,9 @@ var createCanvasStore = (opts = {}) => {
3259
3422
  nodeAtoms.set(node.id, a);
3260
3423
  nodeIdsAtom.update((ids) => [...ids, node.id]);
3261
3424
  reindexNode(node);
3425
+ if (node.type === "frame") seededFrameOrder.push(node.id);
3262
3426
  }
3427
+ if (!scene.frameOrder) frameOrderAtom.set(seededFrameOrder);
3263
3428
  for (const id of Object.keys(scene.edges)) {
3264
3429
  const edge = scene.edges[id];
3265
3430
  if (!edge) continue;
@@ -3328,6 +3493,55 @@ var createCanvasStore = (opts = {}) => {
3328
3493
  if (batch) emitChange(batch);
3329
3494
  });
3330
3495
  },
3496
+ async addImage(opts2) {
3497
+ validateImageInput(opts2.src);
3498
+ const rawBlob = await toImageBlob(opts2.src);
3499
+ const maxDim = opts2.maxDimension ?? 2048;
3500
+ const { blob, naturalW, naturalH } = await downscaleImageBlob(rawBlob, maxDim);
3501
+ const src = await blobToDataUri(blob);
3502
+ const DEFAULT_MAX_NODE_SIDE = 400;
3503
+ const aspectScale = Math.min(1, DEFAULT_MAX_NODE_SIDE / Math.max(naturalW, naturalH));
3504
+ const w = opts2.w ?? Math.max(1, Math.round(naturalW * aspectScale));
3505
+ const h = opts2.h ?? Math.max(1, Math.round(naturalH * aspectScale));
3506
+ const id = asNodeId(idGenerator());
3507
+ this.addNode({
3508
+ id,
3509
+ type: "image",
3510
+ x: opts2.x,
3511
+ y: opts2.y,
3512
+ w,
3513
+ h,
3514
+ angle: 0,
3515
+ z: 0,
3516
+ groups: [],
3517
+ style: opts2.style,
3518
+ data: { src, naturalW, naturalH, alt: opts2.alt }
3519
+ });
3520
+ return id;
3521
+ },
3522
+ async addSvg(opts2) {
3523
+ validateSvgMarkup(opts2.src);
3524
+ const sanitized = sanitizeSvg(opts2.src);
3525
+ const intrinsic = extractSvgDimensions(sanitized);
3526
+ const w = opts2.w ?? intrinsic.w;
3527
+ const h = opts2.h ?? intrinsic.h;
3528
+ const mergedStyle = opts2.color || opts2.style ? { ...opts2.color ? { iconColor: opts2.color } : {}, ...opts2.style } : void 0;
3529
+ const id = asNodeId(idGenerator());
3530
+ this.addNode({
3531
+ id,
3532
+ type: "icon",
3533
+ x: opts2.x,
3534
+ y: opts2.y,
3535
+ w,
3536
+ h,
3537
+ angle: 0,
3538
+ z: 0,
3539
+ groups: [],
3540
+ ...mergedStyle ? { style: mergedStyle } : {},
3541
+ data: { src: sanitized, alt: opts2.alt }
3542
+ });
3543
+ return id;
3544
+ },
3331
3545
  addEdge(edge) {
3332
3546
  const withZ = edge.z === 0 ? { ...edge, z: ++topZ } : edge;
3333
3547
  if (withZ.z > topZ) topZ = withZ.z;
@@ -3512,6 +3726,53 @@ var createCanvasStore = (opts = {}) => {
3512
3726
  getNodeCount: () => nodeIdsAtom.value.length,
3513
3727
  getEdgeCount: () => edgeIdsAtom.value.length,
3514
3728
  getGroupCount: () => groupIdsAtom.value.length,
3729
+ getFrames: () => {
3730
+ const out = [];
3731
+ for (const id of frameOrderAtom.value) {
3732
+ const n = nodeAtoms.get(id)?.value;
3733
+ if (n && n.type === "frame") out.push(n);
3734
+ }
3735
+ return out;
3736
+ },
3737
+ setFrameOrder(ids) {
3738
+ const valid = /* @__PURE__ */ new Set();
3739
+ for (const a of nodeAtoms.values()) {
3740
+ if (a.value.type === "frame") valid.add(a.value.id);
3741
+ }
3742
+ const filtered = [];
3743
+ const seen = /* @__PURE__ */ new Set();
3744
+ for (const id of ids) {
3745
+ if (valid.has(id) && !seen.has(id)) {
3746
+ filtered.push(id);
3747
+ seen.add(id);
3748
+ }
3749
+ }
3750
+ for (const id of valid) {
3751
+ if (!seen.has(id)) filtered.push(id);
3752
+ }
3753
+ const prev = [...frameOrderAtom.value];
3754
+ if (filtered.length === prev.length && filtered.every((id, i) => id === prev[i])) {
3755
+ return;
3756
+ }
3757
+ enqueueOp({ type: "frame.reorder", ids: filtered, prev });
3758
+ },
3759
+ getNodesInFrame(id) {
3760
+ const frame = nodeAtoms.get(id)?.value;
3761
+ if (!frame || frame.type !== "frame") return [];
3762
+ const frameAabb = nodeAABB(frame);
3763
+ const candidates = nodeIndex.queryRect(frameAabb);
3764
+ const out = [];
3765
+ for (const cid of candidates) {
3766
+ if (cid === id) continue;
3767
+ const node = nodeAtoms.get(cid)?.value;
3768
+ if (!node || node.type === "frame") continue;
3769
+ const a = nodeAABB(node);
3770
+ 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) {
3771
+ out.push(node);
3772
+ }
3773
+ }
3774
+ return out;
3775
+ },
3515
3776
  getEdgeGeometry(id) {
3516
3777
  const edge = edgeAtoms.get(id)?.value;
3517
3778
  if (!edge) return void 0;
@@ -3673,7 +3934,8 @@ var toSerialized = (scene) => ({
3673
3934
  edges: Object.values(scene.edges),
3674
3935
  groups: Object.values(scene.groups),
3675
3936
  camera: scene.camera,
3676
- selection: scene.selection
3937
+ selection: scene.selection,
3938
+ ...scene.frameOrder && scene.frameOrder.length > 0 ? { frameOrder: scene.frameOrder } : {}
3677
3939
  });
3678
3940
  var fromSerialized = (raw) => {
3679
3941
  let working = raw;
@@ -3695,7 +3957,8 @@ var fromSerialized = (raw) => {
3695
3957
  edges: Object.fromEntries(ser.edges.map((e) => [asEdgeId(e.id), e])),
3696
3958
  groups: Object.fromEntries(ser.groups.map((g) => [asGroupId(g.id), g])),
3697
3959
  camera: ser.camera,
3698
- selection: ser.selection
3960
+ selection: ser.selection,
3961
+ ...ser.frameOrder ? { frameOrder: ser.frameOrder } : {}
3699
3962
  };
3700
3963
  };
3701
3964
  var storeToJSON = (store) => ({
@@ -3800,6 +4063,182 @@ var createFrameLoop = ({ draw, historySize = 60 }) => {
3800
4063
  };
3801
4064
  };
3802
4065
 
4066
+ // src/render/assets/cache.ts
4067
+ var MAX_ENTRIES2 = 256;
4068
+ var bucketSize = (px) => {
4069
+ if (px <= 32) return 32;
4070
+ if (px <= 64) return 64;
4071
+ if (px <= 128) return 128;
4072
+ if (px <= 256) return 256;
4073
+ if (px <= 512) return 512;
4074
+ return Math.ceil(px / 256) * 256;
4075
+ };
4076
+ var createAssetCache = (opts = {}) => {
4077
+ const entries = /* @__PURE__ */ new Map();
4078
+ let disposed = false;
4079
+ const notify = () => {
4080
+ if (disposed) return;
4081
+ opts.onReady?.();
4082
+ };
4083
+ const touch = (key, entry) => {
4084
+ entries.delete(key);
4085
+ entries.set(key, entry);
4086
+ if (entries.size > MAX_ENTRIES2) {
4087
+ const oldestKey = entries.keys().next().value;
4088
+ if (oldestKey !== void 0) {
4089
+ const evicted = entries.get(oldestKey);
4090
+ if (evicted?.kind === "icon" && evicted.bitmap) evicted.bitmap.close?.();
4091
+ entries.delete(oldestKey);
4092
+ }
4093
+ }
4094
+ };
4095
+ const startImageDecode = (key, src) => {
4096
+ const entry = { kind: "image", state: "pending", bitmap: null };
4097
+ touch(key, entry);
4098
+ const img = new Image();
4099
+ img.onload = () => {
4100
+ if (disposed) return;
4101
+ entry.state = "ready";
4102
+ entry.bitmap = img;
4103
+ notify();
4104
+ };
4105
+ img.onerror = (e) => {
4106
+ if (disposed) return;
4107
+ entry.state = "error";
4108
+ entry.err = e;
4109
+ notify();
4110
+ };
4111
+ img.src = src;
4112
+ };
4113
+ const startIconRaster = (key, markup, color, sizePx) => {
4114
+ const entry = { kind: "icon", state: "pending", bitmap: null };
4115
+ touch(key, entry);
4116
+ const colored = color ? applySvgColor(markup, color) : markup;
4117
+ const blob = new Blob([colored], { type: "image/svg+xml" });
4118
+ const url = URL.createObjectURL(blob);
4119
+ const img = new Image();
4120
+ img.onload = async () => {
4121
+ URL.revokeObjectURL(url);
4122
+ if (disposed) return;
4123
+ try {
4124
+ const bitmap = await createImageBitmap(img, {
4125
+ resizeWidth: sizePx,
4126
+ resizeHeight: sizePx,
4127
+ resizeQuality: "high"
4128
+ });
4129
+ if (disposed) {
4130
+ bitmap.close?.();
4131
+ return;
4132
+ }
4133
+ entry.state = "ready";
4134
+ entry.bitmap = bitmap;
4135
+ notify();
4136
+ } catch (e) {
4137
+ entry.state = "error";
4138
+ entry.err = e;
4139
+ notify();
4140
+ }
4141
+ };
4142
+ img.onerror = (e) => {
4143
+ URL.revokeObjectURL(url);
4144
+ if (disposed) return;
4145
+ entry.state = "error";
4146
+ entry.err = e;
4147
+ notify();
4148
+ };
4149
+ img.src = url;
4150
+ };
4151
+ return {
4152
+ getImage(src) {
4153
+ const key = `img:${src}`;
4154
+ const existing = entries.get(key);
4155
+ if (existing && existing.kind === "image") {
4156
+ if (existing.state === "ready") {
4157
+ touch(key, existing);
4158
+ return existing.bitmap;
4159
+ }
4160
+ return null;
4161
+ }
4162
+ startImageDecode(key, src);
4163
+ return null;
4164
+ },
4165
+ getIcon(markup, color, devicePixelSize) {
4166
+ const size = bucketSize(Math.max(1, Math.ceil(devicePixelSize)));
4167
+ const key = `icon:${size}:${color ?? ""}:${markup}`;
4168
+ const existing = entries.get(key);
4169
+ if (existing && existing.kind === "icon") {
4170
+ if (existing.state === "ready") {
4171
+ touch(key, existing);
4172
+ return existing.bitmap;
4173
+ }
4174
+ return null;
4175
+ }
4176
+ startIconRaster(key, markup, color, size);
4177
+ return null;
4178
+ },
4179
+ dispose() {
4180
+ disposed = true;
4181
+ for (const entry of entries.values()) {
4182
+ if (entry.kind === "icon" && entry.bitmap) entry.bitmap.close?.();
4183
+ }
4184
+ entries.clear();
4185
+ }
4186
+ };
4187
+ };
4188
+
4189
+ // src/render/assets/paint.ts
4190
+ var PLACEHOLDER_FILL = "#e5e7eb";
4191
+ var PLACEHOLDER_TEXT_FILL = "#94a3b8";
4192
+ var paintPlaceholder = (ctx, w, h, label) => {
4193
+ ctx.fillStyle = PLACEHOLDER_FILL;
4194
+ ctx.fillRect(0, 0, w, h);
4195
+ if (w >= 32 && h >= 16) {
4196
+ ctx.fillStyle = PLACEHOLDER_TEXT_FILL;
4197
+ ctx.font = "11px system-ui, sans-serif";
4198
+ ctx.textAlign = "center";
4199
+ ctx.textBaseline = "middle";
4200
+ ctx.fillText(label, w / 2, h / 2);
4201
+ }
4202
+ };
4203
+ var paintImageNode = (ctx, node, cache4, theme) => {
4204
+ if (node.w <= 0 || node.h <= 0) return;
4205
+ const data = node.data;
4206
+ if (!data?.src) return;
4207
+ const bitmap = cache4.getImage(data.src);
4208
+ const opacity = resolveOpacity(node.style, theme);
4209
+ const needsScope = opacity !== 1;
4210
+ if (needsScope) {
4211
+ ctx.save();
4212
+ ctx.globalAlpha = opacity;
4213
+ }
4214
+ if (bitmap?.complete) {
4215
+ ctx.drawImage(bitmap, 0, 0, node.w, node.h);
4216
+ } else {
4217
+ paintPlaceholder(ctx, node.w, node.h, "loading\u2026");
4218
+ }
4219
+ if (needsScope) ctx.restore();
4220
+ };
4221
+ var paintIconNode = (ctx, node, cache4, scale, theme) => {
4222
+ if (node.w <= 0 || node.h <= 0) return;
4223
+ const data = node.data;
4224
+ if (!data?.src) return;
4225
+ const sizePx = Math.max(node.w, node.h) * scale;
4226
+ const color = node.style?.iconColor;
4227
+ const bitmap = cache4.getIcon(data.src, color, sizePx);
4228
+ const opacity = resolveOpacity(node.style, theme);
4229
+ const needsScope = opacity !== 1;
4230
+ if (needsScope) {
4231
+ ctx.save();
4232
+ ctx.globalAlpha = opacity;
4233
+ }
4234
+ if (bitmap) {
4235
+ ctx.drawImage(bitmap, 0, 0, node.w, node.h);
4236
+ } else {
4237
+ paintPlaceholder(ctx, node.w, node.h, "svg\u2026");
4238
+ }
4239
+ if (needsScope) ctx.restore();
4240
+ };
4241
+
3803
4242
  // src/render/background.ts
3804
4243
  var MIN_PATTERN_SCREEN_PX = 8;
3805
4244
  var MIN_VISIBLE_PATTERN_PX = 2;
@@ -3941,14 +4380,14 @@ var hitTestRotateHandle = (node, worldPoint, cameraZ) => {
3941
4380
  };
3942
4381
 
3943
4382
  // src/render/overlay.ts
3944
- var SELECTION_COLOR = "#3b82f6";
4383
+ var DEFAULT_SELECTION_COLOR = "#3b82f6";
3945
4384
  var SELECTION_OUTLINE_PX = 1.5;
3946
- var MARQUEE_FILL = "rgba(59, 130, 246, 0.08)";
4385
+ var MARQUEE_FILL_ALPHA = 0.08;
3947
4386
  var MARQUEE_STROKE_PX = 1;
3948
- var drawSelectionOutline = (ctx, node, scale) => {
4387
+ var drawSelectionOutline = (ctx, node, scale, color) => {
3949
4388
  if (node.angle === 0) {
3950
4389
  ctx.save();
3951
- ctx.strokeStyle = SELECTION_COLOR;
4390
+ ctx.strokeStyle = color;
3952
4391
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3953
4392
  ctx.beginPath();
3954
4393
  ctx.rect(node.x, node.y, node.w, node.h);
@@ -3967,7 +4406,7 @@ var drawSelectionOutline = (ctx, node, scale) => {
3967
4406
  { x: -node.w / 2, y: node.h / 2 }
3968
4407
  ].map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos }));
3969
4408
  ctx.save();
3970
- ctx.strokeStyle = SELECTION_COLOR;
4409
+ ctx.strokeStyle = color;
3971
4410
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3972
4411
  ctx.beginPath();
3973
4412
  const first = corners[0];
@@ -3980,13 +4419,13 @@ var drawSelectionOutline = (ctx, node, scale) => {
3980
4419
  ctx.stroke();
3981
4420
  ctx.restore();
3982
4421
  };
3983
- var drawResizeHandles = (ctx, node, scale) => {
4422
+ var drawResizeHandles = (ctx, node, scale, color) => {
3984
4423
  const halfPx = RESIZE_HANDLE_SIZE_PX / 2;
3985
4424
  const halfWorld = halfPx / scale;
3986
4425
  const positions = handleWorldPositions(node);
3987
4426
  ctx.save();
3988
4427
  ctx.fillStyle = "#fff";
3989
- ctx.strokeStyle = SELECTION_COLOR;
4428
+ ctx.strokeStyle = color;
3990
4429
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3991
4430
  for (const key of Object.keys(positions)) {
3992
4431
  const p = positions[key];
@@ -3997,7 +4436,7 @@ var drawResizeHandles = (ctx, node, scale) => {
3997
4436
  }
3998
4437
  ctx.restore();
3999
4438
  };
4000
- var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4439
+ var drawRotateHandle = (ctx, node, scale, cameraZ, color) => {
4001
4440
  const center = rotateHandleWorldPosition(node, cameraZ);
4002
4441
  const radiusWorld = ROTATE_HANDLE_RADIUS_PX / scale;
4003
4442
  const cx = node.x + node.w / 2;
@@ -4010,7 +4449,7 @@ var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4010
4449
  y: cy + 0 * sin + topMidLocalY * cos
4011
4450
  };
4012
4451
  ctx.save();
4013
- ctx.strokeStyle = SELECTION_COLOR;
4452
+ ctx.strokeStyle = color;
4014
4453
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4015
4454
  ctx.beginPath();
4016
4455
  ctx.moveTo(topMidWorld.x, topMidWorld.y);
@@ -4023,12 +4462,12 @@ var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4023
4462
  ctx.stroke();
4024
4463
  ctx.restore();
4025
4464
  };
4026
- var drawEdgeMidpointHandle = (ctx, midpoint, scale) => {
4465
+ var drawEdgeMidpointHandle = (ctx, midpoint, scale, color) => {
4027
4466
  const radiusPx = 5;
4028
4467
  const radiusWorld = radiusPx / scale;
4029
4468
  ctx.save();
4030
4469
  ctx.fillStyle = "#fff";
4031
- ctx.strokeStyle = SELECTION_COLOR;
4470
+ ctx.strokeStyle = color;
4032
4471
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4033
4472
  ctx.beginPath();
4034
4473
  ctx.arc(midpoint.x, midpoint.y, radiusWorld, 0, Math.PI * 2);
@@ -4036,12 +4475,12 @@ var drawEdgeMidpointHandle = (ctx, midpoint, scale) => {
4036
4475
  ctx.stroke();
4037
4476
  ctx.restore();
4038
4477
  };
4039
- var drawEdgeEndpointHandles = (ctx, source, target, scale) => {
4478
+ var drawEdgeEndpointHandles = (ctx, source, target, scale, color) => {
4040
4479
  const radiusPx = 5;
4041
4480
  const radiusWorld = radiusPx / scale;
4042
4481
  ctx.save();
4043
4482
  ctx.fillStyle = "#fff";
4044
- ctx.strokeStyle = SELECTION_COLOR;
4483
+ ctx.strokeStyle = color;
4045
4484
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4046
4485
  for (const p of [source, target]) {
4047
4486
  ctx.beginPath();
@@ -4051,17 +4490,53 @@ var drawEdgeEndpointHandles = (ctx, source, target, scale) => {
4051
4490
  }
4052
4491
  ctx.restore();
4053
4492
  };
4054
- var drawMarquee = (ctx, rect, scale) => {
4493
+ var drawMarquee = (ctx, rect, scale, color) => {
4055
4494
  ctx.save();
4056
- ctx.fillStyle = MARQUEE_FILL;
4495
+ ctx.globalAlpha = MARQUEE_FILL_ALPHA;
4496
+ ctx.fillStyle = color;
4057
4497
  ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
4058
- ctx.strokeStyle = SELECTION_COLOR;
4498
+ ctx.globalAlpha = 1;
4499
+ ctx.strokeStyle = color;
4059
4500
  ctx.lineWidth = MARQUEE_STROKE_PX / scale;
4060
4501
  ctx.setLineDash([4 / scale, 3 / scale]);
4061
4502
  ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
4062
4503
  ctx.restore();
4063
4504
  };
4064
4505
 
4506
+ // src/render/paint-frame.ts
4507
+ var FRAME_BORDER_PX = 1.5;
4508
+ var FRAME_BORDER_COLOR_DEFAULT = "#94a3b8";
4509
+ var FRAME_FILL_DEFAULT = "rgba(148, 163, 184, 0.06)";
4510
+ var FRAME_LABEL_FONT_PX = 12;
4511
+ var FRAME_LABEL_GAP_PX = 6;
4512
+ var FRAME_LABEL_COLOR = "#64748b";
4513
+ var paintFrameNode = (ctx, node, scale, theme) => {
4514
+ if (node.w <= 0 || node.h <= 0) return;
4515
+ const opacity = resolveOpacity(node.style, theme);
4516
+ const needsScope = opacity !== 1;
4517
+ if (needsScope) {
4518
+ ctx.save();
4519
+ ctx.globalAlpha = opacity;
4520
+ }
4521
+ const fill = node.style?.backgroundColor ?? (theme ? theme("frame.background") : void 0) ?? FRAME_FILL_DEFAULT;
4522
+ ctx.fillStyle = fill;
4523
+ ctx.fillRect(0, 0, node.w, node.h);
4524
+ const stroke = resolveColor(node.style, "strokeColor", FRAME_BORDER_COLOR_DEFAULT, theme);
4525
+ ctx.strokeStyle = stroke;
4526
+ ctx.lineWidth = FRAME_BORDER_PX / scale;
4527
+ ctx.setLineDash([]);
4528
+ ctx.strokeRect(0, 0, node.w, node.h);
4529
+ const labelPx = FRAME_LABEL_FONT_PX / scale;
4530
+ const gapPx = FRAME_LABEL_GAP_PX / scale;
4531
+ const label = node.content?.trim() || "Frame";
4532
+ ctx.fillStyle = FRAME_LABEL_COLOR;
4533
+ ctx.textBaseline = "bottom";
4534
+ ctx.textAlign = "left";
4535
+ ctx.font = `500 ${labelPx}px system-ui, -apple-system, sans-serif`;
4536
+ ctx.fillText(label, 0, -gapPx);
4537
+ if (needsScope) ctx.restore();
4538
+ };
4539
+
4065
4540
  // src/render/shapes/content-bounds.ts
4066
4541
  var SQRT2_INV = 1 / Math.SQRT2;
4067
4542
  var contentBounds = (node) => {
@@ -4134,12 +4609,19 @@ var createRenderer = (opts) => {
4134
4609
  const staticSurface = setupSurface(opts.staticCanvas);
4135
4610
  const interactiveSurface = setupSurface(opts.interactiveCanvas);
4136
4611
  let background = opts.background;
4612
+ let selectionColor = opts.selectionColor ?? DEFAULT_SELECTION_COLOR;
4613
+ let hideFrames = false;
4137
4614
  sizeSurface(staticSurface, opts.width, opts.height);
4138
4615
  sizeSurface(interactiveSurface, opts.width, opts.height);
4139
4616
  let staticDirty = true;
4140
4617
  let interactiveDirty = false;
4141
4618
  let overlaySet = /* @__PURE__ */ new Set();
4142
4619
  let lastDrawn = 0;
4620
+ const requestRepaint = () => {
4621
+ staticDirty = true;
4622
+ loop.requestFrame();
4623
+ };
4624
+ const assetCache = createAssetCache({ onReady: requestRepaint });
4143
4625
  const isInteractive = (state) => state.mode !== "idle" || store.getSelection().length > 0;
4144
4626
  const drawFrame = () => {
4145
4627
  if (staticDirty) {
@@ -4178,7 +4660,18 @@ var createRenderer = (opts) => {
4178
4660
  const cameraIsMoving = interaction.mode === "panning" || interaction.mode === "zooming";
4179
4661
  const movingNodeCount = excludedNodes?.size ?? 0;
4180
4662
  const roughEnabled = !cameraIsMoving && movingNodeCount <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM && visible.length <= ROUGH_MAX_NODES;
4663
+ if (!hideFrames) {
4664
+ for (const node of visible) {
4665
+ if (node.type !== "frame") continue;
4666
+ if (excludedNodes?.has(node.id)) continue;
4667
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4668
+ paintFrameNode(staticSurface.ctx, node, scale, theme);
4669
+ });
4670
+ drawn++;
4671
+ }
4672
+ }
4181
4673
  for (const node of visible) {
4674
+ if (node.type === "frame") continue;
4182
4675
  if (excludedNodes?.has(node.id)) continue;
4183
4676
  const isEditingThis = editingNodeId === node.id;
4184
4677
  if (isDrawablePrimitive(node.type)) {
@@ -4209,6 +4702,20 @@ var createRenderer = (opts) => {
4209
4702
  drawn++;
4210
4703
  continue;
4211
4704
  }
4705
+ if (node.type === "image") {
4706
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4707
+ paintImageNode(staticSurface.ctx, node, assetCache, theme);
4708
+ });
4709
+ drawn++;
4710
+ continue;
4711
+ }
4712
+ if (node.type === "icon") {
4713
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4714
+ paintIconNode(staticSurface.ctx, node, assetCache, scale, theme);
4715
+ });
4716
+ drawn++;
4717
+ continue;
4718
+ }
4212
4719
  if (node.type === "text") {
4213
4720
  drawWithNodeTransform(staticSurface.ctx, node, () => {
4214
4721
  if (isEditingThis) return;
@@ -4367,8 +4874,21 @@ var createRenderer = (opts) => {
4367
4874
  isMoving: true};
4368
4875
  const dragRoughEnabled = inDragMap.size <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM;
4369
4876
  for (const node of inDragMap.values()) {
4370
- if (!isDrawablePrimitive(node.type) && node.type !== "text") continue;
4877
+ if (!isDrawablePrimitive(node.type) && node.type !== "text" && node.type !== "image" && node.type !== "icon" && node.type !== "frame")
4878
+ continue;
4371
4879
  drawWithNodeTransform(ctx, node, () => {
4880
+ if (node.type === "frame") {
4881
+ paintFrameNode(ctx, node, scale, theme);
4882
+ return;
4883
+ }
4884
+ if (node.type === "image") {
4885
+ paintImageNode(ctx, node, assetCache, theme);
4886
+ return;
4887
+ }
4888
+ if (node.type === "icon") {
4889
+ paintIconNode(ctx, node, assetCache, scale, theme);
4890
+ return;
4891
+ }
4372
4892
  if (isDrawablePrimitive(node.type)) {
4373
4893
  const useRough = dragRoughEnabled && (node.style?.roughness ?? 0) > 0;
4374
4894
  const roughReady = useRough ? getRoughCanvasCtor() !== null : false;
@@ -4420,32 +4940,32 @@ var createRenderer = (opts) => {
4420
4940
  for (const id of selectedNodeIds) {
4421
4941
  const node = inDragMap.get(id) ?? store.getNode(id);
4422
4942
  if (!node) continue;
4423
- drawSelectionOutline(ctx, node, scale);
4943
+ drawSelectionOutline(ctx, node, scale, selectionColor);
4424
4944
  }
4425
4945
  if (interaction.mode !== "dragging" && selectedNodeIds.length === 1) {
4426
4946
  const node = inDragMap.get(selectedNodeIds[0]) ?? store.getNode(selectedNodeIds[0]);
4427
4947
  if (node) {
4428
- drawResizeHandles(ctx, node, scale);
4429
- drawRotateHandle(ctx, node, scale, camera.z);
4948
+ drawResizeHandles(ctx, node, scale, selectionColor);
4949
+ drawRotateHandle(ctx, node, scale, camera.z, selectionColor);
4430
4950
  }
4431
4951
  }
4432
4952
  }
4433
4953
  for (const id of selectedEdgeIds) {
4434
4954
  const geom = store.getEdgeGeometry(id);
4435
4955
  if (geom) {
4436
- drawEdgeEndpointHandles(ctx, geom.source, geom.target, scale);
4956
+ drawEdgeEndpointHandles(ctx, geom.source, geom.target, scale, selectionColor);
4437
4957
  const edge = store.getEdge(id);
4438
4958
  if (edge && edge.pathStyle === "bezier") {
4439
4959
  const mid = getPointAndTangentAtArcLength(geom.samples, 0.5).point;
4440
- drawEdgeMidpointHandle(ctx, mid, scale);
4960
+ drawEdgeMidpointHandle(ctx, mid, scale, selectionColor);
4441
4961
  }
4442
4962
  }
4443
4963
  }
4444
4964
  if (interaction.mode === "marqueeing" && interaction.marqueeRect) {
4445
- drawMarquee(ctx, interaction.marqueeRect, scale);
4965
+ drawMarquee(ctx, interaction.marqueeRect, scale, selectionColor);
4446
4966
  }
4447
4967
  if (interaction.mode === "creating-shape" && interaction.createDraftRect) {
4448
- drawMarquee(ctx, interaction.createDraftRect, scale);
4968
+ drawMarquee(ctx, interaction.createDraftRect, scale, selectionColor);
4449
4969
  }
4450
4970
  if ((interaction.mode === "creating-edge" || interaction.mode === "reconnecting-edge") && interaction.draftEdge) {
4451
4971
  const draft = {
@@ -4453,7 +4973,7 @@ var createRenderer = (opts) => {
4453
4973
  source: interaction.draftEdge.source,
4454
4974
  target: interaction.draftEdge.target,
4455
4975
  pathStyle: "bezier",
4456
- style: { strokeColor: "#3b82f6" }
4976
+ style: { strokeColor: selectionColor }
4457
4977
  };
4458
4978
  const geom = computeEdgeGeometry(draft, (id) => store.getNode(id));
4459
4979
  if (geom) {
@@ -4557,6 +5077,16 @@ var createRenderer = (opts) => {
4557
5077
  staticDirty = true;
4558
5078
  loop.requestFrame();
4559
5079
  },
5080
+ setSelectionColor(color) {
5081
+ selectionColor = color;
5082
+ interactiveDirty = true;
5083
+ loop.requestFrame();
5084
+ },
5085
+ setHideFrames(hidden) {
5086
+ hideFrames = hidden;
5087
+ staticDirty = true;
5088
+ loop.requestFrame();
5089
+ },
4560
5090
  stats: () => loop.stats(),
4561
5091
  lastDrawCount: () => lastDrawn,
4562
5092
  getOverlaySet: () => [...overlaySet],
@@ -4567,6 +5097,7 @@ var createRenderer = (opts) => {
4567
5097
  unsubSelection();
4568
5098
  unsubInteraction();
4569
5099
  unsubFontEpoch();
5100
+ assetCache.dispose();
4570
5101
  }
4571
5102
  };
4572
5103
  };
@@ -4586,6 +5117,7 @@ var sceneBounds = (store) => {
4586
5117
  let maxY = Number.NEGATIVE_INFINITY;
4587
5118
  for (const n of nodes) {
4588
5119
  if (n.hidden) continue;
5120
+ if (n.type === "frame") continue;
4589
5121
  const r = nodeAABB(n);
4590
5122
  if (r.x < minX) minX = r.x;
4591
5123
  if (r.y < minY) minY = r.y;
@@ -4616,6 +5148,7 @@ var renderMinimapContent = (ctx, store, mapWidth, mapHeight, opts = {}) => {
4616
5148
  const defaultColor = opts.defaultNodeColor ?? "#94a3b8";
4617
5149
  for (const node of store.getAllNodes()) {
4618
5150
  if (node.hidden) continue;
5151
+ if (node.type === "frame") continue;
4619
5152
  const r = nodeAABB(node);
4620
5153
  const x = offX + (r.x - bx) * scale;
4621
5154
  const y = offY + (r.y - by) * scale;
@@ -5616,6 +6149,8 @@ exports.FONT_FAMILY_MAP = FONT_FAMILY_MAP;
5616
6149
  exports.FONT_SIZE_MAP = FONT_SIZE_MAP;
5617
6150
  exports.LINE_HEIGHT_MAP = LINE_HEIGHT_MAP;
5618
6151
  exports.LINK_COLOR = LINK_COLOR;
6152
+ exports.MAX_IMAGE_BYTES = MAX_IMAGE_BYTES;
6153
+ exports.MAX_SVG_BYTES = MAX_SVG_BYTES;
5619
6154
  exports.MAX_ZOOM = MAX_ZOOM;
5620
6155
  exports.MIN_ZOOM = MIN_ZOOM;
5621
6156
  exports.PALM_REJECTION_GRACE_MS = PALM_REJECTION_GRACE_MS;
@@ -5627,6 +6162,7 @@ exports.SCHEMA_VERSION = SCHEMA_VERSION;
5627
6162
  exports.UniformGrid = UniformGrid;
5628
6163
  exports.VERSION = VERSION;
5629
6164
  exports.applyCameraTransform = applyCameraTransform;
6165
+ exports.applySvgColor = applySvgColor;
5630
6166
  exports.arrowheadLength = arrowheadLength;
5631
6167
  exports.asBatchId = asBatchId;
5632
6168
  exports.asClientId = asClientId;
@@ -5635,6 +6171,7 @@ exports.asGroupId = asGroupId;
5635
6171
  exports.asNodeId = asNodeId;
5636
6172
  exports.attachSync = attachSync;
5637
6173
  exports.autoRouteControls = autoRouteControls;
6174
+ exports.blobToDataUri = blobToDataUri;
5638
6175
  exports.clampEffectiveScale = clampEffectiveScale;
5639
6176
  exports.clampZoom = clampZoom;
5640
6177
  exports.clearMeasureCache = clearMeasureCache;
@@ -5656,6 +6193,7 @@ exports.defineExtension = defineExtension;
5656
6193
  exports.defineNode = defineNode;
5657
6194
  exports.deserializeClipboard = deserializeClipboard;
5658
6195
  exports.detectConflicts = detectConflicts;
6196
+ exports.downscaleImageBlob = downscaleImageBlob;
5659
6197
  exports.drawArrowhead = drawArrowhead;
5660
6198
  exports.drawEdge = drawEdge;
5661
6199
  exports.drawMinimapViewport = drawMinimapViewport;
@@ -5669,6 +6207,7 @@ exports.estimateMarkdownContentHeight = estimateMarkdownContentHeight;
5669
6207
  exports.exportSelection = exportSelection;
5670
6208
  exports.exportSelectionSvg = exportSelectionSvg;
5671
6209
  exports.exportViewport = exportViewport;
6210
+ exports.extractSvgDimensions = extractSvgDimensions;
5672
6211
  exports.fromSerialized = fromSerialized;
5673
6212
  exports.fullVisibleClipResult = fullVisibleClipResult;
5674
6213
  exports.getCanvasFont = getCanvasFont;
@@ -5735,6 +6274,7 @@ exports.rotateVecByAngle = rotateVecByAngle;
5735
6274
  exports.sampleBezier = sampleBezier;
5736
6275
  exports.sampleSelfLoop = sampleSelfLoop;
5737
6276
  exports.samplesFor = samplesFor;
6277
+ exports.sanitizeSvg = sanitizeSvg;
5738
6278
  exports.sceneBounds = sceneBounds;
5739
6279
  exports.screenToWorld = screenToWorld;
5740
6280
  exports.selfLoopGeometry = selfLoopGeometry;
@@ -5748,6 +6288,7 @@ exports.sizeSurface = sizeSurface;
5748
6288
  exports.storeToJSON = storeToJSON;
5749
6289
  exports.subscribeFontEpoch = subscribeFontEpoch;
5750
6290
  exports.tangentAtArcLength = tangentAtArcLength;
6291
+ exports.toImageBlob = toImageBlob;
5751
6292
  exports.toSerialized = toSerialized;
5752
6293
  exports.toggleBold = toggleBold;
5753
6294
  exports.toggleCode = toggleCode;
@@ -5756,6 +6297,8 @@ exports.toggleStrike = toggleStrike;
5756
6297
  exports.toggleUnderline = toggleUnderline;
5757
6298
  exports.tokenize = tokenize;
5758
6299
  exports.unionRects = unionRects;
6300
+ exports.validateImageInput = validateImageInput;
6301
+ exports.validateSvgMarkup = validateSvgMarkup;
5759
6302
  exports.viewportWorldRect = viewportWorldRect;
5760
6303
  exports.withAutoFitHeight = withAutoFitHeight;
5761
6304
  exports.worldToNodeLocal = worldToNodeLocal;