@fieldnotes/core 0.20.0 → 0.22.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
@@ -1823,10 +1823,134 @@ function getTemplateBounds(el) {
1823
1823
  }
1824
1824
  }
1825
1825
  }
1826
+ function transferStrokeBounds(prev, next) {
1827
+ if (prev.type !== "stroke" || next.type !== "stroke") return;
1828
+ if (prev.points !== next.points) return;
1829
+ if (prev.position.x !== next.position.x || prev.position.y !== next.position.y) return;
1830
+ const bounds = strokeBoundsCache.get(prev);
1831
+ if (bounds) strokeBoundsCache.set(next, bounds);
1832
+ }
1826
1833
  function boundsIntersect(a, b) {
1827
1834
  return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
1828
1835
  }
1829
1836
 
1837
+ // src/elements/stroke-smoothing.ts
1838
+ var MIN_PRESSURE_SCALE = 0.2;
1839
+ function pressureToWidth(pressure, baseWidth) {
1840
+ return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
1841
+ }
1842
+ function simplifyPoints(points, tolerance) {
1843
+ if (points.length <= 2) return points.slice();
1844
+ return rdp(points, 0, points.length - 1, tolerance);
1845
+ }
1846
+ function rdp(points, start, end, tolerance) {
1847
+ const first = points[start];
1848
+ const last = points[end];
1849
+ if (!first || !last) return [];
1850
+ if (end - start <= 1) return [first, last];
1851
+ let maxDist = 0;
1852
+ let maxIndex = start;
1853
+ for (let i = start + 1; i < end; i++) {
1854
+ const pt = points[i];
1855
+ if (!pt) continue;
1856
+ const dist = perpendicularDistance(pt, first, last);
1857
+ if (dist > maxDist) {
1858
+ maxDist = dist;
1859
+ maxIndex = i;
1860
+ }
1861
+ }
1862
+ if (maxDist <= tolerance) return [first, last];
1863
+ const left = rdp(points, start, maxIndex, tolerance);
1864
+ const right = rdp(points, maxIndex, end, tolerance);
1865
+ return left.concat(right.slice(1));
1866
+ }
1867
+ function perpendicularDistance(pt, lineStart, lineEnd) {
1868
+ const dx = lineEnd.x - lineStart.x;
1869
+ const dy = lineEnd.y - lineStart.y;
1870
+ const lenSq = dx * dx + dy * dy;
1871
+ if (lenSq === 0) {
1872
+ const ex = pt.x - lineStart.x;
1873
+ const ey = pt.y - lineStart.y;
1874
+ return Math.sqrt(ex * ex + ey * ey);
1875
+ }
1876
+ const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
1877
+ return num / Math.sqrt(lenSq);
1878
+ }
1879
+ function smoothToSegments(points) {
1880
+ if (points.length < 2) return [];
1881
+ if (points.length === 2) {
1882
+ const p0 = points[0];
1883
+ const p1 = points[1];
1884
+ if (!p0 || !p1) return [];
1885
+ const mx = (p0.x + p1.x) / 2;
1886
+ const my = (p0.y + p1.y) / 2;
1887
+ return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
1888
+ }
1889
+ const segments = [];
1890
+ const n = points.length;
1891
+ for (let i = 0; i < n - 1; i++) {
1892
+ const p0 = points[Math.max(0, i - 1)];
1893
+ const p1 = points[i];
1894
+ const p2 = points[i + 1];
1895
+ const p3 = points[Math.min(n - 1, i + 2)];
1896
+ if (!p0 || !p1 || !p2 || !p3) continue;
1897
+ const cp1 = {
1898
+ x: p1.x + (p2.x - p0.x) / 6,
1899
+ y: p1.y + (p2.y - p0.y) / 6
1900
+ };
1901
+ const cp2 = {
1902
+ x: p2.x - (p3.x - p1.x) / 6,
1903
+ y: p2.y - (p3.y - p1.y) / 6
1904
+ };
1905
+ segments.push({ start: p1, cp1, cp2, end: p2 });
1906
+ }
1907
+ return segments;
1908
+ }
1909
+
1910
+ // src/elements/stroke-cache.ts
1911
+ var cache = /* @__PURE__ */ new WeakMap();
1912
+ var WIDTH_QUANTUM = 0.25;
1913
+ function buildWidthBuckets(segments, widths) {
1914
+ if (typeof Path2D === "undefined") return null;
1915
+ const byWidth = /* @__PURE__ */ new Map();
1916
+ for (let i = 0; i < segments.length; i++) {
1917
+ const seg = segments[i];
1918
+ const w = widths[i];
1919
+ if (!seg || w === void 0) continue;
1920
+ const q = Math.max(WIDTH_QUANTUM, Math.round(w / WIDTH_QUANTUM) * WIDTH_QUANTUM);
1921
+ let path = byWidth.get(q);
1922
+ if (!path) {
1923
+ path = new Path2D();
1924
+ byWidth.set(q, path);
1925
+ }
1926
+ path.moveTo(seg.start.x, seg.start.y);
1927
+ path.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
1928
+ }
1929
+ return [...byWidth.entries()].map(([width, path]) => ({ width, path }));
1930
+ }
1931
+ function computeStrokeSegments(stroke) {
1932
+ const segments = smoothToSegments(stroke.points);
1933
+ const widths = [];
1934
+ for (const seg of segments) {
1935
+ const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
1936
+ widths.push(w);
1937
+ }
1938
+ const data = { segments, widths, buckets: buildWidthBuckets(segments, widths) };
1939
+ cache.set(stroke, data);
1940
+ return data;
1941
+ }
1942
+ function getStrokeRenderData(stroke) {
1943
+ const cached = cache.get(stroke);
1944
+ if (cached) return cached;
1945
+ return computeStrokeSegments(stroke);
1946
+ }
1947
+ function transferStrokeRenderData(prev, next) {
1948
+ if (prev.type !== "stroke" || next.type !== "stroke") return;
1949
+ if (prev.points !== next.points) return;
1950
+ const data = cache.get(prev);
1951
+ if (data) cache.set(next, data);
1952
+ }
1953
+
1830
1954
  // src/elements/element-store.ts
