@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/dist/index.js CHANGED
@@ -923,16 +923,17 @@ var KeyboardActions = class {
923
923
  this.pasteCount = 0;
924
924
  }
925
925
  paste() {
926
+ if (this.deps.isToolActive()) return;
926
927
  this.flushPendingNudge();
927
- if (this.clipboard.length === 0 || this.deps.isToolActive()) return;
928
+ if (this.clipboard.length === 0) return;
928
929
  const sel = this.selectTool();
929
930
  if (!sel) return;
930
931
  this.pasteCount++;
931
932
  this.insertClones(this.clipboard, this.pasteCount * 20, sel);
932
933
  }
933
934
  duplicate() {
934
- this.flushPendingNudge();
935
935
  if (this.deps.isToolActive()) return;
936
+ this.flushPendingNudge();
936
937
  const sel = this.selectTool();
937
938
  if (!sel) return;
938
939
  const source = [];
@@ -1116,6 +1117,11 @@ function parseBinding(binding) {
1116
1117
  throw new Error(`Invalid shortcut binding "${binding}": unknown modifier "${part}"`);
1117
1118
  }
1118
1119
  }
1120
+ if (parsed.mod && (parsed.ctrl || parsed.meta)) {
1121
+ throw new Error(
1122
+ `Invalid shortcut binding "${binding}": "mod" already means Ctrl or Cmd; don't combine it with ctrl/meta`
1123
+ );
1124
+ }
1119
1125
  return parsed;
1120
1126
  }
1121
1127
  function bindingMatches(p, e, allowShift) {
@@ -1259,6 +1265,10 @@ var InputHandler = class {
1259
1265
  this.inputFilter.reset();
1260
1266
  this.deferredDown = null;
1261
1267
  this.lastPointerEvent = null;
1268
+ if (this.scope === "focus") {
1269
+ this.element.removeAttribute("tabindex");
1270
+ this.element.style.outline = "";
1271
+ }
1262
1272
  }
1263
1273
  bind() {
1264
1274
  const opts = { signal: this.abortController.signal };
@@ -1589,6 +1599,22 @@ var DoubleTapDetector = class {
1589
1599
  }
1590
1600
  };
1591
1601
 
1602
+ // src/core/geometry.ts
1603
+ function distSqToSegment(p, a, b) {
1604
+ const abx = b.x - a.x;
1605
+ const aby = b.y - a.y;
1606
+ const apx = p.x - a.x;
1607
+ const apy = p.y - a.y;
1608
+ const lenSq = abx * abx + aby * aby;
1609
+ if (lenSq === 0) {
1610
+ return apx * apx + apy * apy;
1611
+ }
1612
+ const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
1613
+ const dx = p.x - (a.x + t * abx);
1614
+ const dy = p.y - (a.y + t * aby);
1615
+ return dx * dx + dy * dy;
1616
+ }
1617
+
1592
1618
  // src/elements/arrow-geometry.ts
1593
1619
  function getArrowControlPoint(from, to, bend) {
1594
1620
  const midX = (from.x + to.x) / 2;
@@ -1677,16 +1703,7 @@ function bezierPoint(from, cp, to, t) {
1677
1703
  };
1678
1704
  }
1679
1705
  function isNearLine(point, a, b, threshold) {
1680
- const dx = b.x - a.x;
1681
- const dy = b.y - a.y;
1682
- const lenSq = dx * dx + dy * dy;
1683
- if (lenSq === 0) {
1684
- return Math.hypot(point.x - a.x, point.y - a.y) <= threshold;
1685
- }
1686
- const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lenSq));
1687
- const projX = a.x + t * dx;
1688
- const projY = a.y + t * dy;
1689
- return Math.hypot(point.x - projX, point.y - projY) <= threshold;
1706
+ return distSqToSegment(point, a, b) <= threshold * threshold;
1690
1707
  }
1691
1708
 
1692
1709
  // src/elements/element-bounds.ts
