@fieldnotes/core 0.38.7 → 0.40.0

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
@@ -831,6 +831,11 @@ var KeyboardActions = class {
831
831
  if (tool?.name !== "select") return null;
832
832
  return { tool, ctx };
833
833
  }
834
+ selectableElements(ctx) {
835
+ return ctx.store.getAll().filter(
836
+ (el) => !el.locked && (ctx.isLayerVisible?.(el.layerId) ?? true) && !(ctx.isLayerLocked?.(el.layerId) ?? false)
837
+ );
838
+ }
834
839
  nudge(dx, dy, byCell) {
835
840
  if (this.deps.isToolActive()) return false;
836
841
  const sel = this.selectTool();
@@ -973,12 +978,28 @@ var KeyboardActions = class {
973
978
  }
974
979
  const sel = this.selectTool();
975
980
  if (!sel) return;
976
- const ids = sel.ctx.store.getAll().filter(
977
- (el) => !el.locked && (sel.ctx.isLayerVisible?.(el.layerId) ?? true) && !(sel.ctx.isLayerLocked?.(el.layerId) ?? false)
978
- ).map((el) => el.id);
981
+ const ids = this.selectableElements(sel.ctx).map((el) => el.id);
979
982
  sel.tool.setSelection(ids);
980
983
  sel.ctx.requestRender();
981
984
  }