1831
1955
  var ElementStore = class {
1832
1956
  elements = /* @__PURE__ */ new Map();
@@ -1877,6 +2001,10 @@ var ElementStore = class {
1877
2001
  this.sortedCache = null;
1878
2002
  this._versions.set(id, (this._versions.get(id) ?? 0) + 1);
1879
2003
  const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
2004
+ if (updated.type === "stroke" && existing.type === "stroke") {
2005
+ transferStrokeRenderData(existing, updated);
2006
+ transferStrokeBounds(existing, updated);
2007
+ }
1880
2008
  if (updated.type === "arrow") {
1881
2009
  const arrow = updated;
1882
2010
  arrow.cachedControlPoint = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
@@ -1920,6 +2048,12 @@ var ElementStore = class {
1920
2048
  this._versions.set(el.id, 0);
1921
2049
  const bounds = getElementBounds(el);
1922
2050
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2051
+ if (el.type === "stroke") {
2052
+ computeStrokeSegments(el);
2053
+ }
2054
+ if (el.type === "arrow" && el.bend !== 0 && !el.cachedControlPoint) {
2055
+ el.cachedControlPoint = getArrowControlPoint(el.from, el.to, el.bend);
2056
+ }
1923
2057
  }
1924
2058
  this.bus.emit("clear", null);
1925
2059
  for (const el of elements) {
@@ -2005,6 +2139,20 @@ var ElementStore = class {
2005
2139
  }
2006
2140
  };
2007
2141
 
2142
+ // src/elements/arrow-render-cache.ts
2143
+ var cache2 = /* @__PURE__ */ new WeakMap();
2144
+ function getArrowRenderGeometry(arrow) {
2145
+ const hit = cache2.get(arrow);
2146
+ if (hit) return hit;
2147
+ const geometry = {
2148
+ controlPoint: arrow.bend !== 0 ? arrow.cachedControlPoint ?? getArrowControlPoint(arrow.from, arrow.to, arrow.bend) : null,
2149
+ tangentStart: getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0),
2150
+ tangentEnd: getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1)
2151
+ };
2152
+ cache2.set(arrow, geometry);
2153
+ return geometry;
2154
+ }
2155
+
2008
2156
  // src/elements/arrow-binding.ts
2009
2157
  var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
2010
2158
  function isBindable(element) {
@@ -2125,98 +2273,6 @@ function unbindArrow(arrow, store) {
2125
2273
  return updates;
2126
2274
  }
2127
2275
 
2128
- // src/elements/stroke-smoothing.ts
2129
- var MIN_PRESSURE_SCALE = 0.2;
2130
- function pressureToWidth(pressure, baseWidth) {
2131
- return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
2132
- }
2133
- function simplifyPoints(points, tolerance) {
2134
- if (points.length <= 2) return points.slice();
2135
- return rdp(points, 0, points.length - 1, tolerance);
2136
- }
2137
- function rdp(points, start, end, tolerance) {
2138
- const first = points[start];
2139
- const last = points[end];
2140
- if (!first || !last) return [];
2141
- if (end - start <= 1) return [first, last];
2142
- let maxDist = 0;
2143
- let maxIndex = start;
2144
- for (let i = start + 1; i < end; i++) {
2145
- const pt = points[i];
2146
- if (!pt) continue;
2147
- const dist = perpendicularDistance(pt, first, last);
2148
- if (dist > maxDist) {
2149
- maxDist = dist;
2150
- maxIndex = i;
2151
- }
2152
- }
2153
- if (maxDist <= tolerance) return [first, last];
2154
- const left = rdp(points, start, maxIndex, tolerance);
2155
- const right = rdp(points, maxIndex, end, tolerance);
2156
- return left.concat(right.slice(1));
2157
- }
2158
- function perpendicularDistance(pt, lineStart, lineEnd) {
2159
- const dx = lineEnd.x - lineStart.x;
2160
- const dy = lineEnd.y - lineStart.y;
2161
- const lenSq = dx * dx + dy * dy;
2162
- if (lenSq === 0) {
2163
- const ex = pt.x - lineStart.x;
2164
- const ey = pt.y - lineStart.y;
2165
- return Math.sqrt(ex * ex + ey * ey);
2166
- }
2167
- const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
2168
- return num / Math.sqrt(lenSq);
2169
- }
2170
- function smoothToSegments(points) {
2171
- if (points.length < 2) return [];
2172
- if (points.length === 2) {
2173
- const p0 = points[0];
2174
- const p1 = points[1];
2175
- if (!p0 || !p1) return [];
2176
- const mx = (p0.x + p1.x) / 2;
2177
- const my = (p0.y + p1.y) / 2;
2178
- return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
2179
- }
2180
- const segments = [];
2181
- const n = points.length;
2182
- for (let i = 0; i < n - 1; i++) {
2183
- const p0 = points[Math.max(0, i - 1)];
2184
- const p1 = points[i];
2185
- const p2 = points[i + 1];
2186
- const p3 = points[Math.min(n - 1, i + 2)];
2187
- if (!p0 || !p1 || !p2 || !p3) continue;
2188
- const cp1 = {
2189
- x: p1.x + (p2.x - p0.x) / 6,
2190
- y: p1.y + (p2.y - p0.y) / 6
2191
- };
2192
- const cp2 = {
2193
- x: p2.x - (p3.x - p1.x) / 6,
2194
- y: p2.y - (p3.y - p1.y) / 6
2195
- };
2196
- segments.push({ start: p1, cp1, cp2, end: p2 });
2197
- }
2198
- return segments;
2199
- }
2200
-
2201
- // src/elements/stroke-cache.ts
2202
- var cache = /* @__PURE__ */ new WeakMap();
2203
- function computeStrokeSegments(stroke) {
2204
- const segments = smoothToSegments(stroke.points);
2205
- const widths = [];
2206
- for (const seg of segments) {
2207
- const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
2208
- widths.push(w);
2209
- }
2210
- const data = { segments, widths };
2211
- cache.set(stroke, data);
2212
- return data;
2213
- }
2214
- function getStrokeRenderData(stroke) {
2215
- const cached = cache.get(stroke);
2216
- if (cached) return cached;
2217
- return computeStrokeSegments(stroke);
2218
- }
2219
-
2220
2276
  // src/elements/grid-renderer.ts
2221
2277
  function getSquareGridLines(bounds, cellSize) {
2222
2278
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2609,6 +2665,7 @@ var ElementRenderer = class {
2609
2665
  store = null;
2610
2666
  imageCache = /* @__PURE__ */ new Map();
2611
2667
  onImageLoad = null;
2668
+ onImageError = null;
2612
2669
  camera = null;
2613
2670
  canvasSize = null;
2614
2671
  hexTileCache = null;
@@ -2619,6 +2676,9 @@ var ElementRenderer = class {
2619
2676
  setOnImageLoad(callback) {
2620
2677
  this.onImageLoad = callback;
2621
2678
  }
2679
+ setOnImageError(callback) {
2680
+ this.onImageError = callback;
2681
+ }
2622
2682
  setCamera(camera) {
2623
2683
  this.camera = camera;
2624
2684
  }
@@ -2658,21 +2718,29 @@ var ElementRenderer = class {
2658
2718
  ctx.lineCap = "round";
2659
2719
  ctx.lineJoin = "round";
2660
2720
  ctx.globalAlpha = stroke.opacity;
2661
- const { segments, widths } = getStrokeRenderData(stroke);
2662
- for (let i = 0; i < segments.length; i++) {
2663
- const seg = segments[i];
2664
- const w = widths[i];
2665
- if (!seg || w === void 0) continue;
2666
- ctx.lineWidth = w;
2667
- ctx.beginPath();
2668
- ctx.moveTo(seg.start.x, seg.start.y);
2669
- ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
2670
- ctx.stroke();
2721
+ const data = getStrokeRenderData(stroke);
2722
+ if (data.buckets) {
2723
+ for (const bucket of data.buckets) {
2724
+ ctx.lineWidth = bucket.width;
2725
+ ctx.stroke(bucket.path);
2726
+ }
2727
+ } else {
2728
+ for (let i = 0; i < data.segments.length; i++) {
2729
+ const seg = data.segments[i];
2730
+ const w = data.widths[i];
2731
+ if (!seg || w === void 0) continue;
2732
+ ctx.lineWidth = w;
2733
+ ctx.beginPath();
2734
+ ctx.moveTo(seg.start.x, seg.start.y);
2735
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
2736
+ ctx.stroke();
2737
+ }
2671
2738
  }
2672
2739
  ctx.restore();
2673
2740
  }
2674
2741
  renderArrow(ctx, arrow) {
2675
- const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
2742
+ const geometry = getArrowRenderGeometry(arrow);
2743
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow, geometry);
2676
2744
  ctx.save();
2677
2745
  ctx.strokeStyle = arrow.color;
2678
2746
  ctx.lineWidth = arrow.width;
@@ -2683,17 +2751,18 @@ var ElementRenderer = class {
2683
2751
  ctx.beginPath();
2684
2752
  ctx.moveTo(visualFrom.x, visualFrom.y);
2685
2753
  if (arrow.bend !== 0) {
2686
- const cp = arrow.cachedControlPoint ?? getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
2687
- ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
2754
+ const cp = geometry.controlPoint;
2755
+ if (cp) {
2756
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
2757
+ }
2688
2758
  } else {
2689
2759
  ctx.lineTo(visualTo.x, visualTo.y);
2690
2760
  }
2691
2761
  ctx.stroke();
2692
- this.renderArrowhead(ctx, arrow, visualTo);
2762
+ this.renderArrowhead(ctx, arrow, visualTo, geometry.tangentEnd);
2693
2763
  ctx.restore();
2694
2764
  }
2695
- renderArrowhead(ctx, arrow, tip) {
2696
- const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
2765
+ renderArrowhead(ctx, arrow, tip, angle) {
2697
2766
  ctx.beginPath();
2698
2767
  ctx.moveTo(tip.x, tip.y);
2699
2768
  ctx.lineTo(
@@ -2708,7 +2777,7 @@ var ElementRenderer = class {
2708
2777
  ctx.fillStyle = arrow.color;
2709
2778
  ctx.fill();
2710
2779
  }
2711
- getVisualEndpoints(arrow) {
2780
+ getVisualEndpoints(arrow, geometry) {
2712
2781
  let visualFrom = arrow.from;
2713
2782
  let visualTo = arrow.to;
2714
2783
  if (!this.store) return { visualFrom, visualTo };
@@ -2717,7 +2786,7 @@ var ElementRenderer = class {
2717
2786
  if (el) {
2718
2787
  const bounds = getElementBounds(el);
2719
2788
  if (bounds) {
2720
- const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
2789
+ const tangentAngle = geometry.tangentStart;
2721
2790
  const rayTarget = {
2722
2791
  x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
2723
2792
  y: arrow.from.y + Math.sin(tangentAngle) * 1e3
@@ -2731,7 +2800,7 @@ var ElementRenderer = class {
2731
2800
  if (el) {
2732
2801
  const bounds = getElementBounds(el);
2733
2802
  if (bounds) {
2734
- const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
2803
+ const tangentAngle = geometry.tangentEnd;
2735
2804
  const rayTarget = {
2736
2805
  x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
2737
2806
  y: arrow.to.y - Math.sin(tangentAngle) * 1e3
@@ -2977,6 +3046,10 @@ var ElementRenderer = class {
2977
3046
  ctx.restore();
2978
3047
  }
2979
3048
  renderImage(ctx, image) {
3049
+ if (this.imageCache.get(image.src) === "failed") {
3050
+ this.renderImagePlaceholder(ctx, image);
3051
+ return;
3052
+ }
2980
3053
  const img = this.getImage(image.src);
2981
3054
  if (!img) return;
2982
3055
  ctx.drawImage(
@@ -2987,6 +3060,27 @@ var ElementRenderer = class {
2987
3060
  image.size.h
2988
3061
  );
2989
3062
  }
3063
+ renderImagePlaceholder(ctx, image) {
3064
+ const { x, y } = image.position;
3065
+ const { w, h } = image.size;
3066
+ ctx.save();
3067
+ ctx.fillStyle = "#eeeeee";
3068
+ ctx.fillRect(x, y, w, h);
3069
+ ctx.strokeStyle = "#bdbdbd";
3070
+ ctx.lineWidth = 1;
3071
+ ctx.strokeRect(x, y, w, h);
3072
+ const glyph = Math.min(24, w / 2, h / 2);
3073
+ const cx = x + w / 2;
3074
+ const cy = y + h / 2;
3075
+ ctx.strokeStyle = "#9e9e9e";
3076
+ ctx.lineWidth = 2;
3077
+ ctx.beginPath();
3078
+ ctx.arc(cx, cy, glyph / 2, 0, Math.PI * 2);
3079
+ ctx.moveTo(cx - glyph / 2, cy + glyph / 2);
3080
+ ctx.lineTo(cx + glyph / 2, cy - glyph / 2);
3081
+ ctx.stroke();
3082
+ ctx.restore();
3083
+ }
2990
3084
  getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
2991
3085
  const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
2992
3086
  if (this.hexTileCacheKey === key && this.hexTileCache) {
@@ -3002,6 +3096,7 @@ var ElementRenderer = class {
3002
3096
  getImage(src) {
3003
3097
  const cached = this.imageCache.get(src);
3004
3098
  if (cached) {
3099
+ if (cached === "failed") return null;
3005
3100
  if (cached instanceof HTMLImageElement) return cached.complete ? cached : null;
3006
3101
  return cached;
3007
3102
  }
@@ -3018,6 +3113,11 @@ var ElementRenderer = class {
3018
3113
  });
3019
3114
  }
3020
3115
  };
3116
+ img.onerror = () => {
3117
+ this.imageCache.set(src, "failed");
3118
+ this.onImageError?.(src);
3119
+ this.onImageLoad?.();
3120
+ };
3021
3121
  return null;
3022
3122
  }
3023
3123
  };
@@ -4064,19 +4164,19 @@ function loadImages(elements) {
4064
4164
  const imageElements = elements.filter(
4065
4165
  (el) => el.type === "image" && "src" in el
4066
4166
  );
4067
- const cache2 = /* @__PURE__ */ new Map();
4068
- if (imageElements.length === 0) return Promise.resolve(cache2);
4167
+ const cache3 = /* @__PURE__ */ new Map();
4168
+ if (imageElements.length === 0) return Promise.resolve(cache3);
4069
4169
  return new Promise((resolve) => {
4070
4170
  let remaining = imageElements.length;
4071
4171
  const done = () => {
4072
4172
  remaining--;
4073
- if (remaining <= 0) resolve(cache2);
4173
+ if (remaining <= 0) resolve(cache3);
4074
4174
  };
4075
4175
  for (const el of imageElements) {
4076
4176
  const img = new Image();
4077
4177
  img.crossOrigin = "anonymous";
4078
4178
  img.onload = () => {
4079
- cache2.set(el.id, img);
4179
+ cache3.set(el.id, img);
4080
4180
  done();
4081
4181
  };
4082
4182
  img.onerror = done;
@@ -4557,18 +4657,39 @@ var RenderStats = class {
4557
4657
  frameTimes = [];
4558
4658
  frameCount = 0;
4559
4659
  _lastGridMs = 0;
4560
- recordFrame(durationMs, gridMs) {
4660
+ _lastLayersMs = 0;
4661
+ _lastBackgroundMs = 0;
4662
+ _lastCompositeMs = 0;
4663
+ _lastOverlayMs = 0;
4664
+ recordFrame(durationMs, breakdown) {
4561
4665
  this.frameCount++;
4562
4666
  this.frameTimes.push(durationMs);
4563
4667
  if (this.frameTimes.length > SAMPLE_SIZE) {
4564
4668
  this.frameTimes.shift();
4565
4669
  }
4566
- if (gridMs !== void 0) this._lastGridMs = gridMs;
4670
+ if (breakdown !== void 0) {
4671
+ if (breakdown.gridMs !== void 0) this._lastGridMs = breakdown.gridMs;
4672
+ if (breakdown.layersMs !== void 0) this._lastLayersMs = breakdown.layersMs;
4673
+ if (breakdown.backgroundMs !== void 0) this._lastBackgroundMs = breakdown.backgroundMs;
4674
+ if (breakdown.compositeMs !== void 0) this._lastCompositeMs = breakdown.compositeMs;
4675
+ if (breakdown.overlayMs !== void 0) this._lastOverlayMs = breakdown.overlayMs;
4676
+ }
4567
4677
  }
4568
4678
  getSnapshot() {
4569
4679
  const times = this.frameTimes;
4570
4680
  if (times.length === 0) {
4571
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
4681
+ return {
4682
+ fps: 0,
4683
+ avgFrameMs: 0,
4684
+ p95FrameMs: 0,
4685
+ lastFrameMs: 0,
4686
+ lastGridMs: 0,
4687
+ layersMs: 0,
4688
+ backgroundMs: 0,
4689
+ compositeMs: 0,
4690
+ overlayMs: 0,
4691
+ frameCount: 0
4692
+ };
4572
4693
  }
4573
4694
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
4574
4695
  const sorted = [...times].sort((a, b) => a - b);
@@ -4580,6 +4701,10 @@ var RenderStats = class {
4580
4701
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
4581
4702
  lastFrameMs: Math.round(lastFrame * 100) / 100,
4582
4703
  lastGridMs: Math.round(this._lastGridMs * 100) / 100,
4704
+ layersMs: Math.round(this._lastLayersMs * 100) / 100,
4705
+ backgroundMs: Math.round(this._lastBackgroundMs * 100) / 100,
4706
+ compositeMs: Math.round(this._lastCompositeMs * 100) / 100,
4707
+ overlayMs: Math.round(this._lastOverlayMs * 100) / 100,
4583
4708
  frameCount: this.frameCount
4584
4709
  };
4585
4710
  }
@@ -4607,7 +4732,7 @@ var RenderLoop = class {
4607
4732
  lastCamX;
4608
4733
  lastCamY;
4609
4734
  stats = new RenderStats();
4610
- lastGridMs = 0;
4735
+ layerGroups = /* @__PURE__ */ new Map();
4611
4736
  gridCacheCanvas = null;
4612
4737
  gridCacheCtx = null;
4613
4738
  gridCacheZoom = -1;
@@ -4705,6 +4830,9 @@ var RenderLoop = class {
4705
4830
  const t0 = performance.now();
4706
4831
  const ctx = this.canvasEl.getContext("2d");
4707
4832
  if (!ctx) return;
4833
+ let layersMs = 0;
4834
+ let compositeMs = 0;
4835
+ let gridMs = 0;
4708
4836
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4709
4837
  const cssWidth = this.canvasEl.clientWidth;
4710
4838
  const cssHeight = this.canvasEl.clientHeight;
@@ -4721,6 +4849,7 @@ var RenderLoop = class {
4721
4849
  ctx.scale(dpr, dpr);
4722
4850
  this.renderer.setCanvasSize(cssWidth, cssHeight);
4723
4851
  const hasGridElement = this.store.getElementsByType("grid").length > 0;
4852
+ const bgT0 = performance.now();
4724
4853
  if (hasGridElement) {
4725
4854
  ctx.save();
4726
4855
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -4729,6 +4858,7 @@ var RenderLoop = class {
4729
4858
  } else {
4730
4859
  this.background.render(ctx, this.camera);
4731
4860
  }
4861
+ const backgroundMs = performance.now() - bgT0;
4732
4862
  ctx.save();
4733
4863
  ctx.translate(this.camera.position.x, this.camera.position.y);
4734
4864
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -4741,7 +4871,7 @@ var RenderLoop = class {
4741
4871
  h: visibleRect.h + margin * 2
4742
4872
  };
4743
4873
  const allElements = this.store.getAll();
4744
- const layerElements = /* @__PURE__ */ new Map();
4874
+ this.layerGroups.clear();
4745
4875
  const gridElements = [];
4746
4876
  let domZIndex = 0;
4747
4877
  for (const element of allElements) {
@@ -4764,25 +4894,30 @@ var RenderLoop = class {
4764
4894
  gridElements.push(element);
4765
4895
  continue;
4766
4896
  }
4767
- let group = layerElements.get(element.layerId);
4897
+ let group = this.layerGroups.get(element.layerId);
4768
4898
  if (!group) {
4769
4899
  group = [];
4770
- layerElements.set(element.layerId, group);
4900
+ this.layerGroups.set(element.layerId, group);
4771
4901
  }
4772
4902
  group.push(element);
4773
4903
  }
4774
- for (const [layerId, elements] of layerElements) {
4904
+ for (const [layerId, elements] of this.layerGroups) {
4775
4905
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
4776
4906
  if (!this.layerCache.isDirty(layerId)) {
4907
+ const compT0 = performance.now();
4777
4908
  this.compositeLayerCache(ctx, layerId, dpr);
4909
+ compositeMs += performance.now() - compT0;
4778
4910
  continue;
4779
4911
  }
4780
4912
  if (isActiveDrawingLayer) {
4913
+ const compT0 = performance.now();
4781
4914
  this.compositeLayerCache(ctx, layerId, dpr);
4915
+ compositeMs += performance.now() - compT0;
4782
4916
  continue;
4783
4917
  }
4784
4918
  const offCtx = this.layerCache.getContext(layerId);
4785
4919
  if (offCtx) {
4920
+ const layerT0 = performance.now();
4786
4921
  const offCanvas = this.layerCache.getCanvas(layerId);
4787
4922
  offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
4788
4923
  offCtx.save();
@@ -4796,7 +4931,10 @@ var RenderLoop = class {
4796
4931
  }
4797
4932
  offCtx.restore();
4798
4933
  this.layerCache.markClean(layerId);
4934
+ layersMs += performance.now() - layerT0;
4935
+ const compT0 = performance.now();
4799
4936
  this.compositeLayerCache(ctx, layerId, dpr);
4937
+ compositeMs += performance.now() - compT0;
4800
4938
  }
4801
4939
  }
4802
4940
  if (gridElements.length > 0) {
@@ -4837,15 +4975,23 @@ var RenderLoop = class {
4837
4975
  this.gridCacheHeight = cssHeight;
4838
4976
  this.lastGridRef = gridRef;
4839
4977
  }
4840
- this.lastGridMs = performance.now() - gridT0;
4978
+ gridMs = performance.now() - gridT0;
4841
4979
  }
4980
+ const overlayT0 = performance.now();
4842
4981
  const activeTool = this.toolManager.activeTool;
4843
4982
  if (activeTool?.renderOverlay) {
4844
4983
  activeTool.renderOverlay(ctx);
4845
4984
  }
4985
+ const overlayMs = performance.now() - overlayT0;
4846
4986
  ctx.restore();
4847
4987
  ctx.restore();
4848
- this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
4988
+ this.stats.recordFrame(performance.now() - t0, {
4989
+ gridMs,
4990
+ layersMs,
4991
+ backgroundMs,
4992
+ compositeMs,
4993
+ overlayMs
4994
+ });
4849
4995
  }
4850
4996
  };
4851
4997
 
@@ -4929,6 +5075,17 @@ var Viewport = class {
4929
5075
  this.renderLoop.markAllLayersDirty();
4930
5076
  this.requestRender();
4931
5077
  });
5078
+ this.renderer.setOnImageError((src) => {
5079
+ const elementIds = [];
5080
+ for (const el of this.store.getAll()) {
5081
+ if (el.type === "image" && el.src === src) elementIds.push(el.id);
5082
+ }
5083
+ if (options.onImageError) {
5084
+ options.onImageError({ src, elementIds });
5085
+ } else {
5086
+ console.warn(`[fieldnotes] image failed to load: ${src}`);
5087
+ }
5088
+ });
4932
5089
  this.noteEditor = new NoteEditor({
4933
5090
  fontSizePresets: options.fontSizePresets,
4934
5091
  toolbar: options.toolbar,
@@ -5246,7 +5403,7 @@ var Viewport = class {
5246
5403
  const id = setInterval(() => {
5247
5404
  const s = this.getRenderStats();
5248
5405
  console.log(
5249
- `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
5406
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms layers=${s.layersMs}ms comp=${s.compositeMs}ms bg=${s.backgroundMs}ms overlay=${s.overlayMs}ms`
5250
5407
  );
5251
5408
  }, intervalMs);
5252
5409
  return () => clearInterval(id);
@@ -5633,6 +5790,43 @@ var PencilTool = class {
5633
5790
  }
5634
5791
  };
5635
5792
 
5793
+ // src/elements/stroke-hit.ts
5794
+ function distSqToSegment(p, a, b) {
5795
+ const abx = b.x - a.x;
5796
+ const aby = b.y - a.y;
5797
+ const apx = p.x - a.x;
5798
+ const apy = p.y - a.y;
5799
+ const lenSq = abx * abx + aby * aby;
5800
+ if (lenSq === 0) {
5801
+ return apx * apx + apy * apy;
5802
+ }
5803
+ const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
5804
+ const dx = p.x - (a.x + t * abx);
5805
+ const dy = p.y - (a.y + t * aby);
5806
+ return dx * dx + dy * dy;
5807
+ }
5808
+ function hitTestStroke(stroke, point, radius) {
5809
+ const bounds = getElementBounds(stroke);
5810
+ if (!bounds) return false;
5811
+ if (point.x < bounds.x - radius || point.x > bounds.x + bounds.w + radius || point.y < bounds.y - radius || point.y > bounds.y + bounds.h + radius) {
5812
+ return false;
5813
+ }
5814
+ const radiusSq = radius * radius;
5815
+ const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
5816
+ const { segments } = getStrokeRenderData(stroke);
5817
+ if (segments.length === 0) {
5818
+ const p = stroke.points[0];
5819
+ if (!p) return false;
5820
+ const dx = p.x - local.x;
5821
+ const dy = p.y - local.y;
5822
+ return dx * dx + dy * dy <= radiusSq;
5823
+ }
5824
+ for (const seg of segments) {
5825
+ if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
5826
+ }
5827
+ return false;
5828
+ }
5829
+
5636
5830
  // src/tools/eraser-tool.ts
5637
5831
  var DEFAULT_RADIUS = 20;
5638
5832
  function makeEraserCursor(radius) {
@@ -5691,12 +5885,7 @@ var EraserTool = class {
5691
5885
  if (erased) ctx.requestRender();
5692
5886
  }
5693
5887
  strokeIntersects(stroke, point) {
5694
- const radiusSq = this.radius * this.radius;
5695
- return stroke.points.some((p) => {
5696
- const dx = p.x + stroke.position.x - point.x;
5697
- const dy = p.y + stroke.position.y - point.y;
5698
- return dx * dx + dy * dy <= radiusSq;
5699
- });
5888
+ return hitTestStroke(stroke, point, this.radius);
5700
5889
  }
5701
5890
  };
5702
5891
 
@@ -6376,12 +6565,7 @@ var SelectTool = class {
6376
6565
  return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
6377
6566
  }
6378
6567
  if (el.type === "stroke") {
6379
- const HIT_RADIUS = 10;
6380
- return el.points.some((p) => {
6381
- const dx = p.x + el.position.x - point.x;
6382
- const dy = p.y + el.position.y - point.y;
6383
- return dx * dx + dy * dy <= HIT_RADIUS * HIT_RADIUS;
6384
- });
6568
+ return hitTestStroke(el, point, 10);
6385
6569
  }
6386
6570
  if (el.type === "arrow") {
6387
6571
  return isNearBezier(point, el.from, el.to, el.bend, 10);
@@ -7217,7 +7401,7 @@ var TemplateTool = class {
7217
7401
  };
7218
7402
 
7219
7403
  // src/index.ts
7220
- var VERSION = "0.20.0";
7404
+ var VERSION = "0.22.0";
7221
7405
  export {
7222
7406
  AddElementCommand,
7223
7407
  ArrowTool,