@canvas-harness/core 0.0.1 → 0.0.2

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.js CHANGED
@@ -2906,6 +2906,155 @@ var createDefaultTextareaEditor = ({
2906
2906
  };
2907
2907
  };
2908
2908
 
2909
+ // src/assets/image.ts
2910
+ var MAX_IMAGE_BYTES = 2 * 1024 * 1024;
2911
+ var ACCEPTED_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg"]);
2912
+ var validateImageInput = (input) => {
2913
+ if (typeof input === "string") {
2914
+ if (!input.startsWith("data:")) {
2915
+ throw new Error(
2916
+ "addImage: external URL strings are not supported. Pass a File, Blob, or `data:image/(png|jpeg)` URI."
2917
+ );
2918
+ }
2919
+ const mimeMatch = /^data:([^;,]+)/.exec(input);
2920
+ const mime = mimeMatch?.[1] ?? "";
2921
+ if (!ACCEPTED_MIME.has(mime)) {
2922
+ throw new Error(
2923
+ `addImage: unsupported MIME "${mime || "(unknown)"}". Only image/png and image/jpeg are supported.`
2924
+ );
2925
+ }
2926
+ const comma = input.indexOf(",");
2927
+ if (comma < 0) throw new Error("addImage: malformed data URI (missing payload separator)");
2928
+ const decodedBytes = Math.floor((input.length - comma - 1) * 3 / 4);
2929
+ if (decodedBytes > MAX_IMAGE_BYTES) {
2930
+ throw new Error(
2931
+ `addImage: image exceeds the 2 MB limit (${Math.round(decodedBytes / 1024)} KB).`
2932
+ );
2933
+ }
2934
+ return;
2935
+ }
2936
+ if (!ACCEPTED_MIME.has(input.type)) {
2937
+ throw new Error(
2938
+ `addImage: unsupported file type "${input.type || "(unknown)"}". Only image/png and image/jpeg are supported.`
2939
+ );
2940
+ }
2941
+ if (input.size > MAX_IMAGE_BYTES) {
2942
+ throw new Error(`addImage: file exceeds the 2 MB limit (${Math.round(input.size / 1024)} KB).`);
2943
+ }
2944
+ };
2945
+ var toImageBlob = async (input) => {
2946
+ if (typeof input === "string") {
2947
+ const res = await fetch(input);
2948
+ return res.blob();
2949
+ }
2950
+ return input;
2951
+ };
2952
+ var downscaleImageBlob = async (blob, maxDim) => {
2953
+ const bitmap = await createImageBitmap(blob);
2954
+ const naturalW = bitmap.width;
2955
+ const naturalH = bitmap.height;
2956
+ const maxSide = Math.max(naturalW, naturalH);
2957
+ if (maxDim <= 0 || maxSide <= maxDim) {
2958
+ bitmap.close?.();
2959
+ return { blob, naturalW, naturalH };
2960
+ }
2961
+ const scale = maxDim / maxSide;
2962
+ const w = Math.max(1, Math.round(naturalW * scale));
2963
+ const h = Math.max(1, Math.round(naturalH * scale));
2964
+ const canvas = new OffscreenCanvas(w, h);
2965
+ const ctx = canvas.getContext("2d");
2966
+ if (!ctx) throw new Error("addImage: failed to acquire OffscreenCanvas 2d context");
2967
+ ctx.drawImage(bitmap, 0, 0, w, h);
2968
+ bitmap.close?.();
2969
+ const outType = blob.type === "image/png" ? "image/png" : "image/jpeg";
2970
+ const outBlob = await canvas.convertToBlob({ type: outType, quality: 0.9 });
2971
+ return { blob: outBlob, naturalW: w, naturalH: h };
2972
+ };
2973
+ var blobToDataUri = (blob) => {
2974
+ return new Promise((resolve, reject) => {
2975
+ const reader = new FileReader();
2976
+ reader.onload = () => {
2977
+ if (typeof reader.result === "string") resolve(reader.result);
2978
+ else reject(new Error("FileReader returned non-string result"));
2979
+ };
2980
+ reader.onerror = () => reject(reader.error ?? new Error("FileReader failed"));
2981
+ reader.readAsDataURL(blob);
2982
+ });
2983
+ };
2984
+
2985
+ // src/assets/svg.ts
2986
+ var MAX_SVG_BYTES = 2 * 1024 * 1024;
2987
+ var DEFAULT_SVG_SIZE = 24;
2988
+ var validateSvgMarkup = (markup) => {
2989
+ if (typeof markup !== "string") {
2990
+ throw new Error("addSvg: src must be a string of SVG markup");
2991
+ }
2992
+ const byteLen = new Blob([markup]).size;
2993
+ if (byteLen > MAX_SVG_BYTES) {
2994
+ throw new Error(`addSvg: SVG markup exceeds the 2 MB limit (${Math.round(byteLen / 1024)} KB).`);
2995
+ }
2996
+ if (!/<svg[\s>]/i.test(markup)) {
2997
+ throw new Error("addSvg: src does not look like SVG markup (no <svg> tag found)");
2998
+ }
2999
+ };
3000
+ var sanitizeSvg = (markup) => {
3001
+ const parser = new DOMParser();
3002
+ const doc = parser.parseFromString(markup, "image/svg+xml");
3003
+ const root = doc.documentElement;
3004
+ if (root.nodeName === "parsererror" || root.querySelector("parsererror")) {
3005
+ throw new Error("addSvg: malformed SVG (parser error)");
3006
+ }
3007
+ const removable = [];
3008
+ const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_ELEMENT);
3009
+ let n = walker.nextNode();
3010
+ while (n) {
3011
+ const el = n;
3012
+ const tag = el.tagName.toLowerCase();
3013
+ if (tag === "script" || tag === "foreignobject") {
3014
+ removable.push(el);
3015
+ } else {
3016
+ for (const attr of [...el.attributes]) {
3017
+ const name = attr.name.toLowerCase();
3018
+ const value = attr.value.trim().toLowerCase();
3019
+ if (name.startsWith("on")) {
3020
+ el.removeAttribute(attr.name);
3021
+ } else if ((name === "href" || name === "xlink:href" || name === "src") && value.startsWith("javascript:")) {
3022
+ el.removeAttribute(attr.name);
3023
+ }
3024
+ }
3025
+ }
3026
+ n = walker.nextNode();
3027
+ }
3028
+ for (const el of removable) el.remove();
3029
+ return new XMLSerializer().serializeToString(doc);
3030
+ };
3031
+ var extractSvgDimensions = (markup) => {
3032
+ const parser = new DOMParser();
3033
+ const doc = parser.parseFromString(markup, "image/svg+xml");
3034
+ const svg = doc.documentElement;
3035
+ if (svg.nodeName.toLowerCase() !== "svg") {
3036
+ return { w: DEFAULT_SVG_SIZE, h: DEFAULT_SVG_SIZE };
3037
+ }
3038
+ const widthAttr = svg.getAttribute("width");
3039
+ const heightAttr = svg.getAttribute("height");
3040
+ if (widthAttr && heightAttr) {
3041
+ const w = Number.parseFloat(widthAttr);
3042
+ const h = Number.parseFloat(heightAttr);
3043
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return { w, h };
3044
+ }
3045
+ const viewBox = svg.getAttribute("viewBox");
3046
+ if (viewBox) {
3047
+ const parts = viewBox.split(/[\s,]+/).map(Number.parseFloat);
3048
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
3049
+ return { w: parts[2], h: parts[3] };
3050
+ }
3051
+ }
3052
+ return { w: DEFAULT_SVG_SIZE, h: DEFAULT_SVG_SIZE };
3053
+ };
3054
+ var applySvgColor = (markup, color) => {
3055
+ return markup.replace(/currentColor/gi, color);
3056
+ };
3057
+
2909
3058
  // src/store/conflict.ts
