@fieldnotes/core 0.22.0 → 0.24.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/README.md CHANGED
@@ -413,6 +413,21 @@ new Viewport(container, {
413
413
  });
414
414
  ```
415
415
 
416
+ ### ViewportOptions reference
417
+
418
+ - `camera?: CameraOptions` — `minZoom` / `maxZoom` (defaults `0.1` / `10`).
419
+ - `background?: BackgroundOptions` — `pattern`, `spacing`, `color`.
420
+ - `fontSizePresets?: FontSizePreset[]` — custom font-size steps for the note toolbar.
421
+ - `toolbar?: boolean` — show/hide the note formatting toolbar (default `true`).
422
+ - `placeholder?: string` — placeholder text shown in empty notes.
423
+ - `shortcuts?: ShortcutOptions` — seed the keyboard shortcut table with custom bindings.
424
+ - `onHtmlElementMount?` — called after `loadState` for HTML elements that need content injected.
425
+ - `onDrop?` — called for every drop event; replaces the built-in image-drop handling.
426
+ - `onImageError?` — called when an image element fails to load.
427
+ - `panBufferMargin?: number` (default `256`) — CSS-pixel margin cached beyond the viewport so
428
+ small pans re-composite instead of re-rasterizing the layers and grid. Larger = more pan
429
+ reuse, more memory per layer. Set `0` to disable (exact-viewport caches) on memory-tight hosts.
430
+
416
431
  ### Tool Options
417
432
 
418
433
  ```typescript
package/dist/index.cjs CHANGED
@@ -1033,16 +1033,17 @@ var KeyboardActions = class {
1033
1033
  this.pasteCount = 0;
1034
1034
  }
1035
1035
  paste() {
1036
+ if (this.deps.isToolActive()) return;
1036
1037
  this.flushPendingNudge();
1037
- if (this.clipboard.length === 0 || this.deps.isToolActive()) return;
1038
+ if (this.clipboard.length === 0) return;
1038
1039
  const sel = this.selectTool();
1039
1040
  if (!sel) return;
1040
1041
  this.pasteCount++;
1041
1042
  this.insertClones(this.clipboard, this.pasteCount * 20, sel);
1042
1043
  }
1043
1044
  duplicate() {
1044
- this.flushPendingNudge();
1045
1045
  if (this.deps.isToolActive()) return;
1046
+ this.flushPendingNudge();
1046
1047
  const sel = this.selectTool();
1047
1048
  if (!sel) return;
1048
1049
  const source = [];
@@ -1226,6 +1227,11 @@ function parseBinding(binding) {
1226
1227
  throw new Error(`Invalid shortcut binding "${binding}": unknown modifier "${part}"`);
1227
1228
  }
1228
1229
  }
1230
+ if (parsed.mod && (parsed.ctrl || parsed.meta)) {
1231
+ throw new Error(
1232
+ `Invalid shortcut binding "${binding}": "mod" already means Ctrl or Cmd; don't combine it with ctrl/meta`
1233
+ );
1234
+ }
1229
1235
  return parsed;
1230
1236
  }