@@ -2670,6 +2687,7 @@ var ElementRenderer = class {
2670
2687
  canvasSize = null;
2671
2688
  hexTileCache = null;
2672
2689
  hexTileCacheKey = "";
2690
+ gridBoundsOverride = null;
2673
2691
  setStore(store) {
2674
2692
  this.store = store;
2675
2693
  }
@@ -2685,6 +2703,9 @@ var ElementRenderer = class {
2685
2703
  setCanvasSize(w, h) {
2686
2704
  this.canvasSize = { w, h };
2687
2705
  }
2706
+ setGridBoundsOverride(bounds) {
2707
+ this.gridBoundsOverride = bounds;
2708
+ }
2688
2709
  isDomElement(element) {
2689
2710
  return DOM_ELEMENT_TYPES.has(element.type);
2690
2711
  }
@@ -2855,20 +2876,20 @@ var ElementRenderer = class {
2855
2876
  }
2856
2877
  }
2857
2878
  renderGrid(ctx, grid) {
2858
- if (!this.canvasSize) return;
2879
+ const canvasSize = this.canvasSize;
2880
+ if (!canvasSize) return;
2859
2881
  const cam = this.camera;
2860
2882
  if (!cam) return;
2861
- const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2862
- const bottomRight = cam.screenToWorld({
2863
- x: this.canvasSize.w,
2864
- y: this.canvasSize.h
2865
- });
2866
- const bounds = {
2867
- minX: topLeft.x,
2868
- minY: topLeft.y,
2869
- maxX: bottomRight.x,
2870
- maxY: bottomRight.y
2871
- };
2883
+ const bounds = this.gridBoundsOverride ?? (() => {
2884
+ const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2885
+ const bottomRight = cam.screenToWorld({ x: canvasSize.w, y: canvasSize.h });
2886
+ return {
2887
+ minX: topLeft.x,
2888
+ minY: topLeft.y,
2889
+ maxX: bottomRight.x,
2890
+ maxY: bottomRight.y
2891
+ };
2892
+ })();
2872
2893
  if (grid.gridType === "hex") {
2873
2894
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2874
2895
  const scale = cam.zoom * dpr;
@@ -3113,9 +3134,9 @@ var ElementRenderer = class {
3113
3134
  });
3114
3135
  }
3115
3136
  };