2910
3059
  var detectConflicts = (batch, getNode, getEdge) => {
2911
3060
  const out = [];
@@ -3326,6 +3475,55 @@ var createCanvasStore = (opts = {}) => {
3326
3475
  if (batch) emitChange(batch);
3327
3476
  });
3328
3477
  },
3478
+ async addImage(opts2) {
3479
+ validateImageInput(opts2.src);
3480
+ const rawBlob = await toImageBlob(opts2.src);
3481
+ const maxDim = opts2.maxDimension ?? 2048;
3482
+ const { blob, naturalW, naturalH } = await downscaleImageBlob(rawBlob, maxDim);
3483
+ const src = await blobToDataUri(blob);
3484
+ const DEFAULT_MAX_NODE_SIDE = 400;
3485
+ const aspectScale = Math.min(1, DEFAULT_MAX_NODE_SIDE / Math.max(naturalW, naturalH));
3486
+ const w = opts2.w ?? Math.max(1, Math.round(naturalW * aspectScale));
3487
+ const h = opts2.h ?? Math.max(1, Math.round(naturalH * aspectScale));
3488
+ const id = asNodeId(idGenerator());
3489
+ this.addNode({
3490
+ id,
3491
+ type: "image",
3492
+ x: opts2.x,
3493
+ y: opts2.y,
3494
+ w,
3495
+ h,
3496
+ angle: 0,
3497
+ z: 0,
3498
+ groups: [],
3499
+ style: opts2.style,
3500
+ data: { src, naturalW, naturalH, alt: opts2.alt }
3501
+ });
3502
+ return id;
3503
+ },
3504
+ async addSvg(opts2) {
3505
+ validateSvgMarkup(opts2.src);
3506
+ const sanitized = sanitizeSvg(opts2.src);
3507
+ const intrinsic = extractSvgDimensions(sanitized);
3508
+ const w = opts2.w ?? intrinsic.w;
3509
+ const h = opts2.h ?? intrinsic.h;
3510
+ const mergedStyle = opts2.color || opts2.style ? { ...opts2.color ? { iconColor: opts2.color } : {}, ...opts2.style } : void 0;
3511
+ const id = asNodeId(idGenerator());
3512
+ this.addNode({
3513
+ id,
3514
+ type: "icon",
3515
+ x: opts2.x,
3516
+ y: opts2.y,
3517
+ w,
3518
+ h,
3519
+ angle: 0,
3520
+ z: 0,
3521
+ groups: [],
3522
+ ...mergedStyle ? { style: mergedStyle } : {},
3523
+ data: { src: sanitized, alt: opts2.alt }
3524
+ });
3525
+ return id;
3526
+ },
3329
3527
  addEdge(edge) {
3330
3528
  const withZ = edge.z === 0 ? { ...edge, z: ++topZ } : edge;
3331
3529
  if (withZ.z > topZ) topZ = withZ.z;
@@ -3798,6 +3996,182 @@ var createFrameLoop = ({ draw, historySize = 60 }) => {
3798
3996
  };
3799
3997
  };