1231
1237
  function bindingMatches(p, e, allowShift) {
@@ -1369,6 +1375,10 @@ var InputHandler = class {
1369
1375
  this.inputFilter.reset();
1370
1376
  this.deferredDown = null;
1371
1377
  this.lastPointerEvent = null;
1378
+ if (this.scope === "focus") {
1379
+ this.element.removeAttribute("tabindex");
1380
+ this.element.style.outline = "";
1381
+ }
1372
1382
  }
1373
1383
  bind() {
1374
1384
  const opts = { signal: this.abortController.signal };
@@ -1699,6 +1709,22 @@ var DoubleTapDetector = class {
1699
1709
  }
1700
1710
  };
1701
1711
 
1712
+ // src/core/geometry.ts
1713
+ function distSqToSegment(p, a, b) {
1714
+ const abx = b.x - a.x;
1715
+ const aby = b.y - a.y;
1716
+ const apx = p.x - a.x;
1717
+ const apy = p.y - a.y;
1718
+ const lenSq = abx * abx + aby * aby;
1719
+ if (lenSq === 0) {
1720
+ return apx * apx + apy * apy;
1721
+ }
1722
+ const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
1723
+ const dx = p.x - (a.x + t * abx);
1724
+ const dy = p.y - (a.y + t * aby);
1725
+ return dx * dx + dy * dy;
1726
+ }
1727
+
1702
1728
  // src/elements/arrow-geometry.ts
1703
1729
  function getArrowControlPoint(from, to, bend) {
1704
1730
  const midX = (from.x + to.x) / 2;
@@ -1787,16 +1813,7 @@ function bezierPoint(from, cp, to, t) {
1787
1813
  };
1788
1814
  }
1789
1815
  function isNearLine(point, a, b, threshold) {
1790
- const dx = b.x - a.x;
1791
- const dy = b.y - a.y;
1792
- const lenSq = dx * dx + dy * dy;
1793
- if (lenSq === 0) {
1794
- return Math.hypot(point.x - a.x, point.y - a.y) <= threshold;
1795
- }
1796
- const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lenSq));
1797
- const projX = a.x + t * dx;
1798
- const projY = a.y + t * dy;
1799
- return Math.hypot(point.x - projX, point.y - projY) <= threshold;
1816
+ return distSqToSegment(point, a, b) <= threshold * threshold;
1800
1817
  }
1801
1818
 
1802
1819
  // src/elements/element-bounds.ts
@@ -2780,6 +2797,7 @@ var ElementRenderer = class {
2780
2797
  canvasSize = null;
2781
2798
  hexTileCache = null;
2782
2799
  hexTileCacheKey = "";
2800
+ gridBoundsOverride = null;
2783
2801
  setStore(store) {
2784
2802
  this.store = store;
2785
2803
  }
@@ -2795,6 +2813,9 @@ var ElementRenderer = class {
2795
2813
  setCanvasSize(w, h) {
2796
2814
  this.canvasSize = { w, h };
2797
2815
  }
2816
+ setGridBoundsOverride(bounds) {
2817
+ this.gridBoundsOverride = bounds;
2818
+ }
2798
2819
  isDomElement(element) {
2799
2820
  return DOM_ELEMENT_TYPES.has(element.type);
2800
2821
  }
@@ -2965,20 +2986,20 @@ var ElementRenderer = class {
2965
2986
  }
2966
2987
  }
2967
2988
  renderGrid(ctx, grid) {
2968
- if (!this.canvasSize) return;
2989
+ const canvasSize = this.canvasSize;
2990
+ if (!canvasSize) return;
2969
2991
  const cam = this.camera;
2970
2992
  if (!cam) return;
2971
- const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2972
- const bottomRight = cam.screenToWorld({
2973
- x: this.canvasSize.w,
2974
- y: this.canvasSize.h
2975
- });
2976
- const bounds = {
2977
- minX: topLeft.x,
2978
- minY: topLeft.y,
2979
- maxX: bottomRight.x,
2980
- maxY: bottomRight.y
2981
- };
2993
+ const bounds = this.gridBoundsOverride ?? (() => {
2994
+ const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2995
+ const bottomRight = cam.screenToWorld({ x: canvasSize.w, y: canvasSize.h });
2996
+ return {
2997
+ minX: topLeft.x,
2998
+ minY: topLeft.y,
2999
+ maxX: bottomRight.x,
3000
+ maxY: bottomRight.y
3001
+ };
3002
+ })();
2982
3003
  if (grid.gridType === "hex") {
2983
3004
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2984
3005
  const scale = cam.zoom * dpr;
@@ -3223,9 +3244,9 @@ var ElementRenderer = class {
3223
3244
  });
3224
3245
  }
3225
3246
  };
