@fieldnotes/core 0.59.0 → 0.61.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.cjs CHANGED
@@ -23,9 +23,11 @@ __export(index_exports, {
23
23
  ArrowTool: () => ArrowTool,
24
24
  AutoSave: () => AutoSave,
25
25
  Camera: () => Camera,
26
+ CameraAnimator: () => CameraAnimator,
26
27
  DEFAULT_NOTE_FONT_SIZE: () => DEFAULT_NOTE_FONT_SIZE,
27
28
  ElementStore: () => ElementStore,
28
29
  EraserTool: () => EraserTool,
30
+ FOCUS_PRESENCE_KIND: () => FOCUS_PRESENCE_KIND,
29
31
  HandTool: () => HandTool,
30
32
  HistoryStack: () => HistoryStack,
31
33
  ImageTool: () => ImageTool,
@@ -43,6 +45,7 @@ __export(index_exports, {
43
45
  PencilTool: () => PencilTool,
44
46
  PingInput: () => PingInput,
45
47
  PingTool: () => PingTool,
48
+ RemoteFocusReceiver: () => RemoteFocusReceiver,
46
49
  RemoteLaserOverlay: () => RemoteLaserOverlay,
47
50
  RemoteMeasureOverlay: () => RemoteMeasureOverlay,
48
51
  RemotePingOverlay: () => RemotePingOverlay,
@@ -53,7 +56,10 @@ __export(index_exports, {
53
56
  ToolManager: () => ToolManager,
54
57
  VERSION: () => VERSION,
55
58
  Viewport: () => Viewport,
59
+ applyCameraView: () => applyCameraView,
56
60
  boundsIntersect: () => boundsIntersect,
61
+ cameraOriginForView: () => cameraOriginForView,
62
+ captureCameraView: () => captureCameraView,
57
63
  createArrow: () => createArrow,
58
64
  createGrid: () => createGrid,
59
65
  createHtmlElement: () => createHtmlElement,
@@ -66,6 +72,7 @@ __export(index_exports, {
66
72
  drawHexPath: () => drawHexPath,
67
73
  exportImage: () => exportImage,
68
74
  exportSvg: () => exportSvg,
75
+ fitZoomForView: () => fitZoomForView,
69
76
  getActiveFormats: () => getActiveFormats,
70
77
  getArrowBounds: () => getArrowBounds,
71
78
  getArrowControlPoint: () => getArrowControlPoint,
@@ -81,6 +88,7 @@ __export(index_exports, {
81
88
  getHexCellsInRectangle: () => getHexCellsInRectangle,
82
89
  getHexCellsInSquare: () => getHexCellsInSquare,
83
90
  getHexDistance: () => getHexDistance,
91
+ isFocusPresence: () => isFocusPresence,
84
92
  isLaserTrailPresence: () => isLaserTrailPresence,
85
93
  isMeasurePresence: () => isMeasurePresence,
86
94
  isNearBezier: () => isNearBezier,
@@ -90,6 +98,7 @@ __export(index_exports, {
90
98
  snapPoint: () => snapPoint,
91
99
  snapToHexCenter: () => snapToHexCenter,
92
100
  styleToPatch: () => styleToPatch,
101
+ toFocusPresence: () => toFocusPresence,
93
102
  toLaserTrailPresence: () => toLaserTrailPresence,
94
103
  toMeasurePresence: () => toMeasurePresence,
95
104
  toPingPresence: () => toPingPresence,
@@ -5272,6 +5281,7 @@ var ToolManager = class {
5272
5281
  tools = /* @__PURE__ */ new Map();
5273
5282
  current = null;
5274
5283
  changeListeners = /* @__PURE__ */ new Set();
5284
+ registerListeners = /* @__PURE__ */ new Set();
5275
5285
  get activeTool() {
5276
5286
  return this.current;
5277
5287
  }
@@ -5280,6 +5290,7 @@ var ToolManager = class {
5280
5290
  }
5281
5291
  register(tool) {
5282
5292
  this.tools.set(tool.name, tool);
5293
+ this.registerListeners.forEach((fn) => fn(tool));
5283
5294
  }
5284
5295
  getTool(name) {
5285
5296
  return this.tools.get(name);
@@ -5305,6 +5316,10 @@ var ToolManager = class {
5305
5316
  this.changeListeners.add(listener);
5306
5317
  return () => this.changeListeners.delete(listener);
5307
5318
  }
5319
+ onRegister(listener) {
5320
+ this.registerListeners.add(listener);
5321
+ return () => this.registerListeners.delete(listener);
5322
+ }
5308
5323
  };
5309
5324
 
5310
5325
  // src/history/history-stack.ts
@@ -5496,6 +5511,7 @@ var HistoryRecorder = class {
5496
5511
  generation = 0;
5497
5512
  updateSnapshots = /* @__PURE__ */ new Map();
5498
5513
  unsubscribers;
5514
+ transactionEndListeners = /* @__PURE__ */ new Set();
5499
5515
  pause() {
5500
5516
  this.recording = false;
5501
5517
  }
@@ -5513,19 +5529,35 @@ var HistoryRecorder = class {
5513
5529
  get currentTransactionId() {
5514
5530
  return this.transaction !== null ? this.generation : null;
5515
5531
  }
5532
+ onTransactionEnd(listener) {
5533
+ this.transactionEndListeners.add(listener);
5534
+ return () => this.transactionEndListeners.delete(listener);
5535
+ }
5536
+ notifyTransactionEnd() {
5537
+ for (const listener of this.transactionEndListeners) {
5538
+ try {
5539
+ listener();
5540
+ } catch {
5541
+ }
5542
+ }
5543
+ }
5516
5544
  commit() {
5517
5545
  if (!this.transaction) return;
5518
5546
  const finalCommands = this.flushUpdateSnapshots();
5519
5547
  const all = [...this.transaction, ...finalCommands];
5520
5548
  this.transaction = null;
5521
5549
  this.updateSnapshots.clear();
5522
- if (all.length === 0) return;
5523
- const first = all[0];
5524
- this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5550
+ if (all.length > 0) {
5551
+ const first = all[0];
5552
+ this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5553
+ }
5554
+ this.notifyTransactionEnd();
5525
5555
  }
5526
5556
  rollback() {
5557
+ const wasOpen = this.transaction !== null;
5527
5558
  this.transaction = null;
5528
5559
  this.updateSnapshots.clear();
5560
+ if (wasOpen) this.notifyTransactionEnd();
5529
5561
  }
5530
5562
  destroy() {
5531
5563
  this.unsubscribers.forEach((fn) => fn());
@@ -7817,6 +7849,14 @@ function rotateElementPatch(el, bounds, pivot, delta) {
7817
7849
  }
7818
7850
 
7819
7851
  // src/canvas/selection-ops.ts
7852
+ var STYLE_FIELDS = [
7853
+ "color",
7854
+ "fillColor",
7855
+ "strokeWidth",
7856
+ "opacity",
7857
+ "fontSize",
7858
+ "strokeStyle"
7859
+ ];
7820
7860
  function sharedValue(values) {
7821
7861
  const present = values.filter((v) => v !== void 0);
7822
7862
  if (present.length === 0) return void 0;
@@ -7851,6 +7891,42 @@ var SelectionOps = class {
7851
7891
  if (strokeStyle !== void 0) result.strokeStyle = strokeStyle;
7852
7892
  return result;
7853
7893
  }
7894
+ /**
7895
+ * Unlike `getStyle()` — which returns `{}` for a selection whose elements
7896
+ * have no applicable style fields (e.g. images) — this returns `null` when
7897
+ * no field is applicable, so callers can distinguish "nothing to show" from
7898
+ * "everything shared but empty".
7899
+ */
7900
+ getStyleDetails() {
7901
+ const ids = this.deps.getSelectedIds();
7902
+ if (ids.length === 0) return null;
7903
+ const styles = [];
7904
+ for (const id of ids) {
7905
+ const el = this.deps.store.getById(id);
7906
+ if (el) styles.push(getElementStyle(el));
7907
+ }
7908
+ if (styles.length === 0) return null;
7909
+ const common = {};
7910
+ const applicable = [];
7911
+ const mixed = [];
7912
+ for (const field of STYLE_FIELDS) {
7913
+ const allValues = styles.map((s) => s[field]);
7914
+ const values = [];
7915
+ for (const v of allValues) {
7916
+ if (v !== void 0) values.push(v);
7917
+ }
7918
+ if (values.length === 0) continue;
7919
+ applicable.push(field);
7920
+ const distinct = new Set(values);
7921
+ if (distinct.size > 1) {
7922
+ mixed.push(field);
7923
+ } else {
7924
+ common[field] = values[0];
7925
+ }
7926
+ }
7927
+ if (applicable.length === 0) return null;
7928
+ return { common, applicable, mixed };
7929
+ }
7854
7930
  applyStyle(style) {
7855
7931
  const ids = this.deps.getSelectedIds();
7856
7932
  if (ids.length === 0) return;
@@ -8225,7 +8301,7 @@ var ViewportInteractions = class {
8225
8301
  var EMPTY_IDS = [];
8226
8302
  function noop() {
8227
8303
  }
8228
- var Viewport = class {
8304
+ var Viewport = class _Viewport {
8229
8305
  constructor(container, options = {}) {
8230
8306
  this.container = container;
8231
8307
  this.camera = new Camera(options.camera);
@@ -8234,6 +8310,13 @@ var Viewport = class {
8234
8310
  this.store = new ElementStore();
8235
8311
  this.layerManager = new LayerManager(this.store);
8236
8312
  this.toolManager = new ToolManager();
8313
+ this.unsubToolRegister = this.toolManager.onRegister((tool) => {
8314
+ if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
8315
+ });
8316
+ const existingSelect = this.getSelectTool();
8317
+ if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
8318
+ this.attachSelectionSource(existingSelect);
8319
+ }
8237
8320
  this.renderer = new ElementRenderer();
8238
8321
  this.renderer.setStore(this.store);
8239
8322
  this.renderer.setCamera(this.camera);
@@ -8268,6 +8351,11 @@ var Viewport = class {
8268
8351
  this.dropHandler = options.onDrop;
8269
8352
  this.history = new HistoryStack();
8270
8353
  this.historyRecorder = new HistoryRecorder(this.store, this.history, this.layerManager);
8354
+ this.unsubRecorderEnd = this.historyRecorder.onTransactionEnd(() => {
8355
+ if (!this.pendingSelectionPrune) return;
8356
+ this.pendingSelectionPrune = false;
8357
+ this.pruneSelection();
8358
+ });
8271
8359
  this.selectionOps = new SelectionOps({
8272
8360
  store: this.store,
8273
8361
  recorder: this.historyRecorder,
@@ -8387,6 +8475,7 @@ var Viewport = class {
8387
8475
  this.domNodeManager.removeDomNode(el.id);
8388
8476
  this.renderLoop.markLayerDirty(el.layerId);
8389
8477
  this.requestRender();
8478
+ this.handleRemovedElement(el.id);
8390
8479
  }),
8391
8480
  this.store.on("update", ({ previous, current }) => {
8392
8481
  if (current.type === "grid") this.gridController.syncContext();
@@ -8401,6 +8490,7 @@ var Viewport = class {
8401
8490
  this.renderLoop.markAllLayersDirty();
8402
8491
  this.gridController.syncContext();
8403
8492
  this.requestRender();
8493
+ this.pruneSelection();
8404
8494
  })
8405
8495
  ];
8406
8496
  this.layerManager.on("change", () => {
@@ -8470,6 +8560,13 @@ var Viewport = class {
8470
8560
  minimap = null;
8471
8561
  htmlRenderers = /* @__PURE__ */ new Map();
8472
8562
  resizeListeners = /* @__PURE__ */ new Set();
8563
+ selectionListeners = /* @__PURE__ */ new Set();
8564
+ detachSelectionSource = null;
8565
+ unsubToolRegister = () => {
8566
+ };
8567
+ pendingSelectionPrune = false;
8568
+ unsubRecorderEnd = () => {
8569
+ };
8473
8570
  get ctx() {
8474
8571
  return this.canvasEl.getContext("2d");
8475
8572
  }
@@ -8498,6 +8595,17 @@ var Viewport = class {
8498
8595
  getVisibleRect() {
8499
8596
  return this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
8500
8597
  }
8598
+ /**
8599
+ * Size in CSS pixels of the canvas that `getVisibleRect()` measures.
8600
+ * Exposed because `canvasEl` is private: consumers can only reach the
8601
+ * wrapper (via `domLayer.parentElement`), so without this accessor the
8602
+ * canonical size behind `getVisibleRect()` is unreachable and callers
8603
+ * resort to `getVisibleRect().w * camera.zoom`. Capture and restore must
8604
+ * measure the same element or saved views do not round-trip.
8605
+ */
8606
+ getCanvasSize() {
8607
+ return { w: this.canvasEl.clientWidth, h: this.canvasEl.clientHeight };
8608
+ }
8501
8609
  /** Centers the camera on a world point without changing zoom. */
8502
8610
  centerCameraAt(world) {
8503
8611
  const z = this.camera.zoom;
@@ -8728,6 +8836,42 @@ var Viewport = class {
8728
8836
  getSelectTool() {
8729
8837
  return this.toolManager.getTool("select");
8730
8838
  }
8839
+ pruneSelection() {
8840
+ const tool = this.getSelectTool();
8841
+ if (!tool) return;
8842
+ const ids = tool.selectedIds;
8843
+ const filtered = ids.filter((id) => this.store.getById(id) !== void 0);
8844
+ if (filtered.length !== ids.length) tool.setSelection(filtered);
8845
+ }
8846
+ handleRemovedElement(id) {
8847
+ if (!this.getSelectedIds().includes(id)) return;
8848
+ if (this.historyRecorder.currentTransactionId !== null) {
8849
+ this.pendingSelectionPrune = true;
8850
+ return;
8851
+ }
8852
+ this.pruneSelection();
8853
+ }
8854
+ static isSelectionSource(tool) {
8855
+ const candidate = tool;
8856
+ return tool.name === "select" && typeof candidate.onSelectionChange === "function" && typeof candidate.setSelection === "function";
8857
+ }
8858
+ emitSelectionChange() {
8859
+ for (const listener of this.selectionListeners) {
8860
+ try {
8861
+ listener();
8862
+ } catch {
8863
+ }
8864
+ }
8865
+ }
8866
+ attachSelectionSource(tool) {
8867
+ this.detachSelectionSource?.();
8868
+ this.detachSelectionSource = tool.onSelectionChange(() => this.emitSelectionChange());
8869
+ }
8870
+ /**
8871
+ * getSelectedIds() and the onSelectionChange emitter never surface stale ids:
8872
+ * once the enclosing history transaction completes, both reflect
8873
+ * the current selection.
8874
+ */
8731
8875
  getSelectedIds() {
8732
8876
  return this.getSelectTool()?.selectedIds ?? EMPTY_IDS;
8733
8877
  }
@@ -8761,13 +8905,29 @@ var Viewport = class {
8761
8905
  if (items.length === 0) return;
8762
8906
  this.contextMenu.open(items, screenPos);
8763
8907
  }
8908
+ /**
8909
+ * Persistent, viewport-owned selection-change emitter. Subscribing works
8910
+ * regardless of whether a select tool is registered yet; it forwards
8911
+ * events from whichever select tool is currently attached via
8912
+ * `toolManager.onRegister`. Never delivers stale ids once the enclosing
8913
+ * history transaction completes.
8914
+ */
8764
8915
  onSelectionChange(listener) {
8765
- const tool = this.getSelectTool();
8766
- return tool ? tool.onSelectionChange(listener) : noop;
8916
+ this.selectionListeners.add(listener);
8917
+ return () => {
8918
+ this.selectionListeners.delete(listener);
8919
+ };
8767
8920
  }
8768
8921
  getSelectionStyle() {
8769
8922
  return this.selectionOps.getStyle();
8770
8923
  }
8924
+ /**
8925
+ * Unlike `getSelectionStyle()` — which returns `{}` for a style-less
8926
+ * selection — this returns `null` when no style field is applicable.
8927
+ */
8928
+ getSelectionStyleDetails() {
8929
+ return this.selectionOps.getStyleDetails();
8930
+ }
8771
8931
  applyStyleToSelection(style) {
8772
8932
  this.selectionOps.applyStyle(style);
8773
8933
  }
@@ -8816,6 +8976,11 @@ var Viewport = class {
8816
8976
  this.inputHandler.destroy();
8817
8977
  this.unsubCamera();
8818
8978
  this.unsubToolChange();
8979
+ this.unsubToolRegister();
8980
+ this.unsubRecorderEnd();
8981
+ this.detachSelectionSource?.();
8982
+ this.detachSelectionSource = null;
8983
+ this.selectionListeners.clear();
8819
8984
  this.unsubStore.forEach((fn) => fn());
8820
8985
  this.resizeObserver?.disconnect();
8821
8986
  this.resizeObserver = null;
@@ -9589,6 +9754,361 @@ var PingInput = class {
9589
9754
  }
9590
9755
  };
9591
9756
 
9757
+ // src/canvas/camera-view.ts
9758
+ function assertValidView(view) {
9759
+ const { x, y, w, h } = view;
9760
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
9761
+ throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
9762
+ }
9763
+ if (w <= 0 || h <= 0) {
9764
+ throw new Error("[fieldnotes] CameraView requires positive w and h");
9765
+ }
9766
+ }
9767
+ function assertCanvasDims(canvasW, canvasH) {
9768
+ if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
9769
+ throw new Error("[fieldnotes] canvas dimensions must be finite");
9770
+ }
9771
+ if (canvasW < 0 || canvasH < 0) {
9772
+ throw new Error("[fieldnotes] canvas dimensions must not be negative");
9773
+ }
9774
+ }
9775
+ function canvasDimsUsable(canvasW, canvasH) {
9776
+ return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
9777
+ }
9778
+ function captureCameraView(viewport) {
9779
+ const rect = viewport.getVisibleRect();
9780
+ return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
9781
+ }
9782
+ function fitZoomForView(view, canvasW, canvasH) {
9783
+ assertValidView(view);
9784
+ assertCanvasDims(canvasW, canvasH);
9785
+ if (canvasW === 0 || canvasH === 0) {
9786
+ throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
9787
+ }
9788
+ return Math.min(canvasW / view.w, canvasH / view.h);
9789
+ }
9790
+ function cameraOriginForView(view, zoom, canvasW, canvasH) {
9791
+ return {
9792
+ x: canvasW / 2 - (view.x + view.w / 2) * zoom,
9793
+ y: canvasH / 2 - (view.y + view.h / 2) * zoom
9794
+ };
9795
+ }
9796
+ function applyCameraView(camera, view, canvasW, canvasH) {
9797
+ assertValidView(view);
9798
+ assertCanvasDims(canvasW, canvasH);
9799
+ if (canvasW === 0 || canvasH === 0) return;
9800
+ camera.setZoom(fitZoomForView(view, canvasW, canvasH));
9801
+ const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
9802
+ camera.moveTo(origin.x, origin.y);
9803
+ }
9804
+
9805
+ // src/canvas/camera-animator.ts
9806
+ var DEFAULT_DURATION_MS3 = 400;
9807
+ var FRAMED_EPSILON = 1e-6;
9808
+ var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
9809
+ function lerp(a, b, k) {
9810
+ return a + (b - a) * k;
9811
+ }
9812
+ function lerpView(from, to, k) {
9813
+ return {
9814
+ x: lerp(from.x, to.x, k),
9815
+ y: lerp(from.y, to.y, k),
9816
+ w: lerp(from.w, to.w, k),
9817
+ h: lerp(from.h, to.h, k)
9818
+ };
9819
+ }
9820
+ function viewsClose(a, b) {
9821
+ return Math.abs(a.x - b.x) < FRAMED_EPSILON && Math.abs(a.y - b.y) < FRAMED_EPSILON && Math.abs(a.w - b.w) < FRAMED_EPSILON && Math.abs(a.h - b.h) < FRAMED_EPSILON;
9822
+ }
9823
+ var CameraAnimator = class {
9824
+ camera;
9825
+ getCanvasSize;
9826
+ frames;
9827
+ now;
9828
+ durationMs;
9829
+ easing;
9830
+ rafId = null;
9831
+ from = null;
9832
+ to = null;
9833
+ startedAt = 0;
9834
+ endListeners = /* @__PURE__ */ new Set();
9835
+ /**
9836
+ * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
9837
+ * before emitting 'superseded'; if an onEnd listener starts a newer
9838
+ * operation during that emit, the outer call sees a bumped counter and
9839
+ * bails instead of overwriting the nested animation's state. Without this,
9840
+ * the nested animation would run to completion having never reported an end
9841
+ * reason, breaking the exactly-one guarantee the spec makes.
9842
+ */
9843
+ generation = 0;
9844
+ lastWrite = null;
9845
+ disposed = false;
9846
+ detachListeners = null;
9847
+ constructor(element, camera, options) {
9848
+ if (options.frames !== void 0) {
9849
+ const { requestFrame, cancelFrame } = options.frames;
9850
+ if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
9851
+ throw new Error(
9852
+ "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
9853
+ );
9854
+ }
9855
+ }
9856
+ this.camera = camera;
9857
+ this.getCanvasSize = options.getCanvasSize;
9858
+ this.frames = options.frames ?? {
9859
+ requestFrame: (cb) => requestAnimationFrame(cb),
9860
+ cancelFrame: (id) => cancelAnimationFrame(id)
9861
+ };
9862
+ this.now = options.now ?? (() => performance.now());
9863
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
9864
+ this.easing = options.easing ?? easeOutCubic2;
9865
+ if (options.interactive ?? true) {
9866
+ const onUserInput = () => {
9867
+ this.end("cancelled");
9868
+ };
9869
+ const types = ["pointerdown", "wheel", "keydown"];
9870
+ for (const type of types) {
9871
+ element.addEventListener(type, onUserInput, { passive: true });
9872
+ }
9873
+ this.detachListeners = () => {
9874
+ for (const type of types) {
9875
+ element.removeEventListener(type, onUserInput);
9876
+ }
9877
+ };
9878
+ }
9879
+ }
9880
+ get animating() {
9881
+ return this.to !== null;
9882
+ }
9883
+ onEnd(listener) {
9884
+ this.endListeners.add(listener);
9885
+ return () => this.endListeners.delete(listener);
9886
+ }
9887
+ animateTo(view) {
9888
+ const size = this.validateAndMeasure(view);
9889
+ if (size === null) return;
9890
+ const current = this.camera.getVisibleRect(size.w, size.h);
9891
+ const generation = ++this.generation;
9892
+ this.end("superseded");
9893
+ if (this.generation !== generation) return;
9894
+ if (viewsClose(current, view)) {
9895
+ applyCameraView(this.camera, view, size.w, size.h);
9896
+ this.emit("complete");
9897
+ return;
9898
+ }
9899
+ this.from = current;
9900
+ this.to = view;
9901
+ this.startedAt = this.now();
9902
+ this.lastWrite = null;
9903
+ this.rafId = this.frames.requestFrame(this.step);
9904
+ }
9905
+ jumpTo(view) {
9906
+ const size = this.validateAndMeasure(view);
9907
+ if (size === null) return;
9908
+ const generation = ++this.generation;
9909
+ this.end("superseded");
9910
+ if (this.generation !== generation) return;
9911
+ applyCameraView(this.camera, view, size.w, size.h);
9912
+ this.lastWrite = null;
9913
+ }
9914
+ cancel() {
9915
+ if (this.disposed) return;
9916
+ this.end("cancelled");
9917
+ }
9918
+ /**
9919
+ * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
9920
+ * because an onEnd listener can call animateTo during the disposal callback.
9921
+ * With the flag set last, that call would start a real animation which the
9922
+ * listener clear then silently discards — a second animation with no end
9923
+ * reason, breaking the exactly-one guarantee.
9924
+ */
9925
+ dispose() {
9926
+ if (this.disposed) return;
9927
+ this.disposed = true;
9928
+ this.generation++;
9929
+ const wasAnimating = this.to !== null;
9930
+ this.clearFrame();
9931
+ this.from = null;
9932
+ this.to = null;
9933
+ this.lastWrite = null;
9934
+ if (wasAnimating) this.emit("cancelled");
9935
+ this.endListeners.clear();
9936
+ this.detachListeners?.();
9937
+ this.detachListeners = null;
9938
+ }
9939
+ /**
9940
+ * Steps 1-3 of the public-call contract. Returns null when the caller must
9941
+ * stop, having already handled termination.
9942
+ *
9943
+ * The disposed check precedes validation deliberately: ordering it after
9944
+ * would make `disposed.animateTo(invalidView)` both required to throw and
9945
+ * required to stay silent. Disposal wins — a terminal animator is inert for
9946
+ * every input, and post-disposal calls are exactly the racy teardown paths
9947
+ * where a throw is least useful.
9948
+ */
9949
+ validateAndMeasure(view) {
9950
+ if (this.disposed) return null;
9951
+ assertValidView(view);
9952
+ const size = this.getCanvasSize();
9953
+ assertCanvasDims(size.w, size.h);
9954
+ if (size.w === 0 || size.h === 0) {
9955
+ this.end("cancelled");
9956
+ return null;
9957
+ }
9958
+ return size;
9959
+ }
9960
+ step = () => {
9961
+ if (this.disposed || this.to === null || this.from === null) return;
9962
+ if (this.foreignWrite()) {
9963
+ this.end("cancelled");
9964
+ return;
9965
+ }
9966
+ const size = this.getCanvasSize();
9967
+ if (!canvasDimsUsable(size.w, size.h)) {
9968
+ this.end("cancelled");
9969
+ return;
9970
+ }
9971
+ if (size.w === 0 || size.h === 0) {
9972
+ this.end("cancelled");
9973
+ return;
9974
+ }
9975
+ const elapsed = this.now() - this.startedAt;
9976
+ const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
9977
+ const view = lerpView(this.from, this.to, this.easing(t));
9978
+ applyCameraView(this.camera, view, size.w, size.h);
9979
+ this.recordWrite();
9980
+ if (t >= 1) {
9981
+ this.end("complete");
9982
+ return;
9983
+ }
9984
+ this.rafId = this.frames.requestFrame(this.step);
9985
+ };
9986
+ recordWrite() {
9987
+ this.lastWrite = {
9988
+ x: this.camera.position.x,
9989
+ y: this.camera.position.y,
9990
+ zoom: this.camera.zoom
9991
+ };
9992
+ }
9993
+ foreignWrite() {
9994
+ if (this.lastWrite === null) return false;
9995
+ const eps = 1e-6;
9996
+ return Math.abs(this.camera.position.x - this.lastWrite.x) > eps || Math.abs(this.camera.position.y - this.lastWrite.y) > eps || Math.abs(this.camera.zoom - this.lastWrite.zoom) > eps;
9997
+ }
9998
+ /** Terminates an in-flight animation with `reason`. No-op when idle. */
9999
+ end(reason) {
10000
+ if (this.disposed || this.to === null) return;
10001
+ this.clearFrame();
10002
+ this.from = null;
10003
+ this.to = null;
10004
+ this.lastWrite = null;
10005
+ this.emit(reason);
10006
+ }
10007
+ clearFrame() {
10008
+ if (this.rafId !== null) {
10009
+ this.frames.cancelFrame(this.rafId);
10010
+ this.rafId = null;
10011
+ }
10012
+ }
10013
+ emit(reason) {
10014
+ for (const listener of [...this.endListeners]) {
10015
+ try {
10016
+ listener(reason);
10017
+ } catch {
10018
+ }
10019
+ }
10020
+ }
10021
+ };
10022
+
10023
+ // src/canvas/focus-presence.ts
10024
+ var FOCUS_PRESENCE_KIND = "focus";
10025
+ var AUDIENCES = ["all", "players", "display"];
10026
+ function isPositiveFinite(value) {
10027
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
10028
+ }
10029
+ function isFiniteNumber2(value) {
10030
+ return typeof value === "number" && Number.isFinite(value);
10031
+ }
10032
+ function isFocusPresence(data) {
10033
+ if (typeof data !== "object" || data === null) return false;
10034
+ const payload = data;
10035
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
10036
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
10037
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
10038
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
10039
+ return false;
10040
+ }
10041
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
10042
+ return true;
10043
+ }
10044
+ function toFocusPresence(view, audience, color) {
10045
+ return {
10046
+ kind: FOCUS_PRESENCE_KIND,
10047
+ x: view.x,
10048
+ y: view.y,
10049
+ w: view.w,
10050
+ h: view.h,
10051
+ audience,
10052
+ ...color === void 0 ? {} : { color }
10053
+ };
10054
+ }
10055
+
10056
+ // src/canvas/remote-focus-receiver.ts
10057
+ function audienceIncludes(audience, role) {
10058
+ if (role === "dm") return false;
10059
+ if (audience === "all") return true;
10060
+ if (audience === "players") return role === "player";
10061
+ return role === "display";
10062
+ }
10063
+ var RemoteFocusReceiver = class {
10064
+ role;
10065
+ animator;
10066
+ animate;
10067
+ pulseColor;
10068
+ overlay;
10069
+ disposed = false;
10070
+ constructor(host, options) {
10071
+ this.role = options.role;
10072
+ this.animator = options.animator;
10073
+ this.animate = options.animate ?? true;
10074
+ this.pulseColor = options.pulseColor;
10075
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
10076
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
10077
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
10078
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
10079
+ maxPingsPerSender: 1
10080
+ }) : null;
10081
+ }
10082
+ /**
10083
+ * Applies a presence payload from `sender`. Returns `false` for payloads
10084
+ * that are not focus frames, or are addressed to a different role, so hosts
10085
+ * can feed every presence frame through without disturbing other handlers.
10086
+ */
10087
+ apply(from, data) {
10088
+ if (this.disposed || !isFocusPresence(data)) return false;
10089
+ if (!audienceIncludes(data.audience, this.role)) return false;
10090
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
10091
+ if (this.animate) {
10092
+ this.animator.animateTo(view);
10093
+ } else {
10094
+ this.animator.jumpTo(view);
10095
+ }
10096
+ this.overlay?.apply(from, {
10097
+ kind: "ping",
10098
+ x: view.x + view.w / 2,
10099
+ y: view.y + view.h / 2,
10100
+ color: data.color ?? this.pulseColor
10101
+ });
10102
+ return true;
10103
+ }
10104
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
10105
+ dispose() {
10106
+ if (this.disposed) return;
10107
+ this.disposed = true;
10108
+ this.overlay?.dispose();
10109
+ }
10110
+ };
10111
+
9592
10112
  // src/tools/hand-tool.ts
9593
10113
  var HandTool = class {
9594
10114
  name = "hand";
@@ -9777,7 +10297,7 @@ function hitTestStroke(stroke, point, radius) {
9777
10297
  }
9778
10298
 
9779
10299
  // src/elements/stroke-erase.ts
9780
- function lerp(a, b, t) {
10300
+ function lerp2(a, b, t) {
9781
10301
  return {
9782
10302
  x: a.x + (b.x - a.x) * t,
9783
10303
  y: a.y + (b.y - a.y) * t,
@@ -9836,13 +10356,13 @@ function erasePoints(points, eraser, radius) {
9836
10356
  erased = true;
9837
10357
  if (tLo > 0) {
9838
10358
  if (current.length === 0) current.push(a);
9839
- current.push(lerp(a, b, tLo));
10359
+ current.push(lerp2(a, b, tLo));
9840
10360
  flush();
9841
10361
  } else {
9842
10362
  flush();
9843
10363
  }
9844
10364
  if (tHi < 1) {
9845
- current = [lerp(a, b, tHi), b];
10365
+ current = [lerp2(a, b, tHi), b];
9846
10366
  }
9847
10367
  }
9848
10368
  flush();
@@ -12298,7 +12818,7 @@ var LaserTool = class {
12298
12818
 
12299
12819
  // src/tools/ping-tool.ts
12300
12820
  var DEFAULT_COLOR6 = "#ff3b30";
12301
- var DEFAULT_DURATION_MS3 = 1800;
12821
+ var DEFAULT_DURATION_MS4 = 1800;
12302
12822
  var DEFAULT_RADIUS4 = 48;
12303
12823
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
12304
12824
  var PingTool = class {
@@ -12315,7 +12835,7 @@ var PingTool = class {
12315
12835
  constructor(options = {}) {
12316
12836
  this.name = options.name ?? "ping";
12317
12837
  this.color = options.color ?? DEFAULT_COLOR6;
12318
- this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
12838
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
12319
12839
  this.radius = options.radius ?? DEFAULT_RADIUS4;
12320
12840
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
12321
12841
  }
@@ -12417,15 +12937,17 @@ var PingTool = class {
12417
12937
  };
12418
12938
 
12419
12939
  // src/index.ts
12420
- var VERSION = "0.59.0";
12940
+ var VERSION = "0.61.0";
12421
12941
  // Annotate the CommonJS export names for ESM import in node:
12422
12942
  0 && (module.exports = {
12423
12943
  ArrowTool,
12424
12944
  AutoSave,
12425
12945
  Camera,
12946
+ CameraAnimator,
12426
12947
  DEFAULT_NOTE_FONT_SIZE,
12427
12948
  ElementStore,
12428
12949
  EraserTool,
12950
+ FOCUS_PRESENCE_KIND,
12429
12951
  HandTool,
12430
12952
  HistoryStack,
12431
12953
  ImageTool,
@@ -12443,6 +12965,7 @@ var VERSION = "0.59.0";
12443
12965
  PencilTool,
12444
12966
  PingInput,
12445
12967
  PingTool,
12968
+ RemoteFocusReceiver,
12446
12969
  RemoteLaserOverlay,
12447
12970
  RemoteMeasureOverlay,
12448
12971
  RemotePingOverlay,
@@ -12453,7 +12976,10 @@ var VERSION = "0.59.0";
12453
12976
  ToolManager,
12454
12977
  VERSION,
12455
12978
  Viewport,
12979
+ applyCameraView,
12456
12980
  boundsIntersect,
12981
+ cameraOriginForView,
12982
+ captureCameraView,
12457
12983
  createArrow,
12458
12984
  createGrid,
12459
12985
  createHtmlElement,
@@ -12466,6 +12992,7 @@ var VERSION = "0.59.0";
12466
12992
  drawHexPath,
12467
12993
  exportImage,
12468
12994
  exportSvg,
12995
+ fitZoomForView,
12469
12996
  getActiveFormats,
12470
12997
  getArrowBounds,
12471
12998
  getArrowControlPoint,
@@ -12481,6 +13008,7 @@ var VERSION = "0.59.0";
12481
13008
  getHexCellsInRectangle,
12482
13009
  getHexCellsInSquare,
12483
13010
  getHexDistance,
13011
+ isFocusPresence,
12484
13012
  isLaserTrailPresence,
12485
13013
  isMeasurePresence,
12486
13014
  isNearBezier,
@@ -12490,6 +13018,7 @@ var VERSION = "0.59.0";
12490
13018
  snapPoint,
12491
13019
  snapToHexCenter,
12492
13020
  styleToPatch,
13021
+ toFocusPresence,
12493
13022
  toLaserTrailPresence,
12494
13023
  toMeasurePresence,
12495
13024
  toPingPresence,