3800
3998
 
3999
+ // src/render/assets/cache.ts
4000
+ var MAX_ENTRIES2 = 256;
4001
+ var bucketSize = (px) => {
4002
+ if (px <= 32) return 32;
4003
+ if (px <= 64) return 64;
4004
+ if (px <= 128) return 128;
4005
+ if (px <= 256) return 256;
4006
+ if (px <= 512) return 512;
4007
+ return Math.ceil(px / 256) * 256;
4008
+ };
4009
+ var createAssetCache = (opts = {}) => {
4010
+ const entries = /* @__PURE__ */ new Map();
4011
+ let disposed = false;
4012
+ const notify = () => {
4013
+ if (disposed) return;
4014
+ opts.onReady?.();
4015
+ };
4016
+ const touch = (key, entry) => {
4017
+ entries.delete(key);
4018
+ entries.set(key, entry);
4019
+ if (entries.size > MAX_ENTRIES2) {
4020
+ const oldestKey = entries.keys().next().value;
4021
+ if (oldestKey !== void 0) {
4022
+ const evicted = entries.get(oldestKey);
4023
+ if (evicted?.kind === "icon" && evicted.bitmap) evicted.bitmap.close?.();
4024
+ entries.delete(oldestKey);
4025
+ }
4026
+ }
4027
+ };
4028
+ const startImageDecode = (key, src) => {
4029
+ const entry = { kind: "image", state: "pending", bitmap: null };
4030
+ touch(key, entry);
4031
+ const img = new Image();
4032
+ img.onload = () => {
4033
+ if (disposed) return;
4034
+ entry.state = "ready";
4035
+ entry.bitmap = img;
4036
+ notify();
4037
+ };
4038
+ img.onerror = (e) => {
4039
+ if (disposed) return;
4040
+ entry.state = "error";
4041
+ entry.err = e;
4042
+ notify();
4043
+ };
4044
+ img.src = src;
4045
+ };
4046
+ const startIconRaster = (key, markup, color, sizePx) => {
4047
+ const entry = { kind: "icon", state: "pending", bitmap: null };
4048
+ touch(key, entry);
4049
+ const colored = color ? applySvgColor(markup, color) : markup;
4050
+ const blob = new Blob([colored], { type: "image/svg+xml" });
4051
+ const url = URL.createObjectURL(blob);
4052
+ const img = new Image();
4053
+ img.onload = async () => {
4054
+ URL.revokeObjectURL(url);
4055
+ if (disposed) return;
4056
+ try {
4057
+ const bitmap = await createImageBitmap(img, {
4058
+ resizeWidth: sizePx,
4059
+ resizeHeight: sizePx,
4060
+ resizeQuality: "high"
4061
+ });
4062
+ if (disposed) {
4063
+ bitmap.close?.();
4064
+ return;
4065
+ }
4066
+ entry.state = "ready";
4067
+ entry.bitmap = bitmap;
4068
+ notify();
4069
+ } catch (e) {
4070
+ entry.state = "error";
4071
+ entry.err = e;
4072
+ notify();
4073
+ }
4074
+ };
4075
+ img.onerror = (e) => {
4076
+ URL.revokeObjectURL(url);
4077
+ if (disposed) return;
4078
+ entry.state = "error";
4079
+ entry.err = e;
4080
+ notify();
4081
+ };
4082
+ img.src = url;
4083
+ };
4084
+ return {
4085
+ getImage(src) {
4086
+ const key = `img:${src}`;
4087
+ const existing = entries.get(key);
4088
+ if (existing && existing.kind === "image") {
4089
+ if (existing.state === "ready") {
4090
+ touch(key, existing);
4091
+ return existing.bitmap;
4092
+ }
4093
+ return null;
4094
+ }
4095
+ startImageDecode(key, src);
4096
+ return null;
4097
+ },
4098
+ getIcon(markup, color, devicePixelSize) {
4099
+ const size = bucketSize(Math.max(1, Math.ceil(devicePixelSize)));
4100
+ const key = `icon:${size}:${color ?? ""}:${markup}`;
4101
+ const existing = entries.get(key);
4102
+ if (existing && existing.kind === "icon") {
4103
+ if (existing.state === "ready") {
4104
+ touch(key, existing);
4105
+ return existing.bitmap;
4106
+ }
4107
+ return null;
4108
+ }
4109
+ startIconRaster(key, markup, color, size);
4110
+ return null;
4111
+ },
4112
+ dispose() {
4113
+ disposed = true;
4114
+ for (const entry of entries.values()) {
4115
+ if (entry.kind === "icon" && entry.bitmap) entry.bitmap.close?.();
4116
+ }
4117
+ entries.clear();
4118
+ }
4119
+ };
4120
+ };
4121
+
4122
+ // src/render/assets/paint.ts
4123
+ var PLACEHOLDER_FILL = "#e5e7eb";
4124
+ var PLACEHOLDER_TEXT_FILL = "#94a3b8";
4125
+ var paintPlaceholder = (ctx, w, h, label) => {
4126
+ ctx.fillStyle = PLACEHOLDER_FILL;
4127
+ ctx.fillRect(0, 0, w, h);
4128
+ if (w >= 32 && h >= 16) {
4129
+ ctx.fillStyle = PLACEHOLDER_TEXT_FILL;
4130
+ ctx.font = "11px system-ui, sans-serif";
4131
+ ctx.textAlign = "center";
4132
+ ctx.textBaseline = "middle";
4133
+ ctx.fillText(label, w / 2, h / 2);
4134
+ }
4135
+ };
4136
+ var paintImageNode = (ctx, node, cache4, theme) => {
4137
+ if (node.w <= 0 || node.h <= 0) return;
4138
+ const data = node.data;
4139
+ if (!data?.src) return;
4140
+ const bitmap = cache4.getImage(data.src);
4141
+ const opacity = resolveOpacity(node.style, theme);
4142
+ const needsScope = opacity !== 1;
4143
+ if (needsScope) {
4144
+ ctx.save();
4145
+ ctx.globalAlpha = opacity;
4146
+ }
4147
+ if (bitmap?.complete) {
4148
+ ctx.drawImage(bitmap, 0, 0, node.w, node.h);
4149
+ } else {
4150
+ paintPlaceholder(ctx, node.w, node.h, "loading\u2026");
4151
+ }
4152
+ if (needsScope) ctx.restore();
4153
+ };
4154
+ var paintIconNode = (ctx, node, cache4, scale, theme) => {
4155
+ if (node.w <= 0 || node.h <= 0) return;
4156
+ const data = node.data;
4157
+ if (!data?.src) return;
4158
+ const sizePx = Math.max(node.w, node.h) * scale;
4159
+ const color = node.style?.iconColor;
4160
+ const bitmap = cache4.getIcon(data.src, color, sizePx);
4161
+ const opacity = resolveOpacity(node.style, theme);
4162
+ const needsScope = opacity !== 1;
4163
+ if (needsScope) {
4164
+ ctx.save();
4165
+ ctx.globalAlpha = opacity;
4166
+ }
4167
+ if (bitmap) {
4168
+ ctx.drawImage(bitmap, 0, 0, node.w, node.h);
4169
+ } else {
4170
+ paintPlaceholder(ctx, node.w, node.h, "svg\u2026");
4171
+ }
4172
+ if (needsScope) ctx.restore();
4173
+ };
4174
+
3801
4175
  // src/render/background.ts