3226
- img.onerror = () => {
3247
+ img.onerror = (event) => {
3227
3248
  this.imageCache.set(src, "failed");
3228
- this.onImageError?.(src);
3249
+ this.onImageError?.(src, event);
3229
3250
  this.onImageLoad?.();
3230
3251
  };
3231
3252
  return null;
@@ -3650,7 +3671,10 @@ var NoteEditor = class {
3650
3671
  this.editingNode.removeAttribute("data-fn-placeholder");
3651
3672
  this.editingNode.removeAttribute("data-fn-empty");
3652
3673
  const text = sanitizeNoteHtml(this.editingNode.innerHTML);
3653
- store.update(this.editingId, { text });
3674
+ const current = store.getById(this.editingId);
3675
+ if (current && (current.type === "note" || current.type === "text") && current.text !== text) {
3676
+ store.update(this.editingId, { text });
3677
+ }
3654
3678
  this.editingNode.contentEditable = "false";
3655
3679
  Object.assign(this.editingNode.style, {
3656
3680
  userSelect: "none",
@@ -4837,19 +4861,14 @@ var RenderLoop = class {
4837
4861
  layerManager;
4838
4862
  domNodeManager;
4839
4863
  layerCache;
4864
+ marginViewport;
4840
4865
  activeDrawingLayerId = null;
4841
- lastZoom;
4842
- lastCamX;
4843
- lastCamY;
4866
+ gridCacheDirty = true;
4867
+ // set on recenter/viewport-change; consumed by the grid block
4844
4868
  stats = new RenderStats();
4845
4869
  layerGroups = /* @__PURE__ */ new Map();
4846
4870
  gridCacheCanvas = null;
4847
4871
  gridCacheCtx = null;
4848
- gridCacheZoom = -1;
4849
- gridCacheCamX = -Infinity;
4850
- gridCacheCamY = -Infinity;
4851
- gridCacheWidth = 0;
4852
- gridCacheHeight = 0;
4853
4872
  lastGridRef = null;
4854
4873
  constructor(deps) {
4855
4874
  this.canvasEl = deps.canvasEl;
@@ -4861,9 +4880,7 @@ var RenderLoop = class {
4861
4880
  this.layerManager = deps.layerManager;
4862
4881
  this.domNodeManager = deps.domNodeManager;
4863
4882
  this.layerCache = deps.layerCache;
4864
- this.lastZoom = deps.camera.zoom;
4865
- this.lastCamX = deps.camera.position.x;
4866
- this.lastCamY = deps.camera.position.y;
4883
+ this.marginViewport = deps.marginViewport;
4867
4884
  }
4868
4885
  requestRender() {
4869
4886
  this.needsRender = true;
@@ -4890,7 +4907,9 @@ var RenderLoop = class {
4890
4907
  setCanvasSize(width, height) {
4891
4908
  this.canvasEl.width = width;
4892
4909
  this.canvasEl.height = height;
4893
- this.layerCache.resize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
4910
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4911
+ this.marginViewport.setViewport(width / dpr, height / dpr, dpr);
4912
+ this.layerCache.resize();
4894
4913
  }
4895
4914
  setActiveDrawingLayer(layerId) {
4896
4915
  this.activeDrawingLayerId = layerId;
@@ -4904,30 +4923,29 @@ var RenderLoop = class {
4904
4923
  getStats() {
4905
4924
  return this.stats.getSnapshot();
4906
4925
  }
4907
- compositeLayerCache(ctx, layerId, dpr) {
4926
+ compositeLayerCache(ctx, layerId) {
4908
4927
  const cached = this.layerCache.getCanvas(layerId);
4928
+ const offset = this.marginViewport.compositeOffset(
4929
+ this.camera.position.x,
4930
+ this.camera.position.y
4931
+ );
4909
4932
  ctx.save();
4910
- ctx.scale(1 / this.camera.zoom, 1 / this.camera.zoom);
4911
- ctx.translate(-this.camera.position.x, -this.camera.position.y);
4912
- ctx.scale(1 / dpr, 1 / dpr);
4913
- ctx.drawImage(cached, 0, 0);
4933
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4934
+ ctx.drawImage(cached, offset.x, offset.y);
4914
4935
  ctx.restore();
4915
4936
  }
4916
- ensureGridCache(cssWidth, cssHeight, dpr) {
4917
- if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
4937
+ ensureGridCache() {
4938
+ const w = this.marginViewport.physicalWidth();
4939
+ const h = this.marginViewport.physicalHeight();
4940
+ if (this.gridCacheCanvas !== null && this.gridCacheCanvas.width === w && this.gridCacheCanvas.height === h) {
4918
4941
  return;
4919
4942
  }
4920
- const physWidth = Math.round(cssWidth * dpr);
4921
- const physHeight = Math.round(cssHeight * dpr);
4922
4943
  if (typeof OffscreenCanvas !== "undefined") {
4923
- this.gridCacheCanvas = new OffscreenCanvas(
4924
- physWidth,
4925
- physHeight
4926
- );
4944
+ this.gridCacheCanvas = new OffscreenCanvas(w, h);
4927
4945
  } else if (typeof document !== "undefined") {
4928
4946
  const el = document.createElement("canvas");
4929
- el.width = physWidth;
4930
- el.height = physHeight;
4947
+ el.width = w;
4948
+ el.height = h;
4931
4949
  this.gridCacheCanvas = el;
4932
4950
  } else {
4933
4951
  this.gridCacheCanvas = null;
@@ -4946,14 +4964,14 @@ var RenderLoop = class {
4946
4964
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4947
4965
  const cssWidth = this.canvasEl.clientWidth;
4948
4966
  const cssHeight = this.canvasEl.clientHeight;
4967
+ this.marginViewport.setViewport(cssWidth, cssHeight, dpr);
4949
4968
  const currentZoom = this.camera.zoom;
4950
4969
  const currentCamX = this.camera.position.x;
4951
4970
  const currentCamY = this.camera.position.y;
4952
- if (currentZoom !== this.lastZoom || currentCamX !== this.lastCamX || currentCamY !== this.lastCamY) {
4971
+ if (this.marginViewport.needsRecenter(currentCamX, currentCamY, currentZoom)) {
4972
+ this.marginViewport.recenter(currentCamX, currentCamY, currentZoom);
4953
4973
  this.layerCache.markAllDirty();
4954
- this.lastZoom = currentZoom;
4955
- this.lastCamX = currentCamX;
4956
- this.lastCamY = currentCamY;
4974
+ this.gridCacheDirty = true;
4957
4975
  }
4958
4976
  ctx.save();
4959
4977
  ctx.scale(dpr, dpr);
@@ -4972,13 +4990,13 @@ var RenderLoop = class {
4972
4990
  ctx.save();
4973
4991
  ctx.translate(this.camera.position.x, this.camera.position.y);
4974
4992
  ctx.scale(this.camera.zoom, this.camera.zoom);
4975
- const visibleRect = this.camera.getVisibleRect(cssWidth, cssHeight);
4976
- const margin = Math.max(visibleRect.w, visibleRect.h) * 0.1;
4993
+ const cullBounds = this.marginViewport.cachedWorldBounds();
4994
+ const cullPad = Math.max(cullBounds.w, cullBounds.h) * 0.05;
4977
4995
  const cullingRect = {
4978
- x: visibleRect.x - margin,
4979
- y: visibleRect.y - margin,
4980
- w: visibleRect.w + margin * 2,
4981
- h: visibleRect.h + margin * 2
4996
+ x: cullBounds.x - cullPad,
4997
+ y: cullBounds.y - cullPad,
4998
+ w: cullBounds.w + cullPad * 2,
4999
+ h: cullBounds.h + cullPad * 2
4982
5000
  };
4983
5001
  const allElements = this.store.getAll();
4984
5002
  this.layerGroups.clear();
@@ -5015,13 +5033,13 @@ var RenderLoop = class {
5015
5033
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
5016
5034
  if (!this.layerCache.isDirty(layerId)) {
5017
5035
  const compT0 = performance.now();
5018
- this.compositeLayerCache(ctx, layerId, dpr);
5036
+ this.compositeLayerCache(ctx, layerId);
5019
5037
  compositeMs += performance.now() - compT0;
5020
5038
  continue;
5021
5039
  }
5022
5040
  if (isActiveDrawingLayer) {
5023
5041
  const compT0 = performance.now();
5024
- this.compositeLayerCache(ctx, layerId, dpr);
5042
+ this.compositeLayerCache(ctx, layerId);
5025
5043
  compositeMs += performance.now() - compT0;
5026
5044
  continue;
5027
5045
  }
@@ -5031,9 +5049,7 @@ var RenderLoop = class {
5031
5049
  const offCanvas = this.layerCache.getCanvas(layerId);
5032
5050
  offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
5033
5051
  offCtx.save();
5034
- offCtx.scale(dpr, dpr);
5035
- offCtx.translate(this.camera.position.x, this.camera.position.y);
5036
- offCtx.scale(this.camera.zoom, this.camera.zoom);
5052
+ this.marginViewport.applyRenderTransform(offCtx);
5037
5053
  for (const element of elements) {
5038
5054
  const elBounds = getElementBounds(element);
5039
5055
  if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
@@ -5043,48 +5059,54 @@ var RenderLoop = class {
5043
5059
  this.layerCache.markClean(layerId);
5044
5060
  layersMs += performance.now() - layerT0;
5045
5061
  const compT0 = performance.now();
5046
- this.compositeLayerCache(ctx, layerId, dpr);
5062
+ this.compositeLayerCache(ctx, layerId);
5047
5063
  compositeMs += performance.now() - compT0;
5048
5064
  }
5049
5065
  }
5050
5066
  if (gridElements.length > 0) {
5051
5067
  const gridT0 = performance.now();
5052
5068
  const gridRef = gridElements[0];
5053
- const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
5054
- if (gridCacheHit) {
5055
- ctx.save();
5056
- ctx.setTransform(1, 0, 0, 1, 0, 0);
5057
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
5058
- ctx.restore();
5059
- } else {
5060
- this.ensureGridCache(cssWidth, cssHeight, dpr);
5069
+ const gridDirty = this.gridCacheDirty || gridRef !== this.lastGridRef;
5070
+ if (gridDirty) {
5071
+ this.ensureGridCache();
5061
5072
  if (this.gridCacheCtx && this.gridCacheCanvas) {
5073
+ const cb = this.marginViewport.cachedWorldBounds();
5074
+ this.renderer.setGridBoundsOverride({
5075
+ minX: cb.x,
5076
+ minY: cb.y,
5077
+ maxX: cb.x + cb.w,
5078
+ maxY: cb.y + cb.h
5079
+ });
5062
5080
  const gc = this.gridCacheCtx;
5063
5081
  gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
5064
5082
  gc.save();
5065
- gc.scale(dpr, dpr);
5066
- gc.translate(currentCamX, currentCamY);
5067
- gc.scale(currentZoom, currentZoom);
5068
- for (const grid of gridElements) {
5069
- this.renderer.renderCanvasElement(gc, grid);
5070
- }
5071
- gc.restore();
5072
- ctx.save();
5073
- ctx.setTransform(1, 0, 0, 1, 0, 0);
5074
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
5075
- ctx.restore();
5076
- } else {
5077
- for (const grid of gridElements) {
5078
- this.renderer.renderCanvasElement(ctx, grid);
5083
+ this.marginViewport.applyRenderTransform(gc);
5084
+ try {
5085
+ for (const grid of gridElements) {
5086
+ this.renderer.renderCanvasElement(gc, grid);
5087
+ }
5088
+ } finally {
5089
+ gc.restore();
5090
+ this.renderer.setGridBoundsOverride(null);
5079
5091
  }
5080
5092
  }
5081
- this.gridCacheZoom = currentZoom;
5082
- this.gridCacheCamX = currentCamX;
5083
- this.gridCacheCamY = currentCamY;
5084
- this.gridCacheWidth = cssWidth;
5085
- this.gridCacheHeight = cssHeight;
5093
+ this.gridCacheDirty = false;
5086
5094
  this.lastGridRef = gridRef;
5087
5095
  }
5096
+ if (this.gridCacheCanvas) {
5097
+ const offset = this.marginViewport.compositeOffset(
5098
+ this.camera.position.x,
5099
+ this.camera.position.y
5100
+ );
5101
+ ctx.save();
5102
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
5103
+ ctx.drawImage(this.gridCacheCanvas, offset.x, offset.y);
5104
+ ctx.restore();
5105
+ } else {
5106
+ for (const grid of gridElements) {
5107
+ this.renderer.renderCanvasElement(ctx, grid);
5108
+ }
5109
+ }
5088
5110
  gridMs = performance.now() - gridT0;
5089
5111
  }
5090
5112
  const overlayT0 = performance.now();
@@ -5116,15 +5138,11 @@ function createOffscreenCanvas(width, height) {
5116
5138
  return canvas;
5117
5139
  }
5118
5140
  var LayerCache = class {
5141
+ constructor(viewport) {
5142
+ this.viewport = viewport;
5143
+ }
5119
5144
  canvases = /* @__PURE__ */ new Map();
5120
5145
  dirtyFlags = /* @__PURE__ */ new Map();
5121
- width;
5122
- height;
5123
- constructor(width, height) {
5124
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5125
- this.width = Math.round(width * dpr);
5126
- this.height = Math.round(height * dpr);
5127
- }
5128
5146
  isDirty(layerId) {
5129
5147
  return this.dirtyFlags.get(layerId) !== false;
5130
5148
  }
@@ -5142,7 +5160,7 @@ var LayerCache = class {
5142
5160
  getCanvas(layerId) {
5143
5161
  let canvas = this.canvases.get(layerId);
5144
5162
  if (!canvas) {
5145
- canvas = createOffscreenCanvas(this.width, this.height);
5163
+ canvas = createOffscreenCanvas(this.viewport.physicalWidth(), this.viewport.physicalHeight());
5146
5164
  this.canvases.set(layerId, canvas);
5147
5165
  this.dirtyFlags.set(layerId, true);
5148
5166
  }
@@ -5152,13 +5170,12 @@ var LayerCache = class {
5152
5170
  const canvas = this.getCanvas(layerId);
5153
5171
  return canvas.getContext("2d");
5154
5172
  }
5155
- resize(width, height) {
5156
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5157
- this.width = Math.round(width * dpr);
5158
- this.height = Math.round(height * dpr);
5173
+ resize() {
5174
+ const w = this.viewport.physicalWidth();
5175
+ const h = this.viewport.physicalHeight();
5159
5176
  for (const [id, canvas] of this.canvases) {
5160
- canvas.width = this.width;
5161
- canvas.height = this.height;
5177
+ canvas.width = w;
5178
+ canvas.height = h;
5162
5179
  this.dirtyFlags.set(id, true);
5163
5180
  }
5164
5181
  }
@@ -5168,6 +5185,75 @@ var LayerCache = class {
5168
5185
  }
5169
5186
  };
5170
5187
 
5188
+ // src/canvas/margin-viewport.ts
5189
+ var MarginViewport = class {
5190
+ constructor(marginPx) {
5191
+ this.marginPx = marginPx;
5192
+ }
5193
+ cssW = 0;
5194
+ cssH = 0;
5195
+ dpr = 1;
5196
+ anchorCamX = 0;
5197
+ anchorCamY = 0;
5198
+ anchorZoom = Number.NaN;
5199
+ // sentinel → first needsRecenter is true
5200
+ viewportDirty = true;
5201
+ setMargin(marginPx) {
5202
+ if (marginPx !== this.marginPx) {
5203
+ this.marginPx = marginPx;
5204
+ this.viewportDirty = true;
5205
+ }
5206
+ }
5207
+ setViewport(cssW, cssH, dpr) {
5208
+ if (cssW !== this.cssW || cssH !== this.cssH || dpr !== this.dpr) {
5209
+ this.cssW = cssW;
5210
+ this.cssH = cssH;
5211
+ this.dpr = dpr;
5212
+ this.viewportDirty = true;
5213
+ }
5214
+ }
5215
+ physicalWidth() {
5216
+ return Math.round((this.cssW + 2 * this.marginPx) * this.dpr);
5217
+ }
5218
+ physicalHeight() {
5219
+ return Math.round((this.cssH + 2 * this.marginPx) * this.dpr);
5220
+ }
5221
+ needsRecenter(camX, camY, zoom) {
5222
+ return this.viewportDirty || zoom !== this.anchorZoom || Math.abs(camX - this.anchorCamX) > this.marginPx || Math.abs(camY - this.anchorCamY) > this.marginPx;
5223
+ }
5224
+ recenter(camX, camY, zoom) {
5225
+ this.anchorCamX = camX;
5226
+ this.anchorCamY = camY;
5227
+ this.anchorZoom = zoom;
5228
+ this.viewportDirty = false;
5229
+ }
5230
+ /** Applies dpr scale + anchor-relative world transform. setViewport must have been called first. */
5231
+ applyRenderTransform(ctx) {
5232
+ ctx.scale(this.dpr, this.dpr);
5233
+ ctx.translate(this.marginPx + this.anchorCamX, this.marginPx + this.anchorCamY);
5234
+ ctx.scale(this.anchorZoom, this.anchorZoom);
5235
+ }
5236
+ // Device-px destination for drawImage(cache, x, y).
5237
+ // A world point P sits in the cache at CSS x `margin + anchorCamX + P*zoom`; it must land on
5238
+ // screen at `camX + P*zoom`; so the blit offset is `camX - anchorCamX - margin` (CSS) * dpr.
5239
+ compositeOffset(camX, camY) {
5240
+ return {
5241
+ x: (camX - this.anchorCamX - this.marginPx) * this.dpr,
5242
+ y: (camY - this.anchorCamY - this.marginPx) * this.dpr
5243
+ };
5244
+ }
5245
+ // World-space bounds of the whole cached region at the anchor (cull rect for re-renders).
5246
+ cachedWorldBounds() {
5247
+ const z = this.anchorZoom;
5248
+ return {
5249
+ x: (-this.marginPx - this.anchorCamX) / z,
5250
+ y: (-this.marginPx - this.anchorCamY) / z,
5251
+ w: (this.cssW + 2 * this.marginPx) / z,
5252
+ h: (this.cssH + 2 * this.marginPx) / z
5253
+ };
5254
+ }
5255
+ };
5256
+
5171
5257
  // src/canvas/viewport.ts
5172
5258
  var Viewport = class {
5173
5259
  constructor(container, options = {}) {
@@ -5185,13 +5271,13 @@ var Viewport = class {
5185
5271
  this.renderLoop.markAllLayersDirty();
5186
5272
  this.requestRender();
5187
5273
  });
5188
- this.renderer.setOnImageError((src) => {
5274
+ this.renderer.setOnImageError((src, cause) => {
5189
5275
  const elementIds = [];
5190
5276
  for (const el of this.store.getAll()) {
5191
5277
  if (el.type === "image" && el.src === src) elementIds.push(el.id);
5192
5278
  }
5193
5279
  if (options.onImageError) {
5194
- options.onImageError({ src, elementIds });
5280
+ options.onImageError({ src, elementIds, cause });
5195
5281
  } else {
5196
5282
  console.warn(`[fieldnotes] image failed to load: ${src}`);
5197
5283
  }
@@ -5244,10 +5330,13 @@ var Viewport = class {
5244
5330
  this.interactMode = new InteractMode({
5245
5331
  getNode: (id) => this.domNodeManager.getNode(id)
5246
5332
  });
5247
- const layerCache = new LayerCache(
5333
+ this.marginViewport = new MarginViewport(options.panBufferMargin ?? 256);
5334
+ this.marginViewport.setViewport(
5248
5335
  this.canvasEl.clientWidth || 800,
5249
- this.canvasEl.clientHeight || 600
5336
+ this.canvasEl.clientHeight || 600,
5337
+ typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1
5250
5338
  );
5339
+ const layerCache = new LayerCache(this.marginViewport);
5251
5340
  this.renderLoop = new RenderLoop({
5252
5341
  canvasEl: this.canvasEl,
5253
5342
  camera: this.camera,
@@ -5257,7 +5346,8 @@ var Viewport = class {
5257
5346
  toolManager: this.toolManager,
5258
5347
  layerManager: this.layerManager,
5259
5348
  domNodeManager: this.domNodeManager,
5260
- layerCache
5349
+ layerCache,
5350
+ marginViewport: this.marginViewport
5261
5351
  });
5262
5352
  this.unsubCamera = this.camera.onChange(() => {
5263
5353
  this.applyCameraTransform();
@@ -5321,6 +5411,7 @@ var Viewport = class {
5321
5411
  noteEditor;
5322
5412
  historyRecorder;
5323
5413
  toolContext;
5414
+ marginViewport;
5324
5415
  resizeObserver = null;
5325
5416
  _snapToGrid = false;
5326
5417
  _gridSize;
@@ -5405,6 +5496,10 @@ var Viewport = class {
5405
5496
  this.loadState(parseState(json));
5406
5497
  }
5407
5498
  setTool(name) {
5499
+ if (!this.toolManager.getTool(name)) {
5500
+ console.warn(`[fieldnotes] setTool: no tool registered as "${name}"`);
5501
+ return;
5502
+ }
5408
5503
  this.toolManager.setTool(name, this.toolContext);
5409
5504
  }
5410
5505
  get shortcuts() {
@@ -5901,20 +5996,6 @@ var PencilTool = class {
5901
5996
  };
5902
5997
 
5903
5998
  // src/elements/stroke-hit.ts
5904
- function distSqToSegment(p, a, b) {
5905
- const abx = b.x - a.x;
5906
- const aby = b.y - a.y;
5907
- const apx = p.x - a.x;
5908
- const apy = p.y - a.y;
5909
- const lenSq = abx * abx + aby * aby;
5910
- if (lenSq === 0) {
5911
- return apx * apx + apy * apy;
5912
- }
5913
- const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
5914
- const dx = p.x - (a.x + t * abx);
5915
- const dy = p.y - (a.y + t * aby);
5916
- return dx * dx + dy * dy;
5917
- }
5918
5999
  function hitTestStroke(stroke, point, radius) {
5919
6000
  const bounds = getElementBounds(stroke);
5920
6001
  if (!bounds) return false;
@@ -7511,7 +7592,7 @@ var TemplateTool = class {
7511
7592
  };
7512
7593
 
7513
7594
  // src/index.ts
7514
- var VERSION = "0.22.0";
7595
+ var VERSION = "0.24.0";
7515
7596
  // Annotate the CommonJS export names for ESM import in node:
7516
7597
  0 && (module.exports = {
7517
7598
  AddElementCommand,