985
+ cycleSelection(direction) {
986
+ if (this.deps.isToolActive()) return;
987
+ const tm = this.deps.getToolManager();
988
+ const ctx = this.deps.getToolContext();
989
+ if (!tm || !ctx) return;
990
+ if (tm.activeTool?.name !== "select") ctx.switchTool?.("select");
991
+ const sel = this.selectTool();
992
+ if (!sel) return;
993
+ const eligible = this.selectableElements(sel.ctx).filter((el) => el.type !== "grid");
994
+ if (eligible.length === 0) return;
995
+ const idxs = sel.tool.selectedIds.map((id) => eligible.findIndex((e) => e.id === id)).filter((i) => i >= 0);
996
+ const anchor = idxs.length === 0 ? direction > 0 ? -1 : 0 : direction > 0 ? Math.max(...idxs) : Math.min(...idxs);
997
+ const next = (anchor + direction + eligible.length) % eligible.length;
998
+ const target = eligible[next];
999
+ if (!target) return;
1000
+ sel.tool.setSelection([target.id]);
1001
+ sel.ctx.requestRender();
1002
+ }
982
1003
  zoomToFit() {
983
1004
  if (this.deps.isToolActive()) return;
984
1005
  this.deps.fitToContent?.();
@@ -1083,8 +1104,9 @@ var DEFAULT_BINDINGS = [
1083
1104
  ["undo", ["mod+z"]],
1084
1105
  ["redo", ["mod+y", "mod+shift+z"]],
1085
1106
  ["select-all", ["mod+a"]],
1107
+ ["cycle-selection", ["tab"]],
1108
+ ["cycle-selection-reverse", ["shift+tab"]],
1086
1109
  ["copy", ["mod+c"]],
1087
- ["paste", ["mod+v"]],
1088
1110
  ["duplicate", ["mod+d"]],
1089
1111
  ["z-forward", ["]"]],
1090
1112
  ["z-backward", ["["]],
@@ -1259,6 +1281,7 @@ var KeyboardHandler = class {
1259
1281
  this.shortcutMap = new ShortcutMap(deps.shortcuts?.bindings);
1260
1282
  window.addEventListener("keydown", this.onKeyDown, { signal: deps.abortSignal });
1261
1283
  window.addEventListener("keyup", this.onKeyUp, { signal: deps.abortSignal });
1284
+ window.addEventListener("paste", this.onPaste, { signal: deps.abortSignal });
1262
1285
  }
1263
1286
  shortcutMap;
1264
1287
  get shortcuts() {
@@ -1274,12 +1297,15 @@ var KeyboardHandler = class {
1274
1297
  zoomToLevel(level) {
1275
1298
  this.deps.camera.zoomAt(level, this.viewportCenter());
1276
1299
  }
1300
+ shouldHandle(target) {
1301
+ const el = target;
1302
+ if (el?.isContentEditable) return false;
1303
+ const tag = el?.tagName;
1304
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return false;
1305
+ return this.isInScope();
1306
+ }
1277
1307
  onKeyDown = (e) => {
1278
- const target = e.target;
1279
- if (target?.isContentEditable) return;
1280
- const tag = target?.tagName;
1281
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
1282
- if (!this.isInScope()) return;
1308
+ if (!this.shouldHandle(e.target)) return;
1283
1309
  if (e.key === " ") {
1284
1310
  this.deps.setSpaceHeld(true);
1285
1311
  }
@@ -1301,6 +1327,34 @@ var KeyboardHandler = class {
1301
1327
  }
1302
1328
  }
1303
1329
  };
1330
+ onPaste = (e) => {
1331
+ if (!this.shouldHandle(e.target)) return;
1332
+ const items = e.clipboardData?.items;
1333
+ let file = null;
1334
+ if (items) {
1335
+ for (const it of items) {
1336
+ if (it.kind === "file" && it.type.startsWith("image/")) {
1337
+ file = it.getAsFile();
1338
+ break;
1339
+ }
1340
+ }
1341
+ }
1342
+ if (file) {
1343
+ e.preventDefault();
1344
+ const world = this.deps.getLastPointerWorld() ?? this.deps.getCenteredWorld();
1345
+ if (this.deps.onPaste) {
1346
+ this.deps.onPaste(e, world);
1347
+ return;
1348
+ }
1349
+ const reader = new FileReader();
1350
+ reader.onload = () => {
1351
+ if (typeof reader.result === "string") this.deps.addImage(reader.result, world);
1352
+ };
1353
+ reader.readAsDataURL(file);
1354
+ return;
1355
+ }
1356
+ this.deps.actions.paste();
1357
+ };
1304
1358
  runAction(action, e) {
1305
1359
  switch (action) {
1306
1360
  case "delete":
@@ -1322,6 +1376,14 @@ var KeyboardHandler = class {
1322
1376
  e?.preventDefault();
1323
1377
  this.deps.actions.selectAll();
1324
1378
  return;
1379
+ case "cycle-selection":
1380
+ e?.preventDefault();
1381
+ this.deps.actions.cycleSelection(1);
1382
+ return;
1383
+ case "cycle-selection-reverse":
1384
+ e?.preventDefault();
1385
+ this.deps.actions.cycleSelection(-1);
1386
+ return;
1325
1387
  case "copy":
1326
1388
  e?.preventDefault();
1327
1389
  this.deps.actions.copy();
@@ -1449,7 +1511,11 @@ var InputHandler = class {
1449
1511
  this.spaceHeld = v;
1450
1512
  },
1451
1513
  getActivePointerCount: () => this.activePointers.size,
1452
- dispatchToolHover: (e) => this.dispatchToolHover(e)
1514
+ dispatchToolHover: (e) => this.dispatchToolHover(e),
1515
+ addImage: options.addImage ?? (() => ""),
1516
+ getLastPointerWorld: () => this.lastPointerWorld(),
1517
+ getCenteredWorld: options.getCenteredWorld ?? (() => ({ x: 0, y: 0 })),
1518
+ onPaste: options.onPaste
1453
1519
  });
1454
1520
  this.element.style.touchAction = "none";
1455
1521
  if (this.scope === "focus") {
@@ -2109,12 +2175,12 @@ function smoothToSegments(points) {
2109
2175
  return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
2110
2176
  }
2111
2177
  const segments = [];
2112
- const n = points.length;
2113
- for (let i = 0; i < n - 1; i++) {
2178
+ const n2 = points.length;
2179
+ for (let i = 0; i < n2 - 1; i++) {
2114
2180
  const p0 = points[Math.max(0, i - 1)];
2115
2181
  const p1 = points[i];
2116
2182
  const p2 = points[i + 1];
2117
- const p3 = points[Math.min(n - 1, i + 2)];
2183
+ const p3 = points[Math.min(n2 - 1, i + 2)];
2118
2184
  if (!p0 || !p1 || !p2 || !p3) continue;
2119
2185
  const cp1 = {
2120
2186
  x: p1.x + (p2.x - p0.x) / 6,
@@ -2804,11 +2870,11 @@ function pixelToOffset(x, y, cellSize, orientation) {
2804
2870
  const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
2805
2871
  return { col, row: Math.round((y - offsetY) / hexH) };
2806
2872
  }
2807
- function enumerateHexRing(centerQ, centerR, n, orientation, cellSize) {
2873
+ function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
2808
2874
  const cells = [];
2809
- for (let dq = -n; dq <= n; dq++) {
2810
- const rMin = Math.max(-n, -dq - n);
2811
- const rMax = Math.min(n, -dq + n);
2875
+ for (let dq = -n2; dq <= n2; dq++) {
2876
+ const rMin = Math.max(-n2, -dq - n2);
2877
+ const rMax = Math.min(n2, -dq + n2);
2812
2878
  for (let dr = rMin; dr <= rMax; dr++) {
2813
2879
  const absQ = centerQ + dq;
2814
2880
  const absR = centerR + dr;
@@ -2829,28 +2895,28 @@ function getHexDistance(a, b, cellSize, orientation) {
2829
2895
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
2830
2896
  }
2831
2897
  function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
2832
- const n = Math.round(radiusCells);
2898
+ const n2 = Math.round(radiusCells);
2833
2899
  const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2834
2900
  const cube = offsetToCube(off.col, off.row, orientation);
2835
- if (n <= 0) {
2901
+ if (n2 <= 0) {
2836
2902
  return [offsetToPixel(off.col, off.row, cellSize, orientation)];
2837
2903
  }
2838
- return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
2904
+ return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
2839
2905
  }
2840
2906
  function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2841
- const n = Math.round(radiusCells);
2907
+ const n2 = Math.round(radiusCells);
2842
2908
  const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2843
2909
  const cube = offsetToCube(off.col, off.row, orientation);
2844
2910
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2845
- if (n <= 0) return [centerPixel];
2911
+ if (n2 <= 0) return [centerPixel];
2846
2912
  const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
2847
2913
  const step = Math.PI / 3;
2848
2914
  const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
2849
2915
  const halfAngle = Math.PI / 6 + 1e-6;
2850
2916
  const cells = [centerPixel];
2851
- for (let dq = -n; dq <= n; dq++) {
2852
- const rMin = Math.max(-n, -dq - n);
2853
- const rMax = Math.min(n, -dq + n);
2917
+ for (let dq = -n2; dq <= n2; dq++) {
2918
+ const rMin = Math.max(-n2, -dq - n2);
2919
+ const rMax = Math.min(n2, -dq + n2);
2854
2920
  for (let dr = rMin; dr <= rMax; dr++) {
2855
2921
  if (dq === 0 && dr === 0) continue;
2856
2922
  const absQ = cube.q + dq;
@@ -2874,23 +2940,23 @@ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
2874
2940
  return cells;
2875
2941
  }
2876
2942
  function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2877
- const n = Math.round(radiusCells);
2943
+ const n2 = Math.round(radiusCells);
2878
2944
  const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2879
2945
  const cube = offsetToCube(off.col, off.row, orientation);
2880
2946
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2881
- if (n <= 0) return [centerPixel];
2947
+ if (n2 <= 0) return [centerPixel];
2882
2948
  const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
2883
2949
  const step = Math.PI / 3;
2884
2950
  const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
2885
2951
  const cos = Math.cos(snappedAngle);
2886
2952
  const sin = Math.sin(snappedAngle);
2887
2953
  const snapUnit = Math.sqrt(3) * cellSize;
2888
- const lineLength = n * snapUnit;
2954
+ const lineLength = n2 * snapUnit;
2889
2955
  const halfWidth = snapUnit * 0.5 + 1e-6;
2890
2956
  const cells = [];
2891
- for (let dq = -n; dq <= n; dq++) {
2892
- const rMin = Math.max(-n, -dq - n);
2893
- const rMax = Math.min(n, -dq + n);
2957
+ for (let dq = -n2; dq <= n2; dq++) {
2958
+ const rMin = Math.max(-n2, -dq - n2);
2959
+ const rMax = Math.min(n2, -dq + n2);
2894
2960
  for (let dr = rMin; dr <= rMax; dr++) {
2895
2961
  const absQ = cube.q + dq;
2896
2962
  const absR = cube.r + dr;
@@ -2912,17 +2978,17 @@ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
2912
2978
  return cells;
2913
2979
  }
2914
2980
  function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
2915
- const n = Math.round(radiusCells);
2981
+ const n2 = Math.round(radiusCells);
2916
2982
  const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
2917
2983
  const cube = offsetToCube(off.col, off.row, orientation);
2918
2984
  const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
2919
- if (n <= 0) return [centerPixel];
2985
+ if (n2 <= 0) return [centerPixel];
2920
2986
  const snapUnit = Math.sqrt(3) * cellSize;
2921
- const halfSide = n * snapUnit / 2;
2987
+ const halfSide = n2 * snapUnit / 2;
2922
2988
  const cells = [];
2923
- for (let dq = -n; dq <= n; dq++) {
2924
- const rMin = Math.max(-n, -dq - n);
2925
- const rMax = Math.min(n, -dq + n);
2989
+ for (let dq = -n2; dq <= n2; dq++) {
2990
+ const rMin = Math.max(-n2, -dq - n2);
2991
+ const rMax = Math.min(n2, -dq + n2);
2926
2992
  for (let dr = rMin; dr <= rMax; dr++) {
2927
2993
  const absQ = cube.q + dq;
2928
2994
  const absR = cube.r + dr;
@@ -3109,6 +3175,58 @@ function getSquareGridLines(bounds, cellSize) {
3109
3175
  }
3110
3176
  return { verticals, horizontals };
3111
3177
  }
3178
+ function getHexVertices(cx, cy, circumradius, orientation) {
3179
+ const vertices = [];
3180
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
3181
+ for (let i = 0; i < 6; i++) {
3182
+ const angle = Math.PI / 3 * i + angleOffset;
3183
+ vertices.push({
3184
+ x: cx + circumradius * Math.cos(angle),
3185
+ y: cy + circumradius * Math.sin(angle)
3186
+ });
3187
+ }
3188
+ return vertices;
3189
+ }
3190
+ function getHexCenters(bounds, circumradius, orientation) {
3191
+ if (circumradius <= 0) return [];
3192
+ const centers = [];
3193
+ if (orientation === "pointy") {
3194
+ const hexW = Math.sqrt(3) * circumradius;
3195
+ const hexH = 2 * circumradius;
3196
+ const rowH = hexH * 0.75;
3197
+ const startRow = Math.floor((bounds.minY - circumradius) / rowH);
3198
+ const endRow = Math.ceil((bounds.maxY + circumradius) / rowH);
3199
+ const startCol = Math.floor((bounds.minX - hexW) / hexW);
3200
+ const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
3201
+ for (let row = startRow; row <= endRow; row++) {
3202
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
3203
+ for (let col = startCol; col <= endCol; col++) {
3204
+ centers.push({
3205
+ x: col * hexW + offsetX,
3206
+ y: row * rowH
3207
+ });
3208
+ }
3209
+ }
3210
+ } else {
3211
+ const hexW = 2 * circumradius;
3212
+ const hexH = Math.sqrt(3) * circumradius;
3213
+ const colW = hexW * 0.75;
3214
+ const startCol = Math.floor((bounds.minX - circumradius) / colW);
3215
+ const endCol = Math.ceil((bounds.maxX + circumradius) / colW);
3216
+ const startRow = Math.floor((bounds.minY - hexH) / hexH);
3217
+ const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
3218
+ for (let col = startCol; col <= endCol; col++) {
3219
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
3220
+ for (let row = startRow; row <= endRow; row++) {
3221
+ centers.push({
3222
+ x: col * colW,
3223
+ y: row * hexH + offsetY
3224
+ });
3225
+ }
3226
+ }
3227
+ }
3228
+ return centers;
3229
+ }
3112
3230
  function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opacity) {
3113
3231
  if (cellSize <= 0) return;
3114
3232
  const { verticals, horizontals } = getSquareGridLines(bounds, cellSize);
@@ -3507,6 +3625,8 @@ function createHtmlElement(input) {
3507
3625
  };
3508
3626
  if (input.domId) el.domId = input.domId;
3509
3627
  if (input.interactive) el.interactive = input.interactive;
3628
+ if (input.htmlType) el.htmlType = input.htmlType;
3629
+ if (input.data) el.data = input.data;
3510
3630
  return el;
3511
3631
  }
3512
3632
  function createShape(input) {
@@ -4787,6 +4907,350 @@ async function exportImage(store, options = {}, layerManager) {
4787
4907
  });
4788
4908
  }
4789
4909
 
4910
+ // src/canvas/export-svg.ts
4911
+ var ARROWHEAD_LENGTH2 = 12;
4912
+ var ARROWHEAD_ANGLE2 = Math.PI / 6;
4913
+ var ARROW_LABEL_FONT_SIZE2 = 14;
4914
+ function esc(s) {
4915
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
4916
+ }
4917
+ var n = (v) => Number.isFinite(v) ? `${Math.round(v * 1e3) / 1e3}` : "0";
4918
+ function elementCenter(el) {
4919
+ const b = getElementBounds(el);
4920
+ if (!b) return null;
4921
+ return { x: b.x + b.w / 2, y: b.y + b.h / 2 };
4922
+ }
4923
+ function withRotationSvg(el, fragment) {
4924
+ const angle = el.rotation ?? 0;
4925
+ if (!angle || !fragment) return fragment;
4926
+ const c = elementCenter(el);
4927
+ if (!c) return fragment;
4928
+ const deg = angle * 180 / Math.PI;
4929
+ return `<g transform="rotate(${n(deg)} ${n(c.x)} ${n(c.y)})">${fragment}</g>`;
4930
+ }
4931
+ var WIDTH_QUANTUM2 = 0.25;
4932
+ function emitStroke(stroke) {
4933
+ if (stroke.points.length < 2) return "";
4934
+ const data = getStrokeRenderData(stroke);
4935
+ const { x: ox, y: oy } = stroke.position;
4936
+ const byWidth = /* @__PURE__ */ new Map();
4937
+ for (let i = 0; i < data.segments.length; i++) {
4938
+ const seg = data.segments[i];
4939
+ const w = data.widths[i];
4940
+ if (!seg || w === void 0) continue;
4941
+ const q = Math.max(WIDTH_QUANTUM2, Math.round(w / WIDTH_QUANTUM2) * WIDTH_QUANTUM2);
4942
+ let parts = byWidth.get(q);
4943
+ if (!parts) {
4944
+ parts = [];
4945
+ byWidth.set(q, parts);
4946
+ }
4947
+ parts.push(
4948
+ `M${n(ox + seg.start.x)} ${n(oy + seg.start.y)} C${n(ox + seg.cp1.x)} ${n(oy + seg.cp1.y)} ${n(ox + seg.cp2.x)} ${n(oy + seg.cp2.y)} ${n(ox + seg.end.x)} ${n(oy + seg.end.y)}`
4949
+ );
4950
+ }
4951
+ const blend = stroke.blendMode === "multiply" ? ' style="mix-blend-mode:multiply"' : "";
4952
+ let out = "";
4953
+ for (const [width, parts] of byWidth) {
4954
+ out += `<path d="${parts.join(" ")}" fill="none" stroke="${esc(stroke.color)}" stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round" opacity="${n(stroke.opacity)}"${blend} />`;
4955
+ }
4956
+ return out;
4957
+ }
4958
+ function emitShape(shape) {
4959
+ const { x, y } = shape.position;
4960
+ const { w, h } = shape.size;
4961
+ const fill = shape.fillColor !== "none" && shape.shape !== "line" ? esc(shape.fillColor) : "none";
4962
+ const stroke = shape.strokeWidth > 0 ? esc(shape.strokeColor) : "none";
4963
+ const sw = shape.strokeWidth > 0 ? ` stroke-width="${n(shape.strokeWidth)}"` : "";
4964
+ switch (shape.shape) {
4965
+ case "rectangle":
4966
+ return `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="${fill}" stroke="${stroke}"${sw} />`;
4967
+ case "ellipse":
4968
+ return `<ellipse cx="${n(x + w / 2)}" cy="${n(y + h / 2)}" rx="${n(w / 2)}" ry="${n(h / 2)}" fill="${fill}" stroke="${stroke}"${sw} />`;
4969
+ case "line": {
4970
+ const [a, b] = lineEndpoints(shape);
4971
+ return `<line x1="${n(a.x)}" y1="${n(a.y)}" x2="${n(b.x)}" y2="${n(b.y)}" stroke="${stroke}"${sw} stroke-linecap="round" />`;
4972
+ }
4973
+ }
4974
+ }
4975
+ function emitArrow(arrow, store) {
4976
+ const geometry = getArrowRenderGeometry(arrow);
4977
+ const { visualFrom: from, visualTo: to } = getVisualEndpoints(arrow, geometry, store);
4978
+ let d;
4979
+ if (arrow.bend !== 0) {
4980
+ const cp = geometry.controlPoint ?? getArrowControlPoint(from, to, arrow.bend);
4981
+ d = `M${n(from.x)} ${n(from.y)} Q${n(cp.x)} ${n(cp.y)} ${n(to.x)} ${n(to.y)}`;
4982
+ } else {
4983
+ d = `M${n(from.x)} ${n(from.y)} L${n(to.x)} ${n(to.y)}`;
4984
+ }
4985
+ const dash = arrow.fromBinding || arrow.toBinding ? ' stroke-dasharray="8 4"' : "";
4986
+ let out = `<path d="${d}" fill="none" stroke="${esc(arrow.color)}" stroke-width="${n(arrow.width)}" stroke-linecap="round"${dash} />`;
4987
+ const angle = geometry.tangentEnd;
4988
+ const p1x = to.x - ARROWHEAD_LENGTH2 * Math.cos(angle - ARROWHEAD_ANGLE2);
4989
+ const p1y = to.y - ARROWHEAD_LENGTH2 * Math.sin(angle - ARROWHEAD_ANGLE2);
4990
+ const p2x = to.x - ARROWHEAD_LENGTH2 * Math.cos(angle + ARROWHEAD_ANGLE2);
4991
+ const p2y = to.y - ARROWHEAD_LENGTH2 * Math.sin(angle + ARROWHEAD_ANGLE2);
4992
+ out += `<polygon points="${n(to.x)},${n(to.y)} ${n(p1x)},${n(p1y)} ${n(p2x)},${n(p2y)}" fill="${esc(arrow.color)}" />`;
4993
+ if (arrow.label && arrow.label.length > 0) {
4994
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
4995
+ const approxW = arrow.label.length * ARROW_LABEL_FONT_SIZE2 * 0.6;
4996
+ const padX = 6;
4997
+ const padY = 4;
4998
+ const lw = approxW + padX * 2;
4999
+ const lh = ARROW_LABEL_FONT_SIZE2 + padY * 2;
5000
+ out += `<rect x="${n(mid.x - lw / 2)}" y="${n(mid.y - lh / 2)}" width="${n(lw)}" height="${n(lh)}" rx="4" fill="rgba(255,255,255,0.9)" />`;
5001
+ out += `<text x="${n(mid.x)}" y="${n(mid.y)}" font-family="system-ui, sans-serif" font-size="${ARROW_LABEL_FONT_SIZE2}" fill="#1a1a1a" text-anchor="middle" dominant-baseline="central">${esc(arrow.label)}</text>`;
5002
+ }
5003
+ return out;
5004
+ }
5005
+ function emitImage(image, dataUri) {
5006
+ const href = dataUri ?? image.src;
5007
+ if (!href) return "";
5008
+ const { x, y } = image.position;
5009
+ const { w, h } = image.size;
5010
+ return `<image href="${esc(href)}" x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" />`;
5011
+ }
5012
+ function emitText(text) {
5013
+ if (!text.text) return "";
5014
+ const pad = 2;
5015
+ let anchor = "start";
5016
+ let textX = text.position.x + pad;
5017
+ if (text.textAlign === "center") {
5018
+ anchor = "middle";
5019
+ textX = text.position.x + text.size.w / 2;
5020
+ } else if (text.textAlign === "right") {
5021
+ anchor = "end";
5022
+ textX = text.position.x + text.size.w - pad;
5023
+ }
5024
+ const lineHeight = text.fontSize * 1.4;
5025
+ const lines = text.text.split("\n");
5026
+ let out = "";
5027
+ for (let i = 0; i < lines.length; i++) {
5028
+ const line = lines[i];
5029
+ if (line === void 0) continue;
5030
+ const y = text.position.y + pad + i * lineHeight;
5031
+ out += `<text x="${n(textX)}" y="${n(y)}" font-family="system-ui, sans-serif" font-size="${n(text.fontSize)}" fill="${esc(text.color)}" text-anchor="${anchor}" dominant-baseline="text-before-edge">${esc(line)}</text>`;
5032
+ }
5033
+ return out;
5034
+ }
5035
+ function emitNote(note, rasterScale) {
5036
+ const { x, y } = note.position;
5037
+ const { w, h } = note.size;
5038
+ if (typeof document === "undefined") return emitNotePlaceholder(note);
5039
+ const canvas = document.createElement("canvas");
5040
+ canvas.width = Math.max(1, Math.ceil(w * rasterScale));
5041
+ canvas.height = Math.max(1, Math.ceil(h * rasterScale));
5042
+ const ctx = canvas.getContext("2d");
5043
+ if (!ctx) return emitNotePlaceholder(note);
5044
+ ctx.scale(rasterScale, rasterScale);
5045
+ ctx.translate(-x, -y);
5046
+ renderNoteOnCanvas(ctx, note);
5047
+ let dataUri;
5048
+ try {
5049
+ dataUri = canvas.toDataURL();
5050
+ } catch {
5051
+ return emitNotePlaceholder(note);
5052
+ }
5053
+ if (!dataUri || !dataUri.startsWith("data:")) return emitNotePlaceholder(note);
5054
+ return `<image href="${esc(dataUri)}" x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" />`;
5055
+ }
5056
+ function emitNotePlaceholder(note) {
5057
+ const { x, y } = note.position;
5058
+ const { w, h } = note.size;
5059
+ return `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" rx="4" fill="${esc(note.backgroundColor)}" />`;
5060
+ }
5061
+ function emitGrid(grid, bounds) {
5062
+ if (grid.cellSize <= 0) return "";
5063
+ const vb = {
5064
+ minX: bounds.x,
5065
+ minY: bounds.y,
5066
+ maxX: bounds.x + bounds.w,
5067
+ maxY: bounds.y + bounds.h
5068
+ };
5069
+ const stroke = esc(grid.strokeColor);
5070
+ const sw = n(grid.strokeWidth);
5071
+ const op = n(grid.opacity);
5072
+ if (grid.gridType === "hex") {
5073
+ const centers = getHexCenters(vb, grid.cellSize, grid.hexOrientation);
5074
+ let d2 = "";
5075
+ for (const c of centers) {
5076
+ const verts = getHexVertices(c.x, c.y, grid.cellSize, grid.hexOrientation);
5077
+ const first = verts[0];
5078
+ if (!first) continue;
5079
+ d2 += `M${n(first.x)} ${n(first.y)}`;
5080
+ for (let i = 1; i < verts.length; i++) {
5081
+ const v = verts[i];
5082
+ if (v) d2 += `L${n(v.x)} ${n(v.y)}`;
5083
+ }
5084
+ d2 += "Z";
5085
+ }
5086
+ return `<path d="${d2}" fill="none" stroke="${stroke}" stroke-width="${sw}" opacity="${op}" />`;
5087
+ }
5088
+ const { verticals, horizontals } = getSquareGridLines(vb, grid.cellSize);
5089
+ let d = "";
5090
+ for (const gx of verticals) d += `M${n(gx)} ${n(vb.minY)}L${n(gx)} ${n(vb.maxY)}`;
5091
+ for (const gy of horizontals) d += `M${n(vb.minX)} ${n(gy)}L${n(vb.maxX)} ${n(gy)}`;
5092
+ return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${sw}" opacity="${op}" />`;
5093
+ }
5094
+ function emitTemplate(template, grid) {
5095
+ if (grid && grid.gridType === "hex") {
5096
+ return emitHexTemplate(template, grid);
5097
+ }
5098
+ return emitGeometricTemplate(template);
5099
+ }
5100
+ function emitGeometricTemplate(t) {
5101
+ const { x: cx, y: cy } = t.position;
5102
+ const r = t.radius;
5103
+ const fill = esc(t.fillColor);
5104
+ const stroke = esc(t.strokeColor);
5105
+ const sw = n(t.strokeWidth);
5106
+ const op = n(t.opacity);
5107
+ const attrs = `fill="${fill}" stroke="${stroke}" stroke-width="${sw}" opacity="${op}"`;
5108
+ switch (t.templateShape) {
5109
+ case "circle":
5110
+ return `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r)}" ${attrs} />`;
5111
+ case "square":
5112
+ return `<rect x="${n(cx - r / 2)}" y="${n(cy - r / 2)}" width="${n(r)}" height="${n(r)}" ${attrs} />`;
5113
+ case "cone": {
5114
+ const halfAngle = Math.atan(0.5);
5115
+ const a0 = t.angle - halfAngle;
5116
+ const a1 = t.angle + halfAngle;
5117
+ const p0x = cx + r * Math.cos(a0);
5118
+ const p0y = cy + r * Math.sin(a0);
5119
+ const p1x = cx + r * Math.cos(a1);
5120
+ const p1y = cy + r * Math.sin(a1);
5121
+ const large = a1 - a0 > Math.PI ? 1 : 0;
5122
+ return `<path d="M${n(cx)} ${n(cy)} L${n(p0x)} ${n(p0y)} A${n(r)} ${n(r)} 0 ${large} 1 ${n(p1x)} ${n(p1y)} Z" ${attrs} />`;
5123
+ }
5124
+ case "line": {
5125
+ const halfW = r / 12;
5126
+ const cos = Math.cos(t.angle);
5127
+ const sin = Math.sin(t.angle);
5128
+ const perpX = -sin * halfW;
5129
+ const perpY = cos * halfW;
5130
+ const pts = [
5131
+ [cx + perpX, cy + perpY],
5132
+ [cx + r * cos + perpX, cy + r * sin + perpY],
5133
+ [cx + r * cos - perpX, cy + r * sin - perpY],
5134
+ [cx - perpX, cy - perpY]
5135
+ ].map(([px, py]) => `${n(px ?? 0)},${n(py ?? 0)}`).join(" ");
5136
+ return `<polygon points="${pts}" ${attrs} />`;
5137
+ }
5138
+ }
5139
+ }
5140
+ function emitHexTemplate(t, grid) {
5141
+ const cellSize = grid.cellSize;
5142
+ const orientation = grid.hexOrientation;
5143
+ const snapUnit = Math.sqrt(3) * cellSize;
5144
+ const radiusCells = t.radius / snapUnit;
5145
+ const center2 = t.position;
5146
+ let cells;
5147
+ switch (t.templateShape) {
5148
+ case "circle":
5149
+ cells = getHexCellsInRadius(center2, radiusCells, cellSize, orientation);
5150
+ break;
5151
+ case "cone":
5152
+ cells = getHexCellsInCone(center2, t.angle, radiusCells, cellSize, orientation);
5153
+ break;
5154
+ case "line":
5155
+ cells = getHexCellsInLine(center2, t.angle, radiusCells, cellSize, orientation);
5156
+ break;
5157
+ case "square":
5158
+ cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
5159
+ break;
5160
+ }
5161
+ let d = "";
5162
+ for (const cell of cells) {
5163
+ const verts = getHexVertices(cell.x, cell.y, cellSize, orientation);
5164
+ const first = verts[0];
5165
+ if (!first) continue;
5166
+ d += `M${n(first.x)} ${n(first.y)}`;
5167
+ for (let i = 1; i < verts.length; i++) {
5168
+ const v = verts[i];
5169
+ if (v) d += `L${n(v.x)} ${n(v.y)}`;
5170
+ }
5171
+ d += "Z";
5172
+ }
5173
+ return `<path d="${d}" fill="${esc(t.fillColor)}" stroke="${esc(t.strokeColor)}" stroke-width="${n(t.strokeWidth)}" opacity="${n(t.opacity)}" />`;
5174
+ }
5175
+ async function exportSvg(store, options = {}, layerManager) {
5176
+ const padding = options.padding ?? 0;
5177
+ const rasterScale = options.rasterScale ?? 2;
5178
+ const filter = options.filter;
5179
+ const allElements = store.getAll();
5180
+ let visibleElements = layerManager ? allElements.filter((el) => layerManager.isLayerVisible(el.layerId)) : allElements;
5181
+ if (filter) visibleElements = visibleElements.filter(filter);
5182
+ const bounds = computeBounds(visibleElements, padding);
5183
+ if (!bounds) {
5184
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
5185
+ }
5186
+ const remoteImages = visibleElements.filter(
5187
+ (el) => el.type === "image" && !el.src.startsWith("data:")
5188
+ );
5189
+ const imageCache = await loadImages(remoteImages);
5190
+ const imageDataUris = encodeImages(visibleElements, imageCache, rasterScale);
5191
+ const grids = visibleElements.filter((el) => el.type === "grid");
5192
+ const firstGrid = grids[0];
5193
+ let body = "";
5194
+ if (options.background) {
5195
+ body += `<rect x="${n(bounds.x)}" y="${n(bounds.y)}" width="${n(bounds.w)}" height="${n(bounds.h)}" fill="${esc(options.background)}" />`;
5196
+ }
5197
+ for (const el of visibleElements) {
5198
+ body += emitElement(el, imageDataUris, rasterScale, firstGrid, store);
5199
+ }
5200
+ for (const grid of grids) {
5201
+ body += emitGrid(grid, bounds);
5202
+ }
5203
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(bounds.w)}" height="${n(bounds.h)}" viewBox="${n(bounds.x)} ${n(bounds.y)} ${n(bounds.w)} ${n(bounds.h)}">${body}</svg>`;
5204
+ }
5205
+ function emitElement(el, imageDataUris, rasterScale, firstGrid, store) {
5206
+ switch (el.type) {
5207
+ case "stroke":
5208
+ return withRotationSvg(el, emitStroke(el));
5209
+ case "shape":
5210
+ return withRotationSvg(el, emitShape(el));
5211
+ case "arrow":
5212
+ return emitArrow(el, store);
5213
+ case "image":
5214
+ return withRotationSvg(el, emitImage(el, imageDataUris.get(el.id)));
5215
+ case "text":
5216
+ return withRotationSvg(el, emitText(el));
5217
+ case "note":
5218
+ return withRotationSvg(el, emitNote(el, rasterScale));
5219
+ case "template":
5220
+ return emitTemplate(el, firstGrid);
5221
+ case "grid":
5222
+ return "";
5223
+ case "html":
5224
+ return "";
5225
+ default:
5226
+ return "";
5227
+ }
5228
+ }
5229
+ function encodeImages(elements, imageCache, rasterScale) {
5230
+ const out = /* @__PURE__ */ new Map();
5231
+ for (const el of elements) {
5232
+ if (el.type !== "image") continue;
5233
+ if (el.src.startsWith("data:")) {
5234
+ out.set(el.id, el.src);
5235
+ continue;
5236
+ }
5237
+ const img = imageCache.get(el.id);
5238
+ if (!img || typeof document === "undefined") continue;
5239
+ const canvas = document.createElement("canvas");
5240
+ canvas.width = Math.max(1, Math.ceil(el.size.w * rasterScale));
5241
+ canvas.height = Math.max(1, Math.ceil(el.size.h * rasterScale));
5242
+ const ctx = canvas.getContext("2d");
5243
+ if (!ctx) continue;
5244
+ try {
5245
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
5246
+ const uri = canvas.toDataURL();
5247
+ if (uri.startsWith("data:")) out.set(el.id, uri);
5248
+ } catch {
5249
+ }
5250
+ }
5251
+ return out;
5252
+ }
5253
+
4790
5254
  // src/layers/layer-manager.ts
4791
5255
  var LayerManager = class {
4792
5256
  constructor(store) {
@@ -5947,13 +6411,13 @@ var SelectionOps = class {
5947
6411
  if (!first || !last) return;
5948
6412
  const c0 = center2(first.bounds);
5949
6413
  const cN = center2(last.bounds);
5950
- const n = sorted.length;
6414
+ const n2 = sorted.length;
5951
6415
  this.deps.recorder.begin();
5952
6416
  const moved = [];
5953
- for (let i = 1; i < n - 1; i++) {
6417
+ for (let i = 1; i < n2 - 1; i++) {
5954
6418
  const item = sorted[i];
5955
6419
  if (!item || !this.isMovable(item.el)) continue;
5956
- const target = c0 + i * (cN - c0) / (n - 1);
6420
+ const target = c0 + i * (cN - c0) / (n2 - 1);
5957
6421
  const delta = target - center2(item.bounds);
5958
6422
  if (delta === 0) continue;
5959
6423
  const [dx, dy] = axis === "horizontal" ? [delta, 0] : [0, delta];
@@ -6291,7 +6755,10 @@ var Viewport = class {
6291
6755
  this.getSelectTool()?.selectAtPoint(world, this.toolContext);
6292
6756
  this.openContextMenu(screenPos);
6293
6757
  },
6294
- shortcuts: options.shortcuts
6758
+ shortcuts: options.shortcuts,
6759
+ addImage: (src, world) => this.addImage(src, world),
6760
+ getCenteredWorld: () => this.centeredPosition({ w: 300, h: 200 }),
6761
+ onPaste: options.onPaste
6295
6762
  });
6296
6763
  if (options.contextMenu !== false) {
6297
6764
  this.contextMenu = new ContextMenu({
@@ -6431,6 +6898,7 @@ var Viewport = class {
6431
6898
  gridController;
6432
6899
  interactions;
6433
6900
  contextMenu = null;
6901
+ htmlRenderers = /* @__PURE__ */ new Map();
6434
6902
  get ctx() {
6435
6903
  return this.canvasEl.getContext("2d");
6436
6904
  }
@@ -6472,6 +6940,9 @@ var Viewport = class {
6472
6940
  async exportImage(options) {
6473
6941
  return exportImage(this.store, options, this.layerManager);
6474
6942
  }
6943
+ async exportSVG(options) {
6944
+ return exportSvg(this.store, options, this.layerManager);
6945
+ }
6475
6946
  loadState(state) {
6476
6947
  this.inputHandler.flushPendingHistory();
6477
6948
  this.historyRecorder.pause();
@@ -6485,19 +6956,24 @@ var Viewport = class {
6485
6956
  this.layerManager.setActiveLayer(state.activeLayerId);
6486
6957
  }
6487
6958
  this.domNodeManager.reattachHtmlContent(this.store);
6488
- if (this.onHtmlElementMount) {
6489
- for (const el of this.store.getElementsByType("html")) {
6490
- if (!this.domNodeManager.hasContent(el.id)) {
6491
- this.domNodeManager.syncDomNode(el);
6492
- const node = this.domNodeManager.getNode(el.id);
6493
- if (node) {
6494
- this.onHtmlElementMount(el.id, el.domId, node);
6495
- node.dataset["initialized"] = "true";
6496
- Object.assign(node.style, {
6497
- overflow: "hidden",
6498
- pointerEvents: el.interactive ? "auto" : "none"
6499
- });
6500
- }
6959
+ for (const el of this.store.getElementsByType("html")) {
6960
+ if (this.domNodeManager.hasContent(el.id)) continue;
6961
+ const factory = el.htmlType ? this.htmlRenderers.get(el.htmlType) : void 0;
6962
+ const rebuilt = factory ? factory(el) : null;
6963
+ if (rebuilt) {
6964
+ this.domNodeManager.storeHtmlContent(el.id, rebuilt);
6965
+ this.domNodeManager.syncDomNode(el);
6966
+ }
6967
+ if (this.onHtmlElementMount) {
6968
+ if (!rebuilt) this.domNodeManager.syncDomNode(el);
6969
+ const node = this.domNodeManager.getNode(el.id);
6970
+ if (node) {
6971
+ this.onHtmlElementMount(el.id, el.domId, node);
6972
+ node.dataset["initialized"] = "true";
6973
+ Object.assign(node.style, {
6974
+ overflow: "hidden",
6975
+ pointerEvents: el.interactive ? "auto" : "none"
6976
+ });
6501
6977
  }
6502
6978
  }
6503
6979
  }
@@ -6543,12 +7019,14 @@ var Viewport = class {
6543
7019
  this.requestRender();
6544
7020
  return image.id;
6545
7021
  }
6546
- addHtmlElement(dom, position, size = { w: 200, h: 150 }) {
7022
+ addHtmlElement(dom, position, size = { w: 200, h: 150 }, opts) {
6547
7023
  const domId = dom.id || void 0;
6548
7024
  const el = createHtmlElement({
6549
7025
  position,
6550
7026
  size,
6551
7027
  domId,
7028
+ htmlType: opts?.htmlType,
7029
+ data: opts?.data,
6552
7030
  layerId: this.layerManager.activeLayerId
6553
7031
  });
6554
7032
  this.domNodeManager.storeHtmlContent(el.id, dom);
@@ -6589,6 +7067,9 @@ var Viewport = class {
6589
7067
  this.layerManager.removeLayer(id);
6590
7068
  this.historyRecorder.commit();
6591
7069
  }
7070
+ registerHtmlRenderer(htmlType, factory) {
7071
+ this.htmlRenderers.set(htmlType, factory);
7072
+ }
6592
7073
  updateHtmlElement(id, newContent) {
6593
7074
  const el = this.store.getById(id);
6594
7075
  if (!el) throw new Error(`Element not found: ${id}`);
@@ -7241,6 +7722,17 @@ function renderArrowHandles(canvasCtx, arrow, zoom) {
7241
7722
  canvasCtx.stroke();
7242
7723
  }
7243
7724
  }
7725
+ function renderArrowHoverHandle(canvasCtx, arrow, zoom) {
7726
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
7727
+ const radius = HANDLE_RADIUS / zoom;
7728
+ canvasCtx.fillStyle = "#2196F3";
7729
+ canvasCtx.strokeStyle = "#2196F3";
7730
+ canvasCtx.lineWidth = 1.5 / zoom;
7731
+ canvasCtx.beginPath();
7732
+ canvasCtx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
7733
+ canvasCtx.fill();
7734
+ canvasCtx.stroke();
7735
+ }
7244
7736
 
7245
7737
  // src/elements/snap-guides.ts
7246
7738
  function xAnchors(b) {
@@ -8089,7 +8581,13 @@ var SelectTool = class {
8089
8581
  if (this.hoveredId && this.ctx && this.mode.type === "idle") {
8090
8582
  if (!this._selectedIds.includes(this.hoveredId)) {
8091
8583
  const el = this.ctx.store.getById(this.hoveredId);
8092
- if (el) {
8584
+ if (el?.type === "arrow") {
8585
+ canvasCtx.save();
8586
+ canvasCtx.globalAlpha = 0.35;
8587
+ canvasCtx.setLineDash([]);
8588
+ renderArrowHoverHandle(canvasCtx, el, this.ctx.camera.zoom);
8589
+ canvasCtx.restore();
8590
+ } else if (el) {
8093
8591
  const b = getElementBounds(el);
8094
8592
  if (b) {
8095
8593
  canvasCtx.save();
@@ -9060,8 +9558,118 @@ var TemplateTool = class {
9060
9558
  }
9061
9559
  };
9062
9560
 
9561
+ // src/tools/laser-tool.ts
9562
+ var DEFAULT_COLOR = "#ff3b30";
9563
+ var DEFAULT_WIDTH = 4;
9564
+ var DEFAULT_FADE_MS = 1200;
9565
+ var LaserTool = class {
9566
+ name;
9567
+ color;
9568
+ width;
9569
+ fadeMs;
9570
+ trail = [];
9571
+ rafId = null;
9572
+ drawing = false;
9573
+ optionListeners = /* @__PURE__ */ new Set();
9574
+ constructor(options = {}) {
9575
+ this.name = options.name ?? "laser";
9576
+ this.color = options.color ?? DEFAULT_COLOR;
9577
+ this.width = options.width ?? DEFAULT_WIDTH;
9578
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
9579
+ }
9580
+ now() {
9581
+ return performance.now();
9582
+ }
9583
+ onActivate(ctx) {
9584
+ ctx.setCursor?.("crosshair");
9585
+ }
9586
+ onDeactivate(ctx) {
9587
+ if (this.rafId !== null) {
9588
+ cancelAnimationFrame(this.rafId);
9589
+ this.rafId = null;
9590
+ }
9591
+ this.trail = [];
9592
+ this.drawing = false;
9593
+ ctx.setCursor?.("default");
9594
+ ctx.requestRender();
9595
+ }
9596
+ getOptions() {
9597
+ return {
9598
+ name: this.name,
9599
+ color: this.color,
9600
+ width: this.width,
9601
+ fadeMs: this.fadeMs
9602
+ };
9603
+ }
9604
+ onOptionsChange(listener) {
9605
+ this.optionListeners.add(listener);
9606
+ return () => this.optionListeners.delete(listener);
9607
+ }
9608
+ setOptions(options) {
9609
+ if (options.color !== void 0) this.color = options.color;
9610
+ if (options.width !== void 0) this.width = options.width;
9611
+ if (options.fadeMs !== void 0) this.fadeMs = options.fadeMs;
9612
+ this.notifyOptionsChange();
9613
+ }
9614
+ onPointerDown(state, ctx) {
9615
+ this.drawing = true;
9616
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
9617
+ this.trail.push({ x: world.x, y: world.y, t: this.now() });
9618
+ this.ensureAnimating(ctx);
9619
+ }
9620
+ onPointerMove(state, ctx) {
9621
+ if (!this.drawing) return;
9622
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
9623
+ this.trail.push({ x: world.x, y: world.y, t: this.now() });
9624
+ this.ensureAnimating(ctx);
9625
+ }
9626
+ onPointerUp(_state, _ctx) {
9627
+ this.drawing = false;
9628
+ }
9629
+ renderOverlay(ctx) {
9630
+ if (this.trail.length < 2) return;
9631
+ ctx.save();
9632
+ ctx.strokeStyle = this.color;
9633
+ ctx.lineWidth = this.width;
9634
+ ctx.lineCap = "round";
9635
+ ctx.lineJoin = "round";
9636
+ const now = this.now();
9637
+ for (let i = 0; i < this.trail.length - 1; i++) {
9638
+ const a = this.trail[i];
9639
+ const b = this.trail[i + 1];
9640
+ if (!a || !b) continue;
9641
+ const age = now - b.t;
9642
+ ctx.globalAlpha = Math.max(0, 1 - age / this.fadeMs);
9643
+ ctx.beginPath();
9644
+ ctx.moveTo(a.x, a.y);
9645
+ ctx.lineTo(b.x, b.y);
9646
+ ctx.stroke();
9647
+ }
9648
+ ctx.restore();
9649
+ }
9650
+ ensureAnimating(ctx) {
9651
+ if (this.rafId === null) {
9652
+ this.rafId = requestAnimationFrame(() => this.tick(ctx));
9653
+ }
9654
+ }
9655
+ tick(ctx) {
9656
+ const cutoff = this.now() - this.fadeMs;
9657
+ this.trail = this.trail.filter((p) => p.t >= cutoff);
9658
+ if (this.trail.length > 0) {
9659
+ ctx.requestRender();
9660
+ this.rafId = requestAnimationFrame(() => this.tick(ctx));
9661
+ } else {
9662
+ ctx.requestRender();
9663
+ this.rafId = null;
9664
+ }
9665
+ }
9666
+ notifyOptionsChange() {
9667
+ for (const listener of this.optionListeners) listener();
9668
+ }
9669
+ };
9670
+
9063
9671
  // src/index.ts
9064
- var VERSION = "0.38.7";
9672
+ var VERSION = "0.40.0";
9065
9673
  export {
9066
9674
  ArrowTool,
9067
9675
  AutoSave,
@@ -9072,6 +9680,7 @@ export {
9072
9680
  HandTool,
9073
9681
  HistoryStack,
9074
9682
  ImageTool,
9683
+ LaserTool,
9075
9684
  LayerManager,
9076
9685
  MeasureTool,
9077
9686
  NoteTool,
@@ -9095,6 +9704,7 @@ export {
9095
9704
  createText,
9096
9705
  drawHexPath,
9097
9706
  exportImage,
9707
+ exportSvg,
9098
9708
  getActiveFormats,
9099
9709
  getArrowBounds,
9100
9710
  getArrowControlPoint,