3802
4176
  var MIN_PATTERN_SCREEN_PX = 8;
3803
4177
  var MIN_VISIBLE_PATTERN_PX = 2;
@@ -3939,14 +4313,14 @@ var hitTestRotateHandle = (node, worldPoint, cameraZ) => {
3939
4313
  };
3940
4314
 
3941
4315
  // src/render/overlay.ts
3942
- var SELECTION_COLOR = "#3b82f6";
4316
+ var DEFAULT_SELECTION_COLOR = "#3b82f6";
3943
4317
  var SELECTION_OUTLINE_PX = 1.5;
3944
- var MARQUEE_FILL = "rgba(59, 130, 246, 0.08)";
4318
+ var MARQUEE_FILL_ALPHA = 0.08;
3945
4319
  var MARQUEE_STROKE_PX = 1;
3946
- var drawSelectionOutline = (ctx, node, scale) => {
4320
+ var drawSelectionOutline = (ctx, node, scale, color) => {
3947
4321
  if (node.angle === 0) {
3948
4322
  ctx.save();
3949
- ctx.strokeStyle = SELECTION_COLOR;
4323
+ ctx.strokeStyle = color;
3950
4324
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3951
4325
  ctx.beginPath();
3952
4326
  ctx.rect(node.x, node.y, node.w, node.h);
@@ -3965,7 +4339,7 @@ var drawSelectionOutline = (ctx, node, scale) => {
3965
4339
  { x: -node.w / 2, y: node.h / 2 }
3966
4340
  ].map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos }));
3967
4341
  ctx.save();
3968
- ctx.strokeStyle = SELECTION_COLOR;
4342
+ ctx.strokeStyle = color;
3969
4343
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3970
4344
  ctx.beginPath();