3116
- img.onerror = () => {
3137
+ img.onerror = (event) => {
3117
3138
  this.imageCache.set(src, "failed");
3118
- this.onImageError?.(src);
3139
+ this.onImageError?.(src, event);
3119
3140
  this.onImageLoad?.();
3120
3141
  };
3121
3142
  return null;
@@ -3540,7 +3561,10 @@ var NoteEditor = class {
3540
3561
  this.editingNode.removeAttribute("data-fn-placeholder");
3541
3562
  this.editingNode.removeAttribute("data-fn-empty");
3542
3563
  const text = sanitizeNoteHtml(this.editingNode.innerHTML);
3543
- store.update(this.editingId, { text });
3564
+ const current = store.getById(this.editingId);
3565
+ if (current && (current.type === "note" || current.type === "text") && current.text !== text) {
3566
+ store.update(this.editingId, { text });
3567
+ }
3544
3568
  this.editingNode.contentEditable = "false";
3545
3569
  Object.assign(this.editingNode.style, {
3546
3570
  userSelect: "none",
@@ -4727,19 +4751,14 @@ var RenderLoop = class {
4727
4751
  layerManager;
4728
4752
  domNodeManager;
4729
4753
  layerCache;
4754
+ marginViewport;
4730
4755
  activeDrawingLayerId = null;
4731
- lastZoom;
4732
- lastCamX;
4733
- lastCamY;
4756
+ gridCacheDirty = true;
4757
+ // set on recenter/viewport-change; consumed by the grid block
4734
4758
  stats = new RenderStats();
4735
4759
  layerGroups = /* @__PURE__ */ new Map();
4736
4760
  gridCacheCanvas = null;
4737
4761
  gridCacheCtx = null;
4738
- gridCacheZoom = -1;
4739
- gridCacheCamX = -Infinity;
4740
- gridCacheCamY = -Infinity;
4741
- gridCacheWidth = 0;
4742
- gridCacheHeight = 0;
4743
4762
  lastGridRef = null;
4744
4763
  constructor(deps) {
4745
4764
  this.canvasEl = deps.canvasEl;
@@ -4751,9 +4770,7 @@ var RenderLoop = class {
4751
4770
  this.layerManager = deps.layerManager;
4752
4771
  this.domNodeManager = deps.domNodeManager;
4753
4772
  this.layerCache = deps.layerCache;
4754
- this.lastZoom = deps.camera.zoom;
4755
- this.lastCamX = deps.camera.position.x;
4756
- this.lastCamY = deps.camera.position.y;
4773
+ this.marginViewport = deps.marginViewport;
4757
4774
  }
4758
4775
  requestRender() {
4759
4776
  this.needsRender = true;
@@ -4780,7 +4797,9 @@ var RenderLoop = class {
4780
4797
  setCanvasSize(width, height) {
4781
4798
  this.canvasEl.width = width;
4782
4799
  this.canvasEl.height = height;
4783
- this.layerCache.resize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
4800
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4801
+ this.marginViewport.setViewport(width / dpr, height / dpr, dpr);
4802
+ this.layerCache.resize();
4784
4803
  }
4785
4804
  setActiveDrawingLayer(layerId) {
4786
4805
  this.activeDrawingLayerId = layerId;
@@ -4794,30 +4813,29 @@ var RenderLoop = class {
4794
4813
  getStats() {
4795
4814
  return this.stats.getSnapshot();
4796
4815
  }
4797
- compositeLayerCache(ctx, layerId, dpr) {
4816
+ compositeLayerCache(ctx, layerId) {
4798
4817
  const cached = this.layerCache.getCanvas(layerId);
4818
+ const offset = this.marginViewport.compositeOffset(
4819
+ this.camera.position.x,
4820
+ this.camera.position.y
4821
+ );
4799
4822
  ctx.save();
4800
- ctx.scale(1 / this.camera.zoom, 1 / this.camera.zoom);
4801
- ctx.translate(-this.camera.position.x, -this.camera.position.y);
4802
- ctx.scale(1 / dpr, 1 / dpr);
4803
- ctx.drawImage(cached, 0, 0);
4823
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4824
+ ctx.drawImage(cached, offset.x, offset.y);
4804
4825
  ctx.restore();
4805
4826
  }
4806
- ensureGridCache(cssWidth, cssHeight, dpr) {
4807
- if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
4827
+ ensureGridCache() {
4828
+ const w = this.marginViewport.physicalWidth();
4829
+ const h = this.marginViewport.physicalHeight();
4830
+ if (this.gridCacheCanvas !== null && this.gridCacheCanvas.width === w && this.gridCacheCanvas.height === h) {
4808
4831
  return;
4809
4832
  }
4810
- const physWidth = Math.round(cssWidth * dpr);
4811
- const physHeight = Math.round(cssHeight * dpr);
4812
4833
  if (typeof OffscreenCanvas !== "undefined") {
4813
- this.gridCacheCanvas = new OffscreenCanvas(
4814
- physWidth,
4815
- physHeight
4816
- );
4834
+ this.gridCacheCanvas = new OffscreenCanvas(w, h);
4817
4835
  } else if (typeof document !== "undefined") {
4818
4836
  const el = document.createElement("canvas");
4819
- el.width = physWidth;
4820
- el.height = physHeight;
4837
+ el.width = w;
4838
+ el.height = h;
4821
4839
  this.gridCacheCanvas = el;
4822
4840
  } else {
4823
4841
  this.gridCacheCanvas = null;
@@ -4836,14 +4854,14 @@ var RenderLoop = class {
4836
4854
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4837
4855
  const cssWidth = this.canvasEl.clientWidth;
4838
4856
  const cssHeight = this.canvasEl.clientHeight;
4857
+ this.marginViewport.setViewport(cssWidth, cssHeight, dpr);
4839
4858
  const currentZoom = this.camera.zoom;
4840
4859
  const currentCamX = this.camera.position.x;
4841
4860
  const currentCamY = this.camera.position.y;
4842
- if (currentZoom !== this.lastZoom || currentCamX !== this.lastCamX || currentCamY !== this.lastCamY) {
4861
+ if (this.marginViewport.needsRecenter(currentCamX, currentCamY, currentZoom)) {
4862
+ this.marginViewport.recenter(currentCamX, currentCamY, currentZoom);
4843
4863
  this.layerCache.markAllDirty();
4844
- this.lastZoom = currentZoom;
4845
- this.lastCamX = currentCamX;
4846
- this.lastCamY = currentCamY;
4864
+ this.gridCacheDirty = true;
4847
4865
  }
4848
4866
  ctx.save();
4849
4867
  ctx.scale(dpr, dpr);
@@ -4862,13 +4880,13 @@ var RenderLoop = class {
4862
4880
  ctx.save();
4863
4881
  ctx.translate(this.camera.position.x, this.camera.position.y);
4864
4882
  ctx.scale(this.camera.zoom, this.camera.zoom);
4865
- const visibleRect = this.camera.getVisibleRect(cssWidth, cssHeight);
4866
- const margin = Math.max(visibleRect.w, visibleRect.h) * 0.1;
4883
+ const cullBounds = this.marginViewport.cachedWorldBounds();
4884
+ const cullPad = Math.max(cullBounds.w, cullBounds.h) * 0.05;
4867
4885
  const cullingRect = {
4868
- x: visibleRect.x - margin,
4869
- y: visibleRect.y - margin,
4870
- w: visibleRect.w + margin * 2,
4871
- h: visibleRect.h + margin * 2
4886
+ x: cullBounds.x - cullPad,
4887
+ y: cullBounds.y - cullPad,
4888
+ w: cullBounds.w + cullPad * 2,
4889
+ h: cullBounds.h + cullPad * 2
4872
4890
  };
4873
4891
  const allElements = this.store.getAll();
4874
4892
  this.layerGroups.clear();
@@ -4905,13 +4923,13 @@ var RenderLoop = class {
4905
4923
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
4906
4924
  if (!this.layerCache.isDirty(layerId)) {
4907
4925
  const compT0 = performance.now();
4908
- this.compositeLayerCache(ctx, layerId, dpr);
4926
+ this.compositeLayerCache(ctx, layerId);
4909
4927
  compositeMs += performance.now() - compT0;
4910
4928
  continue;
4911
4929
  }
4912
4930
  if (isActiveDrawingLayer) {
4913
4931
  const compT0 = performance.now();
4914
- this.compositeLayerCache(ctx, layerId, dpr);
4932
+ this.compositeLayerCache(ctx, layerId);
4915
4933
  compositeMs += performance.now() - compT0;
4916
4934
  continue;
4917
4935
  }
@@ -4921,9 +4939,7 @@ var RenderLoop = class {
4921
4939
  const offCanvas = this.layerCache.getCanvas(layerId);
4922
4940
  offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
4923
4941
  offCtx.save();
4924
- offCtx.scale(dpr, dpr);
4925
- offCtx.translate(this.camera.position.x, this.camera.position.y);
4926
- offCtx.scale(this.camera.zoom, this.camera.zoom);
4942
+ this.marginViewport.applyRenderTransform(offCtx);
4927
4943
  for (const element of elements) {
4928
4944
  const elBounds = getElementBounds(element);
4929
4945
  if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
@@ -4933,48 +4949,54 @@ var RenderLoop = class {
4933
4949
  this.layerCache.markClean(layerId);
4934
4950
  layersMs += performance.now() - layerT0;
4935
4951
  const compT0 = performance.now();
4936
- this.compositeLayerCache(ctx, layerId, dpr);
4952
+ this.compositeLayerCache(ctx, layerId);
4937
4953
  compositeMs += performance.now() - compT0;
4938
4954
  }
4939
4955
  }
4940
4956
  if (gridElements.length > 0) {
4941
4957
  const gridT0 = performance.now();
4942
4958
  const gridRef = gridElements[0];
4943
- const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
4944
- if (gridCacheHit) {
4945
- ctx.save();
4946
- ctx.setTransform(1, 0, 0, 1, 0, 0);
4947
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
4948
- ctx.restore();
4949
- } else {
4950
- this.ensureGridCache(cssWidth, cssHeight, dpr);
4959
+ const gridDirty = this.gridCacheDirty || gridRef !== this.lastGridRef;
4960
+ if (gridDirty) {
4961
+ this.ensureGridCache();
4951
4962
  if (this.gridCacheCtx && this.gridCacheCanvas) {
4963
+ const cb = this.marginViewport.cachedWorldBounds();
4964
+ this.renderer.setGridBoundsOverride({
4965
+ minX: cb.x,
4966
+ minY: cb.y,
4967
+ maxX: cb.x + cb.w,
4968
+ maxY: cb.y + cb.h
4969
+ });
4952
4970
  const gc = this.gridCacheCtx;
4953
4971
  gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
4954
4972
  gc.save();
4955
- gc.scale(dpr, dpr);
4956
- gc.translate(currentCamX, currentCamY);
4957
- gc.scale(currentZoom, currentZoom);
4958
- for (const grid of gridElements) {
4959
- this.renderer.renderCanvasElement(gc, grid);
4960
- }
4961
- gc.restore();
4962
- ctx.save();
4963
- ctx.setTransform(1, 0, 0, 1, 0, 0);
4964
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
4965
- ctx.restore();
4966
- } else {
4967
- for (const grid of gridElements) {
4968
- this.renderer.renderCanvasElement(ctx, grid);
4973
+ this.marginViewport.applyRenderTransform(gc);
4974
+ try {
4975
+ for (const grid of gridElements) {
4976
+ this.renderer.renderCanvasElement(gc, grid);
4977
+ }
4978
+ } finally {
4979
+ gc.restore();
4980
+ this.renderer.setGridBoundsOverride(null);
4969
4981
  }
4970
4982
  }
4971
- this.gridCacheZoom = currentZoom;
4972
- this.gridCacheCamX = currentCamX;
4973
- this.gridCacheCamY = currentCamY;
4974
- this.gridCacheWidth = cssWidth;
4975
- this.gridCacheHeight = cssHeight;
4983
+ this.gridCacheDirty = false;
4976
4984
  this.lastGridRef = gridRef;
4977
4985
  }
4986
+ if (this.gridCacheCanvas) {
4987
+ const offset = this.marginViewport.compositeOffset(
4988
+ this.camera.position.x,
4989
+ this.camera.position.y
4990
+ );
4991
+ ctx.save();
4992
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4993
+ ctx.drawImage(this.gridCacheCanvas, offset.x, offset.y);
4994
+ ctx.restore();
4995
+ } else {
4996
+ for (const grid of gridElements) {
4997
+ this.renderer.renderCanvasElement(ctx, grid);
4998
+ }
4999
+ }
4978
5000
  gridMs = performance.now() - gridT0;
4979
5001
  }
4980
5002
  const overlayT0 = performance.now();
@@ -5006,15 +5028,11 @@ function createOffscreenCanvas(width, height) {
5006
5028
  return canvas;
5007
5029
  }
5008
5030
  var LayerCache = class {
5031
+ constructor(viewport) {
5032
+ this.viewport = viewport;
5033
+ }
5009
5034
  canvases = /* @__PURE__ */ new Map();
5010
5035
  dirtyFlags = /* @__PURE__ */ new Map();
5011
- width;
5012
- height;
5013
- constructor(width, height) {
5014
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5015
- this.width = Math.round(width * dpr);
5016
- this.height = Math.round(height * dpr);
5017
- }
5018
5036
  isDirty(layerId) {
5019
5037
  return this.dirtyFlags.get(layerId) !== false;
5020
5038
  }
@@ -5032,7 +5050,7 @@ var LayerCache = class {
5032
5050
  getCanvas(layerId) {
5033
5051
  let canvas = this.canvases.get(layerId);
5034
5052
  if (!canvas) {
5035
- canvas = createOffscreenCanvas(this.width, this.height);
5053
+ canvas = createOffscreenCanvas(this.viewport.physicalWidth(), this.viewport.physicalHeight());
5036
5054
  this.canvases.set(layerId, canvas);
5037
5055
  this.dirtyFlags.set(layerId, true);
5038
5056
  }
@@ -5042,13 +5060,12 @@ var LayerCache = class {
5042
5060
  const canvas = this.getCanvas(layerId);
5043
5061
  return canvas.getContext("2d");
5044
5062
  }
5045
- resize(width, height) {
5046
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5047
- this.width = Math.round(width * dpr);
5048
- this.height = Math.round(height * dpr);
5063
+ resize() {
5064
+ const w = this.viewport.physicalWidth();
5065
+ const h = this.viewport.physicalHeight();
5049
5066
  for (const [id, canvas] of this.canvases) {
5050
- canvas.width = this.width;
5051
- canvas.height = this.height;
5067
+ canvas.width = w;
5068
+ canvas.height = h;
5052
5069
  this.dirtyFlags.set(id, true);
5053
5070
  }
5054
5071
  }
@@ -5058,6 +5075,75 @@ var LayerCache = class {
5058
5075
  }
5059
5076
  };
5060
5077
 
5078
+ // src/canvas/margin-viewport.ts
5079
+ var MarginViewport = class {
5080
+ constructor(marginPx) {
5081
+ this.marginPx = marginPx;
5082
+ }
5083
+ cssW = 0;
5084
+ cssH = 0;
5085
+ dpr = 1;
5086
+ anchorCamX = 0;
5087
+ anchorCamY = 0;
5088
+ anchorZoom = Number.NaN;
5089
+ // sentinel → first needsRecenter is true
5090
+ viewportDirty = true;
5091
+ setMargin(marginPx) {
5092
+ if (marginPx !== this.marginPx) {
5093
+ this.marginPx = marginPx;
5094
+ this.viewportDirty = true;
5095
+ }
5096
+ }
5097
+ setViewport(cssW, cssH, dpr) {
5098
+ if (cssW !== this.cssW || cssH !== this.cssH || dpr !== this.dpr) {
5099
+ this.cssW = cssW;
5100
+ this.cssH = cssH;
5101
+ this.dpr = dpr;
5102
+ this.viewportDirty = true;
5103
+ }
5104
+ }
5105
+ physicalWidth() {
5106
+ return Math.round((this.cssW + 2 * this.marginPx) * this.dpr);
5107
+ }
5108
+ physicalHeight() {
5109
+ return Math.round((this.cssH + 2 * this.marginPx) * this.dpr);
5110
+ }
5111
+ needsRecenter(camX, camY, zoom) {
5112
+ return this.viewportDirty || zoom !== this.anchorZoom || Math.abs(camX - this.anchorCamX) > this.marginPx || Math.abs(camY - this.anchorCamY) > this.marginPx;
5113
+ }
5114
+ recenter(camX, camY, zoom) {
5115
+ this.anchorCamX = camX;
5116
+ this.anchorCamY = camY;
5117
+ this.anchorZoom = zoom;
5118
+ this.viewportDirty = false;
5119
+ }
5120
+ /** Applies dpr scale + anchor-relative world transform. setViewport must have been called first. */
5121
+ applyRenderTransform(ctx) {
5122
+ ctx.scale(this.dpr, this.dpr);
5123
+ ctx.translate(this.marginPx + this.anchorCamX, this.marginPx + this.anchorCamY);
5124
+ ctx.scale(this.anchorZoom, this.anchorZoom);
5125
+ }
5126
+ // Device-px destination for drawImage(cache, x, y).
5127
+ // A world point P sits in the cache at CSS x `margin + anchorCamX + P*zoom`; it must land on
5128
+ // screen at `camX + P*zoom`; so the blit offset is `camX - anchorCamX - margin` (CSS) * dpr.
5129
+ compositeOffset(camX, camY) {
5130
+ return {
5131
+ x: (camX - this.anchorCamX - this.marginPx) * this.dpr,
5132
+ y: (camY - this.anchorCamY - this.marginPx) * this.dpr
5133
+ };
5134
+ }
5135
+ // World-space bounds of the whole cached region at the anchor (cull rect for re-renders).
5136
+ cachedWorldBounds() {
5137
+ const z = this.anchorZoom;
5138
+ return {
5139
+ x: (-this.marginPx - this.anchorCamX) / z,
5140
+ y: (-this.marginPx - this.anchorCamY) / z,
5141
+ w: (this.cssW + 2 * this.marginPx) / z,
5142
+ h: (this.cssH + 2 * this.marginPx) / z
5143
+ };
5144
+ }
5145
+ };
5146
+
5061
5147
  // src/canvas/viewport.ts
5062
5148
  var Viewport = class {
5063
5149
  constructor(container, options = {}) {
@@ -5075,13 +5161,13 @@ var Viewport = class {
5075
5161
  this.renderLoop.markAllLayersDirty();
5076
5162
  this.requestRender();
5077
5163
  });
5078
- this.renderer.setOnImageError((src) => {
5164
+ this.renderer.setOnImageError((src, cause) => {
5079
5165
  const elementIds = [];
5080
5166
  for (const el of this.store.getAll()) {
5081
5167
  if (el.type === "image" && el.src === src) elementIds.push(el.id);
5082
5168
  }
5083
5169
  if (options.onImageError) {
5084
- options.onImageError({ src, elementIds });
5170
+ options.onImageError({ src, elementIds, cause });
5085
5171
  } else {
5086
5172
  console.warn(`[fieldnotes] image failed to load: ${src}`);
5087
5173
  }
@@ -5134,10 +5220,13 @@ var Viewport = class {
5134
5220
  this.interactMode = new InteractMode({
5135
5221
  getNode: (id) => this.domNodeManager.getNode(id)
5136
5222
  });
5137
- const layerCache = new LayerCache(
5223
+ this.marginViewport = new MarginViewport(options.panBufferMargin ?? 256);
5224
+ this.marginViewport.setViewport(
5138
5225
  this.canvasEl.clientWidth || 800,
5139
- this.canvasEl.clientHeight || 600
5226
+ this.canvasEl.clientHeight || 600,
5227
+ typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1
5140
5228
  );
5229
+ const layerCache = new LayerCache(this.marginViewport);
5141
5230
  this.renderLoop = new RenderLoop({
5142
5231
  canvasEl: this.canvasEl,
5143
5232
  camera: this.camera,
@@ -5147,7 +5236,8 @@ var Viewport = class {
5147
5236
  toolManager: this.toolManager,
5148
5237
  layerManager: this.layerManager,
5149
5238
  domNodeManager: this.domNodeManager,
5150
- layerCache
5239
+ layerCache,
5240
+ marginViewport: this.marginViewport
5151
5241
  });
5152
5242
  this.unsubCamera = this.camera.onChange(() => {
5153
5243
  this.applyCameraTransform();
@@ -5211,6 +5301,7 @@ var Viewport = class {
5211
5301
  noteEditor;
5212
5302
  historyRecorder;
5213
5303
  toolContext;
5304
+ marginViewport;
5214
5305
  resizeObserver = null;
5215
5306
  _snapToGrid = false;
5216
5307
  _gridSize;
@@ -5295,6 +5386,10 @@ var Viewport = class {
5295
5386
  this.loadState(parseState(json));
5296
5387
  }
5297
5388
  setTool(name) {
5389
+ if (!this.toolManager.getTool(name)) {
5390
+ console.warn(`[fieldnotes] setTool: no tool registered as "${name}"`);
5391
+ return;
5392
+ }
5298
5393
  this.toolManager.setTool(name, this.toolContext);
5299
5394
  }
5300
5395
  get shortcuts() {
@@ -5791,20 +5886,6 @@ var PencilTool = class {
5791
5886
  };
5792
5887
 
5793
5888
  // 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
5889
  function hitTestStroke(stroke, point, radius) {
5809
5890
  const bounds = getElementBounds(stroke);
5810
5891
  if (!bounds) return false;
@@ -7401,7 +7482,7 @@ var TemplateTool = class {
7401
7482
  };
7402
7483
 
7403
7484
  // src/index.ts
7404
- var VERSION = "0.22.0";
7485
+ var VERSION = "0.24.0";
7405
7486
  export {
7406
7487
  AddElementCommand,
7407
7488
  ArrowTool,