3971
4345
  const first = corners[0];
@@ -3978,13 +4352,13 @@ var drawSelectionOutline = (ctx, node, scale) => {
3978
4352
  ctx.stroke();
3979
4353
  ctx.restore();
3980
4354
  };
3981
- var drawResizeHandles = (ctx, node, scale) => {
4355
+ var drawResizeHandles = (ctx, node, scale, color) => {
3982
4356
  const halfPx = RESIZE_HANDLE_SIZE_PX / 2;
3983
4357
  const halfWorld = halfPx / scale;
3984
4358
  const positions = handleWorldPositions(node);
3985
4359
  ctx.save();
3986
4360
  ctx.fillStyle = "#fff";
3987
- ctx.strokeStyle = SELECTION_COLOR;
4361
+ ctx.strokeStyle = color;
3988
4362
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
3989
4363
  for (const key of Object.keys(positions)) {
3990
4364
  const p = positions[key];
@@ -3995,7 +4369,7 @@ var drawResizeHandles = (ctx, node, scale) => {
3995
4369
  }
3996
4370
  ctx.restore();
3997
4371
  };
3998
- var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4372
+ var drawRotateHandle = (ctx, node, scale, cameraZ, color) => {
3999
4373
  const center = rotateHandleWorldPosition(node, cameraZ);
4000
4374
  const radiusWorld = ROTATE_HANDLE_RADIUS_PX / scale;
4001
4375
  const cx = node.x + node.w / 2;
@@ -4008,7 +4382,7 @@ var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4008
4382
  y: cy + 0 * sin + topMidLocalY * cos
4009
4383
  };
4010
4384
  ctx.save();
4011
- ctx.strokeStyle = SELECTION_COLOR;
4385
+ ctx.strokeStyle = color;
4012
4386
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4013
4387
  ctx.beginPath();
4014
4388
  ctx.moveTo(topMidWorld.x, topMidWorld.y);
@@ -4021,12 +4395,12 @@ var drawRotateHandle = (ctx, node, scale, cameraZ) => {
4021
4395
  ctx.stroke();
4022
4396
  ctx.restore();
4023
4397
  };
4024
- var drawEdgeMidpointHandle = (ctx, midpoint, scale) => {
4398
+ var drawEdgeMidpointHandle = (ctx, midpoint, scale, color) => {
4025
4399
  const radiusPx = 5;
4026
4400
  const radiusWorld = radiusPx / scale;
4027
4401
  ctx.save();
4028
4402
  ctx.fillStyle = "#fff";
4029
- ctx.strokeStyle = SELECTION_COLOR;
4403
+ ctx.strokeStyle = color;
4030
4404
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4031
4405
  ctx.beginPath();
4032
4406
  ctx.arc(midpoint.x, midpoint.y, radiusWorld, 0, Math.PI * 2);
@@ -4034,12 +4408,12 @@ var drawEdgeMidpointHandle = (ctx, midpoint, scale) => {
4034
4408
  ctx.stroke();
4035
4409
  ctx.restore();
4036
4410
  };
4037
- var drawEdgeEndpointHandles = (ctx, source, target, scale) => {
4411
+ var drawEdgeEndpointHandles = (ctx, source, target, scale, color) => {
4038
4412
  const radiusPx = 5;
4039
4413
  const radiusWorld = radiusPx / scale;
4040
4414
  ctx.save();
4041
4415
  ctx.fillStyle = "#fff";
4042
- ctx.strokeStyle = SELECTION_COLOR;
4416
+ ctx.strokeStyle = color;
4043
4417
  ctx.lineWidth = SELECTION_OUTLINE_PX / scale;
4044
4418
  for (const p of [source, target]) {
4045
4419
  ctx.beginPath();
@@ -4049,11 +4423,13 @@ var drawEdgeEndpointHandles = (ctx, source, target, scale) => {
4049
4423
  }
4050
4424
  ctx.restore();
4051
4425
  };
4052
- var drawMarquee = (ctx, rect, scale) => {
4426
+ var drawMarquee = (ctx, rect, scale, color) => {
4053
4427
  ctx.save();
4054
- ctx.fillStyle = MARQUEE_FILL;
4428
+ ctx.globalAlpha = MARQUEE_FILL_ALPHA;
4429
+ ctx.fillStyle = color;
4055
4430
  ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
4056
- ctx.strokeStyle = SELECTION_COLOR;
4431
+ ctx.globalAlpha = 1;
4432
+ ctx.strokeStyle = color;
4057
4433
  ctx.lineWidth = MARQUEE_STROKE_PX / scale;
4058
4434
  ctx.setLineDash([4 / scale, 3 / scale]);
4059
4435
  ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
@@ -4132,12 +4508,18 @@ var createRenderer = (opts) => {
4132
4508
  const staticSurface = setupSurface(opts.staticCanvas);
4133
4509
  const interactiveSurface = setupSurface(opts.interactiveCanvas);
4134
4510
  let background = opts.background;
4511
+ let selectionColor = opts.selectionColor ?? DEFAULT_SELECTION_COLOR;
4135
4512
  sizeSurface(staticSurface, opts.width, opts.height);
4136
4513
  sizeSurface(interactiveSurface, opts.width, opts.height);
4137
4514
  let staticDirty = true;
4138
4515
  let interactiveDirty = false;
4139
4516
  let overlaySet = /* @__PURE__ */ new Set();
4140
4517
  let lastDrawn = 0;
4518
+ const requestRepaint = () => {
4519
+ staticDirty = true;
4520
+ loop.requestFrame();
4521
+ };
4522
+ const assetCache = createAssetCache({ onReady: requestRepaint });
4141
4523
  const isInteractive = (state) => state.mode !== "idle" || store.getSelection().length > 0;
4142
4524
  const drawFrame = () => {
4143
4525
  if (staticDirty) {
@@ -4207,6 +4589,20 @@ var createRenderer = (opts) => {
4207
4589
  drawn++;
4208
4590
  continue;
4209
4591
  }
4592
+ if (node.type === "image") {
4593
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4594
+ paintImageNode(staticSurface.ctx, node, assetCache, theme);
4595
+ });
4596
+ drawn++;
4597
+ continue;
4598
+ }
4599
+ if (node.type === "icon") {
4600
+ drawWithNodeTransform(staticSurface.ctx, node, () => {
4601
+ paintIconNode(staticSurface.ctx, node, assetCache, scale, theme);
4602
+ });
4603
+ drawn++;
4604
+ continue;
4605
+ }
4210
4606
  if (node.type === "text") {
4211
4607
  drawWithNodeTransform(staticSurface.ctx, node, () => {
4212
4608
  if (isEditingThis) return;
@@ -4365,8 +4761,17 @@ var createRenderer = (opts) => {
4365
4761
  isMoving: true};
4366
4762
  const dragRoughEnabled = inDragMap.size <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM;
4367
4763
  for (const node of inDragMap.values()) {
4368
- if (!isDrawablePrimitive(node.type) && node.type !== "text") continue;
4764
+ if (!isDrawablePrimitive(node.type) && node.type !== "text" && node.type !== "image" && node.type !== "icon")
4765
+ continue;
4369
4766
  drawWithNodeTransform(ctx, node, () => {
4767
+ if (node.type === "image") {
4768
+ paintImageNode(ctx, node, assetCache, theme);
4769
+ return;
4770
+ }
4771
+ if (node.type === "icon") {
4772
+ paintIconNode(ctx, node, assetCache, scale, theme);
4773
+ return;
4774
+ }
4370
4775
  if (isDrawablePrimitive(node.type)) {
4371
4776
  const useRough = dragRoughEnabled && (node.style?.roughness ?? 0) > 0;
4372
4777
  const roughReady = useRough ? getRoughCanvasCtor() !== null : false;
@@ -4418,32 +4823,32 @@ var createRenderer = (opts) => {
4418
4823
  for (const id of selectedNodeIds) {
4419
4824
  const node = inDragMap.get(id) ?? store.getNode(id);
4420
4825
  if (!node) continue;
4421
- drawSelectionOutline(ctx, node, scale);
4826
+ drawSelectionOutline(ctx, node, scale, selectionColor);
4422
4827
  }
4423
4828
  if (interaction.mode !== "dragging" && selectedNodeIds.length === 1) {
4424
4829
  const node = inDragMap.get(selectedNodeIds[0]) ?? store.getNode(selectedNodeIds[0]);
4425
4830
  if (node) {
4426
- drawResizeHandles(ctx, node, scale);
4427
- drawRotateHandle(ctx, node, scale, camera.z);
4831
+ drawResizeHandles(ctx, node, scale, selectionColor);
4832
+ drawRotateHandle(ctx, node, scale, camera.z, selectionColor);
4428
4833
  }
4429
4834
  }
4430
4835
  }
4431
4836
  for (const id of selectedEdgeIds) {
4432
4837
  const geom = store.getEdgeGeometry(id);
4433
4838
  if (geom) {
4434
- drawEdgeEndpointHandles(ctx, geom.source, geom.target, scale);
4839
+ drawEdgeEndpointHandles(ctx, geom.source, geom.target, scale, selectionColor);
4435
4840
  const edge = store.getEdge(id);
4436
4841
  if (edge && edge.pathStyle === "bezier") {
4437
4842
  const mid = getPointAndTangentAtArcLength(geom.samples, 0.5).point;
4438
- drawEdgeMidpointHandle(ctx, mid, scale);
4843
+ drawEdgeMidpointHandle(ctx, mid, scale, selectionColor);
4439
4844
  }
4440
4845
  }
4441
4846
  }
4442
4847
  if (interaction.mode === "marqueeing" && interaction.marqueeRect) {
4443
- drawMarquee(ctx, interaction.marqueeRect, scale);
4848
+ drawMarquee(ctx, interaction.marqueeRect, scale, selectionColor);
4444
4849
  }
4445
4850
  if (interaction.mode === "creating-shape" && interaction.createDraftRect) {
4446
- drawMarquee(ctx, interaction.createDraftRect, scale);
4851
+ drawMarquee(ctx, interaction.createDraftRect, scale, selectionColor);
4447
4852
  }
4448
4853
  if ((interaction.mode === "creating-edge" || interaction.mode === "reconnecting-edge") && interaction.draftEdge) {
4449
4854
  const draft = {
@@ -4451,7 +4856,7 @@ var createRenderer = (opts) => {
4451
4856
  source: interaction.draftEdge.source,
4452
4857
  target: interaction.draftEdge.target,
4453
4858
  pathStyle: "bezier",
4454
- style: { strokeColor: "#3b82f6" }
4859
+ style: { strokeColor: selectionColor }
4455
4860
  };
4456
4861
  const geom = computeEdgeGeometry(draft, (id) => store.getNode(id));
4457
4862
  if (geom) {
@@ -4555,6 +4960,11 @@ var createRenderer = (opts) => {
4555
4960
  staticDirty = true;
4556
4961
  loop.requestFrame();
4557
4962
  },
4963
+ setSelectionColor(color) {
4964
+ selectionColor = color;
4965
+ interactiveDirty = true;
4966
+ loop.requestFrame();
4967
+ },
4558
4968
  stats: () => loop.stats(),
4559
4969
  lastDrawCount: () => lastDrawn,
4560
4970
  getOverlaySet: () => [...overlaySet],
@@ -4565,6 +4975,7 @@ var createRenderer = (opts) => {
4565
4975
  unsubSelection();
4566
4976
  unsubInteraction();
4567
4977
  unsubFontEpoch();
4978
+ assetCache.dispose();
4568
4979
  }
4569
4980
  };
4570
4981
  };
@@ -5594,6 +6005,6 @@ var installedExtensions = (store) => {
5594
6005
  // src/index.ts
5595
6006
  var VERSION = "0.0.0";
5596
6007
 
5597
- export { BEZIER_SEGMENTS, CODE_BG_COLOR, CODE_BLOCK_MARGIN_Y, CODE_BLOCK_PADDING_X, CONTENT_HEIGHT_BUFFER, CONTENT_PADDING, DEFAULT_BACKGROUND, DEFAULT_CAMERA, DEFAULT_HIGHLIGHT_COLOR, DEFAULT_HIGHLIGHT_COLOR_DARK, DEFAULT_MINIMAP_MAX_NODES, DEFAULT_STYLE, DEFAULT_TEXT_COLOR, EDGE_HANDLE_SLOP_PX, EDGE_HIT_SLOP_PX, EdgeGeometryCache, FONT_FAMILY_MAP, FONT_SIZE_MAP, LINE_HEIGHT_MAP, LINK_COLOR, MAX_ZOOM, MIN_ZOOM, PALM_REJECTION_GRACE_MS, RESIZE_HANDLES, RESIZE_HANDLE_SIZE_PX, ROTATE_HANDLE_OFFSET_PX, ROTATE_HANDLE_RADIUS_PX, SCHEMA_VERSION, UniformGrid, VERSION, applyCameraTransform, arrowheadLength, asBatchId, asClientId, asEdgeId, asGroupId, asNodeId, attachSync, autoRouteControls, clampEffectiveScale, clampZoom, clearMeasureCache, clearSurface, clearTextBitmapCache, clipSamples, computeAutoFitHeight, computeEdgeGeometry, copy, createCanvasStore, createDefaultTextareaEditor, createFrameLoop, createPalmRejectionState, createRenderer, cubicBezier, cubicBezierTangent, cut, defineExtension, defineNode, deserializeClipboard, detectConflicts, drawArrowhead, drawEdge, drawMinimapViewport, drawShape, drawTextToCanvas, drawWithNodeTransform, edgeAABBFromSamples, edgeLabelBoundsWorld, emptyPresenceState, estimateMarkdownContentHeight, exportSelection, exportSelectionSvg, exportViewport, fromSerialized, fullVisibleClipResult, getCanvasFont, getContentHeight, getContext, getDpr, getFontEpoch, getMarkdownLineHeightPx, getOrRenderTextBitmap, getPointAndTangentAtArcLength, getTextBitmapCacheSize, handleEnter, handleWorldPositions, hitTestAny, hitTestEdge, hitTestHandles, hitTestPoint, hitTestRotateHandle, idleInteractionState, inflateRect, insertLink, installExtension, installedExtensions, inverseBatch, inverseOp, isAttached, isCanvasHarnessClipboard, isDrawablePrimitive, isMoving, isNodeRemoteEditing, layoutTokens, makeIdGenerator, marqueeNodes, measureText, midpointToCubicControls, minimapScreenToWorld, nodeAABB, nodeIntersectsRect, nodeLocalToWorld, notePenActive, notePenInactive, opSchemas, opSchemasAsAnthropicTools, paintBackground, panByScreen, paste, pointInNode, projectEndToWorld, projectToNodeBoundary, quantizeDpr, quantizeZoom, randomClientId, rectContainsPoint, rectFromPoints, rectsIntersect, registerMigrator, renderMinimapContent, resolveColor, resolveOpacity, resolveRenderScale, resolveStrokeWidth, rotateHandleWorldPosition, rotateVecByAngle, sampleBezier, sampleSelfLoop, samplesFor, sceneBounds, screenToWorld, selfLoopGeometry, serializeSelection, setupSurface, shouldAutoFit, shouldRejectTouch, sideNormalLocal, sideOf, sizeSurface, storeToJSON, subscribeFontEpoch, tangentAtArcLength, toSerialized, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleUnderline, tokenize, unionRects, viewportWorldRect, withAutoFitHeight, worldToNodeLocal, worldToScreen, worldViewport, worldViewportFromCamera, zoomAtScreenPoint };
6008
+ export { BEZIER_SEGMENTS, CODE_BG_COLOR, CODE_BLOCK_MARGIN_Y, CODE_BLOCK_PADDING_X, CONTENT_HEIGHT_BUFFER, CONTENT_PADDING, DEFAULT_BACKGROUND, DEFAULT_CAMERA, DEFAULT_HIGHLIGHT_COLOR, DEFAULT_HIGHLIGHT_COLOR_DARK, DEFAULT_MINIMAP_MAX_NODES, DEFAULT_STYLE, DEFAULT_TEXT_COLOR, EDGE_HANDLE_SLOP_PX, EDGE_HIT_SLOP_PX, EdgeGeometryCache, FONT_FAMILY_MAP, FONT_SIZE_MAP, LINE_HEIGHT_MAP, LINK_COLOR, MAX_IMAGE_BYTES, MAX_SVG_BYTES, MAX_ZOOM, MIN_ZOOM, PALM_REJECTION_GRACE_MS, RESIZE_HANDLES, RESIZE_HANDLE_SIZE_PX, ROTATE_HANDLE_OFFSET_PX, ROTATE_HANDLE_RADIUS_PX, SCHEMA_VERSION, UniformGrid, VERSION, applyCameraTransform, applySvgColor, arrowheadLength, asBatchId, asClientId, asEdgeId, asGroupId, asNodeId, attachSync, autoRouteControls, blobToDataUri, clampEffectiveScale, clampZoom, clearMeasureCache, clearSurface, clearTextBitmapCache, clipSamples, computeAutoFitHeight, computeEdgeGeometry, copy, createCanvasStore, createDefaultTextareaEditor, createFrameLoop, createPalmRejectionState, createRenderer, cubicBezier, cubicBezierTangent, cut, defineExtension, defineNode, deserializeClipboard, detectConflicts, downscaleImageBlob, drawArrowhead, drawEdge, drawMinimapViewport, drawShape, drawTextToCanvas, drawWithNodeTransform, edgeAABBFromSamples, edgeLabelBoundsWorld, emptyPresenceState, estimateMarkdownContentHeight, exportSelection, exportSelectionSvg, exportViewport, extractSvgDimensions, fromSerialized, fullVisibleClipResult, getCanvasFont, getContentHeight, getContext, getDpr, getFontEpoch, getMarkdownLineHeightPx, getOrRenderTextBitmap, getPointAndTangentAtArcLength, getTextBitmapCacheSize, handleEnter, handleWorldPositions, hitTestAny, hitTestEdge, hitTestHandles, hitTestPoint, hitTestRotateHandle, idleInteractionState, inflateRect, insertLink, installExtension, installedExtensions, inverseBatch, inverseOp, isAttached, isCanvasHarnessClipboard, isDrawablePrimitive, isMoving, isNodeRemoteEditing, layoutTokens, makeIdGenerator, marqueeNodes, measureText, midpointToCubicControls, minimapScreenToWorld, nodeAABB, nodeIntersectsRect, nodeLocalToWorld, notePenActive, notePenInactive, opSchemas, opSchemasAsAnthropicTools, paintBackground, panByScreen, paste, pointInNode, projectEndToWorld, projectToNodeBoundary, quantizeDpr, quantizeZoom, randomClientId, rectContainsPoint, rectFromPoints, rectsIntersect, registerMigrator, renderMinimapContent, resolveColor, resolveOpacity, resolveRenderScale, resolveStrokeWidth, rotateHandleWorldPosition, rotateVecByAngle, sampleBezier, sampleSelfLoop, samplesFor, sanitizeSvg, sceneBounds, screenToWorld, selfLoopGeometry, serializeSelection, setupSurface, shouldAutoFit, shouldRejectTouch, sideNormalLocal, sideOf, sizeSurface, storeToJSON, subscribeFontEpoch, tangentAtArcLength, toImageBlob, toSerialized, toggleBold, toggleCode, toggleItalic, toggleStrike, toggleUnderline, tokenize, unionRects, validateImageInput, validateSvgMarkup, viewportWorldRect, withAutoFitHeight, worldToNodeLocal, worldToScreen, worldViewport, worldViewportFromCamera, zoomAtScreenPoint };
5598
6009
  //# sourceMappingURL=index.js.map
5599
6010
  //# sourceMappingURL=index.js.map