@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.js CHANGED
@@ -5170,6 +5170,7 @@ var ToolManager = class {
5170
5170
  tools = /* @__PURE__ */ new Map();
5171
5171
  current = null;
5172
5172
  changeListeners = /* @__PURE__ */ new Set();
5173
+ registerListeners = /* @__PURE__ */ new Set();
5173
5174
  get activeTool() {
5174
5175
  return this.current;
5175
5176
  }
@@ -5178,6 +5179,7 @@ var ToolManager = class {
5178
5179
  }
5179
5180
  register(tool) {
5180
5181
  this.tools.set(tool.name, tool);
5182
+ this.registerListeners.forEach((fn) => fn(tool));
5181
5183
  }
5182
5184
  getTool(name) {
5183
5185
  return this.tools.get(name);
@@ -5203,6 +5205,10 @@ var ToolManager = class {
5203
5205
  this.changeListeners.add(listener);
5204
5206
  return () => this.changeListeners.delete(listener);
5205
5207
  }
5208
+ onRegister(listener) {
5209
+ this.registerListeners.add(listener);
5210
+ return () => this.registerListeners.delete(listener);
5211
+ }
5206
5212
  };
5207
5213
 
5208
5214
  // src/history/history-stack.ts
@@ -5394,6 +5400,7 @@ var HistoryRecorder = class {
5394
5400
  generation = 0;
5395
5401
  updateSnapshots = /* @__PURE__ */ new Map();
5396
5402
  unsubscribers;
5403
+ transactionEndListeners = /* @__PURE__ */ new Set();
5397
5404
  pause() {
5398
5405
  this.recording = false;
5399
5406
  }
@@ -5411,19 +5418,35 @@ var HistoryRecorder = class {
5411
5418
  get currentTransactionId() {
5412
5419
  return this.transaction !== null ? this.generation : null;
5413
5420
  }
5421
+ onTransactionEnd(listener) {
5422
+ this.transactionEndListeners.add(listener);
5423
+ return () => this.transactionEndListeners.delete(listener);
5424
+ }
5425
+ notifyTransactionEnd() {
5426
+ for (const listener of this.transactionEndListeners) {
5427
+ try {
5428
+ listener();
5429
+ } catch {
5430
+ }
5431
+ }
5432
+ }
5414
5433
  commit() {
5415
5434
  if (!this.transaction) return;
5416
5435
  const finalCommands = this.flushUpdateSnapshots();
5417
5436
  const all = [...this.transaction, ...finalCommands];
5418
5437
  this.transaction = null;
5419
5438
  this.updateSnapshots.clear();
5420
- if (all.length === 0) return;
5421
- const first = all[0];
5422
- this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5439
+ if (all.length > 0) {
5440
+ const first = all[0];
5441
+ this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
5442
+ }
5443
+ this.notifyTransactionEnd();
5423
5444
  }
5424
5445
  rollback() {
5446
+ const wasOpen = this.transaction !== null;
5425
5447
  this.transaction = null;
5426
5448
  this.updateSnapshots.clear();
5449
+ if (wasOpen) this.notifyTransactionEnd();
5427
5450
  }
5428
5451
  destroy() {
5429
5452
  this.unsubscribers.forEach((fn) => fn());
@@ -7715,6 +7738,14 @@ function rotateElementPatch(el, bounds, pivot, delta) {
7715
7738
  }
7716
7739
 
7717
7740
  // src/canvas/selection-ops.ts
7741
+ var STYLE_FIELDS = [
7742
+ "color",
7743
+ "fillColor",
7744
+ "strokeWidth",
7745
+ "opacity",
7746
+ "fontSize",
7747
+ "strokeStyle"
7748
+ ];
7718
7749
  function sharedValue(values) {
7719
7750
  const present = values.filter((v) => v !== void 0);
7720
7751
  if (present.length === 0) return void 0;
@@ -7749,6 +7780,42 @@ var SelectionOps = class {
7749
7780
  if (strokeStyle !== void 0) result.strokeStyle = strokeStyle;
7750
7781
  return result;
7751
7782
  }
7783
+ /**
7784
+ * Unlike `getStyle()` — which returns `{}` for a selection whose elements
7785
+ * have no applicable style fields (e.g. images) — this returns `null` when
7786
+ * no field is applicable, so callers can distinguish "nothing to show" from
7787
+ * "everything shared but empty".
7788
+ */
7789
+ getStyleDetails() {
7790
+ const ids = this.deps.getSelectedIds();
7791
+ if (ids.length === 0) return null;
7792
+ const styles = [];
7793
+ for (const id of ids) {
7794
+ const el = this.deps.store.getById(id);
7795
+ if (el) styles.push(getElementStyle(el));
7796
+ }
7797
+ if (styles.length === 0) return null;
7798
+ const common = {};
7799
+ const applicable = [];
7800
+ const mixed = [];
7801
+ for (const field of STYLE_FIELDS) {
7802
+ const allValues = styles.map((s) => s[field]);
7803
+ const values = [];
7804
+ for (const v of allValues) {
7805
+ if (v !== void 0) values.push(v);
7806
+ }
7807
+ if (values.length === 0) continue;
7808
+ applicable.push(field);
7809
+ const distinct = new Set(values);
7810
+ if (distinct.size > 1) {
7811
+ mixed.push(field);
7812
+ } else {
7813
+ common[field] = values[0];
7814
+ }
7815
+ }
7816
+ if (applicable.length === 0) return null;
7817
+ return { common, applicable, mixed };
7818
+ }
7752
7819
  applyStyle(style) {
7753
7820
  const ids = this.deps.getSelectedIds();
7754
7821
  if (ids.length === 0) return;
@@ -8123,7 +8190,7 @@ var ViewportInteractions = class {
8123
8190
  var EMPTY_IDS = [];
8124
8191
  function noop() {
8125
8192
  }
8126
- var Viewport = class {
8193
+ var Viewport = class _Viewport {
8127
8194
  constructor(container, options = {}) {
8128
8195
  this.container = container;
8129
8196
  this.camera = new Camera(options.camera);
@@ -8132,6 +8199,13 @@ var Viewport = class {
8132
8199
  this.store = new ElementStore();
8133
8200
  this.layerManager = new LayerManager(this.store);
8134
8201
  this.toolManager = new ToolManager();
8202
+ this.unsubToolRegister = this.toolManager.onRegister((tool) => {
8203
+ if (_Viewport.isSelectionSource(tool)) this.attachSelectionSource(tool);
8204
+ });
8205
+ const existingSelect = this.getSelectTool();
8206
+ if (existingSelect && _Viewport.isSelectionSource(existingSelect)) {
8207
+ this.attachSelectionSource(existingSelect);
8208
+ }
8135
8209
  this.renderer = new ElementRenderer();
8136
8210
  this.renderer.setStore(this.store);
8137
8211
  this.renderer.setCamera(this.camera);
@@ -8166,6 +8240,11 @@ var Viewport = class {
8166
8240
  this.dropHandler = options.onDrop;
8167
8241
  this.history = new HistoryStack();
8168
8242
  this.historyRecorder = new HistoryRecorder(this.store, this.history, this.layerManager);
8243
+ this.unsubRecorderEnd = this.historyRecorder.onTransactionEnd(() => {
8244
+ if (!this.pendingSelectionPrune) return;
8245
+ this.pendingSelectionPrune = false;
8246
+ this.pruneSelection();
8247
+ });
8169
8248
  this.selectionOps = new SelectionOps({
8170
8249
  store: this.store,
8171
8250
  recorder: this.historyRecorder,
@@ -8285,6 +8364,7 @@ var Viewport = class {
8285
8364
  this.domNodeManager.removeDomNode(el.id);
8286
8365
  this.renderLoop.markLayerDirty(el.layerId);
8287
8366
  this.requestRender();
8367
+ this.handleRemovedElement(el.id);
8288
8368
  }),
8289
8369
  this.store.on("update", ({ previous, current }) => {
8290
8370
  if (current.type === "grid") this.gridController.syncContext();
@@ -8299,6 +8379,7 @@ var Viewport = class {
8299
8379
  this.renderLoop.markAllLayersDirty();
8300
8380
  this.gridController.syncContext();
8301
8381
  this.requestRender();
8382
+ this.pruneSelection();
8302
8383
  })
8303
8384
  ];
8304
8385
  this.layerManager.on("change", () => {
@@ -8368,6 +8449,13 @@ var Viewport = class {
8368
8449
  minimap = null;
8369
8450
  htmlRenderers = /* @__PURE__ */ new Map();
8370
8451
  resizeListeners = /* @__PURE__ */ new Set();
8452
+ selectionListeners = /* @__PURE__ */ new Set();
8453
+ detachSelectionSource = null;
8454
+ unsubToolRegister = () => {
8455
+ };
8456
+ pendingSelectionPrune = false;
8457
+ unsubRecorderEnd = () => {
8458
+ };
8371
8459
  get ctx() {
8372
8460
  return this.canvasEl.getContext("2d");
8373
8461
  }
@@ -8396,6 +8484,17 @@ var Viewport = class {
8396
8484
  getVisibleRect() {
8397
8485
  return this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
8398
8486
  }
8487
+ /**
8488
+ * Size in CSS pixels of the canvas that `getVisibleRect()` measures.
8489
+ * Exposed because `canvasEl` is private: consumers can only reach the
8490
+ * wrapper (via `domLayer.parentElement`), so without this accessor the
8491
+ * canonical size behind `getVisibleRect()` is unreachable and callers
8492
+ * resort to `getVisibleRect().w * camera.zoom`. Capture and restore must
8493
+ * measure the same element or saved views do not round-trip.
8494
+ */
8495
+ getCanvasSize() {
8496
+ return { w: this.canvasEl.clientWidth, h: this.canvasEl.clientHeight };
8497
+ }
8399
8498
  /** Centers the camera on a world point without changing zoom. */
8400
8499
  centerCameraAt(world) {
8401
8500
  const z = this.camera.zoom;
@@ -8626,6 +8725,42 @@ var Viewport = class {
8626
8725
  getSelectTool() {
8627
8726
  return this.toolManager.getTool("select");
8628
8727
  }
8728
+ pruneSelection() {
8729
+ const tool = this.getSelectTool();
8730
+ if (!tool) return;
8731
+ const ids = tool.selectedIds;
8732
+ const filtered = ids.filter((id) => this.store.getById(id) !== void 0);
8733
+ if (filtered.length !== ids.length) tool.setSelection(filtered);
8734
+ }
8735
+ handleRemovedElement(id) {
8736
+ if (!this.getSelectedIds().includes(id)) return;
8737
+ if (this.historyRecorder.currentTransactionId !== null) {
8738
+ this.pendingSelectionPrune = true;
8739
+ return;
8740
+ }
8741
+ this.pruneSelection();
8742
+ }
8743
+ static isSelectionSource(tool) {
8744
+ const candidate = tool;
8745
+ return tool.name === "select" && typeof candidate.onSelectionChange === "function" && typeof candidate.setSelection === "function";
8746
+ }
8747
+ emitSelectionChange() {
8748
+ for (const listener of this.selectionListeners) {
8749
+ try {
8750
+ listener();
8751
+ } catch {
8752
+ }
8753
+ }
8754
+ }
8755
+ attachSelectionSource(tool) {
8756
+ this.detachSelectionSource?.();
8757
+ this.detachSelectionSource = tool.onSelectionChange(() => this.emitSelectionChange());
8758
+ }
8759
+ /**
8760
+ * getSelectedIds() and the onSelectionChange emitter never surface stale ids:
8761
+ * once the enclosing history transaction completes, both reflect
8762
+ * the current selection.
8763
+ */
8629
8764
  getSelectedIds() {
8630
8765
  return this.getSelectTool()?.selectedIds ?? EMPTY_IDS;
8631
8766
  }
@@ -8659,13 +8794,29 @@ var Viewport = class {
8659
8794
  if (items.length === 0) return;
8660
8795
  this.contextMenu.open(items, screenPos);
8661
8796
  }
8797
+ /**
8798
+ * Persistent, viewport-owned selection-change emitter. Subscribing works
8799
+ * regardless of whether a select tool is registered yet; it forwards
8800
+ * events from whichever select tool is currently attached via
8801
+ * `toolManager.onRegister`. Never delivers stale ids once the enclosing
8802
+ * history transaction completes.
8803
+ */
8662
8804
  onSelectionChange(listener) {
8663
- const tool = this.getSelectTool();
8664
- return tool ? tool.onSelectionChange(listener) : noop;
8805
+ this.selectionListeners.add(listener);
8806
+ return () => {
8807
+ this.selectionListeners.delete(listener);
8808
+ };
8665
8809
  }
8666
8810
  getSelectionStyle() {
8667
8811
  return this.selectionOps.getStyle();
8668
8812
  }
8813
+ /**
8814
+ * Unlike `getSelectionStyle()` — which returns `{}` for a style-less
8815
+ * selection — this returns `null` when no style field is applicable.
8816
+ */
8817
+ getSelectionStyleDetails() {
8818
+ return this.selectionOps.getStyleDetails();
8819
+ }
8669
8820
  applyStyleToSelection(style) {
8670
8821
  this.selectionOps.applyStyle(style);
8671
8822
  }
@@ -8714,6 +8865,11 @@ var Viewport = class {
8714
8865
  this.inputHandler.destroy();
8715
8866
  this.unsubCamera();
8716
8867
  this.unsubToolChange();
8868
+ this.unsubToolRegister();
8869
+ this.unsubRecorderEnd();
8870
+ this.detachSelectionSource?.();
8871
+ this.detachSelectionSource = null;
8872
+ this.selectionListeners.clear();
8717
8873
  this.unsubStore.forEach((fn) => fn());
8718
8874
  this.resizeObserver?.disconnect();
8719
8875
  this.resizeObserver = null;
@@ -9487,6 +9643,361 @@ var PingInput = class {
9487
9643
  }
9488
9644
  };
9489
9645
 
9646
+ // src/canvas/camera-view.ts
9647
+ function assertValidView(view) {
9648
+ const { x, y, w, h } = view;
9649
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
9650
+ throw new Error("[fieldnotes] CameraView requires finite x, y, w, and h");
9651
+ }
9652
+ if (w <= 0 || h <= 0) {
9653
+ throw new Error("[fieldnotes] CameraView requires positive w and h");
9654
+ }
9655
+ }
9656
+ function assertCanvasDims(canvasW, canvasH) {
9657
+ if (!Number.isFinite(canvasW) || !Number.isFinite(canvasH)) {
9658
+ throw new Error("[fieldnotes] canvas dimensions must be finite");
9659
+ }
9660
+ if (canvasW < 0 || canvasH < 0) {
9661
+ throw new Error("[fieldnotes] canvas dimensions must not be negative");
9662
+ }
9663
+ }
9664
+ function canvasDimsUsable(canvasW, canvasH) {
9665
+ return Number.isFinite(canvasW) && Number.isFinite(canvasH) && canvasW >= 0 && canvasH >= 0;
9666
+ }
9667
+ function captureCameraView(viewport) {
9668
+ const rect = viewport.getVisibleRect();
9669
+ return { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
9670
+ }
9671
+ function fitZoomForView(view, canvasW, canvasH) {
9672
+ assertValidView(view);
9673
+ assertCanvasDims(canvasW, canvasH);
9674
+ if (canvasW === 0 || canvasH === 0) {
9675
+ throw new Error("[fieldnotes] fitZoomForView requires a non-zero canvas size");
9676
+ }
9677
+ return Math.min(canvasW / view.w, canvasH / view.h);
9678
+ }
9679
+ function cameraOriginForView(view, zoom, canvasW, canvasH) {
9680
+ return {
9681
+ x: canvasW / 2 - (view.x + view.w / 2) * zoom,
9682
+ y: canvasH / 2 - (view.y + view.h / 2) * zoom
9683
+ };
9684
+ }
9685
+ function applyCameraView(camera, view, canvasW, canvasH) {
9686
+ assertValidView(view);
9687
+ assertCanvasDims(canvasW, canvasH);
9688
+ if (canvasW === 0 || canvasH === 0) return;
9689
+ camera.setZoom(fitZoomForView(view, canvasW, canvasH));
9690
+ const origin = cameraOriginForView(view, camera.zoom, canvasW, canvasH);
9691
+ camera.moveTo(origin.x, origin.y);
9692
+ }
9693
+
9694
+ // src/canvas/camera-animator.ts
9695
+ var DEFAULT_DURATION_MS3 = 400;
9696
+ var FRAMED_EPSILON = 1e-6;
9697
+ var easeOutCubic2 = (t) => 1 - Math.pow(1 - t, 3);
9698
+ function lerp(a, b, k) {
9699
+ return a + (b - a) * k;
9700
+ }
9701
+ function lerpView(from, to, k) {
9702
+ return {
9703
+ x: lerp(from.x, to.x, k),
9704
+ y: lerp(from.y, to.y, k),
9705
+ w: lerp(from.w, to.w, k),
9706
+ h: lerp(from.h, to.h, k)
9707
+ };
9708
+ }
9709
+ function viewsClose(a, b) {
9710
+ 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;
9711
+ }
9712
+ var CameraAnimator = class {
9713
+ camera;
9714
+ getCanvasSize;
9715
+ frames;
9716
+ now;
9717
+ durationMs;
9718
+ easing;
9719
+ rafId = null;
9720
+ from = null;
9721
+ to = null;
9722
+ startedAt = 0;
9723
+ endListeners = /* @__PURE__ */ new Set();
9724
+ /**
9725
+ * Monotonic operation counter. `animateTo`/`jumpTo` claim a generation
9726
+ * before emitting 'superseded'; if an onEnd listener starts a newer
9727
+ * operation during that emit, the outer call sees a bumped counter and
9728
+ * bails instead of overwriting the nested animation's state. Without this,
9729
+ * the nested animation would run to completion having never reported an end
9730
+ * reason, breaking the exactly-one guarantee the spec makes.
9731
+ */
9732
+ generation = 0;
9733
+ lastWrite = null;
9734
+ disposed = false;
9735
+ detachListeners = null;
9736
+ constructor(element, camera, options) {
9737
+ if (options.frames !== void 0) {
9738
+ const { requestFrame, cancelFrame } = options.frames;
9739
+ if (typeof requestFrame !== "function" || typeof cancelFrame !== "function") {
9740
+ throw new Error(
9741
+ "[fieldnotes] CameraAnimator: `frames` must supply both requestFrame and cancelFrame"
9742
+ );
9743
+ }
9744
+ }
9745
+ this.camera = camera;
9746
+ this.getCanvasSize = options.getCanvasSize;
9747
+ this.frames = options.frames ?? {
9748
+ requestFrame: (cb) => requestAnimationFrame(cb),
9749
+ cancelFrame: (id) => cancelAnimationFrame(id)
9750
+ };
9751
+ this.now = options.now ?? (() => performance.now());
9752
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
9753
+ this.easing = options.easing ?? easeOutCubic2;
9754
+ if (options.interactive ?? true) {
9755
+ const onUserInput = () => {
9756
+ this.end("cancelled");
9757
+ };
9758
+ const types = ["pointerdown", "wheel", "keydown"];
9759
+ for (const type of types) {
9760
+ element.addEventListener(type, onUserInput, { passive: true });
9761
+ }
9762
+ this.detachListeners = () => {
9763
+ for (const type of types) {
9764
+ element.removeEventListener(type, onUserInput);
9765
+ }
9766
+ };
9767
+ }
9768
+ }
9769
+ get animating() {
9770
+ return this.to !== null;
9771
+ }
9772
+ onEnd(listener) {
9773
+ this.endListeners.add(listener);
9774
+ return () => this.endListeners.delete(listener);
9775
+ }
9776
+ animateTo(view) {
9777
+ const size = this.validateAndMeasure(view);
9778
+ if (size === null) return;
9779
+ const current = this.camera.getVisibleRect(size.w, size.h);
9780
+ const generation = ++this.generation;
9781
+ this.end("superseded");
9782
+ if (this.generation !== generation) return;
9783
+ if (viewsClose(current, view)) {
9784
+ applyCameraView(this.camera, view, size.w, size.h);
9785
+ this.emit("complete");
9786
+ return;
9787
+ }
9788
+ this.from = current;
9789
+ this.to = view;
9790
+ this.startedAt = this.now();
9791
+ this.lastWrite = null;
9792
+ this.rafId = this.frames.requestFrame(this.step);
9793
+ }
9794
+ jumpTo(view) {
9795
+ const size = this.validateAndMeasure(view);
9796
+ if (size === null) return;
9797
+ const generation = ++this.generation;
9798
+ this.end("superseded");
9799
+ if (this.generation !== generation) return;
9800
+ applyCameraView(this.camera, view, size.w, size.h);
9801
+ this.lastWrite = null;
9802
+ }
9803
+ cancel() {
9804
+ if (this.disposed) return;
9805
+ this.end("cancelled");
9806
+ }
9807
+ /**
9808
+ * Terminal. Order is load-bearing: the flag is set BEFORE any listener runs,
9809
+ * because an onEnd listener can call animateTo during the disposal callback.
9810
+ * With the flag set last, that call would start a real animation which the
9811
+ * listener clear then silently discards — a second animation with no end
9812
+ * reason, breaking the exactly-one guarantee.
9813
+ */
9814
+ dispose() {
9815
+ if (this.disposed) return;
9816
+ this.disposed = true;
9817
+ this.generation++;
9818
+ const wasAnimating = this.to !== null;
9819
+ this.clearFrame();
9820
+ this.from = null;
9821
+ this.to = null;
9822
+ this.lastWrite = null;
9823
+ if (wasAnimating) this.emit("cancelled");
9824
+ this.endListeners.clear();
9825
+ this.detachListeners?.();
9826
+ this.detachListeners = null;
9827
+ }
9828
+ /**
9829
+ * Steps 1-3 of the public-call contract. Returns null when the caller must
9830
+ * stop, having already handled termination.
9831
+ *
9832
+ * The disposed check precedes validation deliberately: ordering it after
9833
+ * would make `disposed.animateTo(invalidView)` both required to throw and
9834
+ * required to stay silent. Disposal wins — a terminal animator is inert for
9835
+ * every input, and post-disposal calls are exactly the racy teardown paths
9836
+ * where a throw is least useful.
9837
+ */
9838
+ validateAndMeasure(view) {
9839
+ if (this.disposed) return null;
9840
+ assertValidView(view);
9841
+ const size = this.getCanvasSize();
9842
+ assertCanvasDims(size.w, size.h);
9843
+ if (size.w === 0 || size.h === 0) {
9844
+ this.end("cancelled");
9845
+ return null;
9846
+ }
9847
+ return size;
9848
+ }
9849
+ step = () => {
9850
+ if (this.disposed || this.to === null || this.from === null) return;
9851
+ if (this.foreignWrite()) {
9852
+ this.end("cancelled");
9853
+ return;
9854
+ }
9855
+ const size = this.getCanvasSize();
9856
+ if (!canvasDimsUsable(size.w, size.h)) {
9857
+ this.end("cancelled");
9858
+ return;
9859
+ }
9860
+ if (size.w === 0 || size.h === 0) {
9861
+ this.end("cancelled");
9862
+ return;
9863
+ }
9864
+ const elapsed = this.now() - this.startedAt;
9865
+ const t = this.durationMs <= 0 ? 1 : Math.min(1, elapsed / this.durationMs);
9866
+ const view = lerpView(this.from, this.to, this.easing(t));
9867
+ applyCameraView(this.camera, view, size.w, size.h);
9868
+ this.recordWrite();
9869
+ if (t >= 1) {
9870
+ this.end("complete");
9871
+ return;
9872
+ }
9873
+ this.rafId = this.frames.requestFrame(this.step);
9874
+ };
9875
+ recordWrite() {
9876
+ this.lastWrite = {
9877
+ x: this.camera.position.x,
9878
+ y: this.camera.position.y,
9879
+ zoom: this.camera.zoom
9880
+ };
9881
+ }
9882
+ foreignWrite() {
9883
+ if (this.lastWrite === null) return false;
9884
+ const eps = 1e-6;
9885
+ 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;
9886
+ }
9887
+ /** Terminates an in-flight animation with `reason`. No-op when idle. */
9888
+ end(reason) {
9889
+ if (this.disposed || this.to === null) return;
9890
+ this.clearFrame();
9891
+ this.from = null;
9892
+ this.to = null;
9893
+ this.lastWrite = null;
9894
+ this.emit(reason);
9895
+ }
9896
+ clearFrame() {
9897
+ if (this.rafId !== null) {
9898
+ this.frames.cancelFrame(this.rafId);
9899
+ this.rafId = null;
9900
+ }
9901
+ }
9902
+ emit(reason) {
9903
+ for (const listener of [...this.endListeners]) {
9904
+ try {
9905
+ listener(reason);
9906
+ } catch {
9907
+ }
9908
+ }
9909
+ }
9910
+ };
9911
+
9912
+ // src/canvas/focus-presence.ts
9913
+ var FOCUS_PRESENCE_KIND = "focus";
9914
+ var AUDIENCES = ["all", "players", "display"];
9915
+ function isPositiveFinite(value) {
9916
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
9917
+ }
9918
+ function isFiniteNumber2(value) {
9919
+ return typeof value === "number" && Number.isFinite(value);
9920
+ }
9921
+ function isFocusPresence(data) {
9922
+ if (typeof data !== "object" || data === null) return false;
9923
+ const payload = data;
9924
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
9925
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
9926
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
9927
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
9928
+ return false;
9929
+ }
9930
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
9931
+ return true;
9932
+ }
9933
+ function toFocusPresence(view, audience, color) {
9934
+ return {
9935
+ kind: FOCUS_PRESENCE_KIND,
9936
+ x: view.x,
9937
+ y: view.y,
9938
+ w: view.w,
9939
+ h: view.h,
9940
+ audience,
9941
+ ...color === void 0 ? {} : { color }
9942
+ };
9943
+ }
9944
+
9945
+ // src/canvas/remote-focus-receiver.ts
9946
+ function audienceIncludes(audience, role) {
9947
+ if (role === "dm") return false;
9948
+ if (audience === "all") return true;
9949
+ if (audience === "players") return role === "player";
9950
+ return role === "display";
9951
+ }
9952
+ var RemoteFocusReceiver = class {
9953
+ role;
9954
+ animator;
9955
+ animate;
9956
+ pulseColor;
9957
+ overlay;
9958
+ disposed = false;
9959
+ constructor(host, options) {
9960
+ this.role = options.role;
9961
+ this.animator = options.animator;
9962
+ this.animate = options.animate ?? true;
9963
+ this.pulseColor = options.pulseColor;
9964
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
9965
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
9966
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
9967
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
9968
+ maxPingsPerSender: 1
9969
+ }) : null;
9970
+ }
9971
+ /**
9972
+ * Applies a presence payload from `sender`. Returns `false` for payloads
9973
+ * that are not focus frames, or are addressed to a different role, so hosts
9974
+ * can feed every presence frame through without disturbing other handlers.
9975
+ */
9976
+ apply(from, data) {
9977
+ if (this.disposed || !isFocusPresence(data)) return false;
9978
+ if (!audienceIncludes(data.audience, this.role)) return false;
9979
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
9980
+ if (this.animate) {
9981
+ this.animator.animateTo(view);
9982
+ } else {
9983
+ this.animator.jumpTo(view);
9984
+ }
9985
+ this.overlay?.apply(from, {
9986
+ kind: "ping",
9987
+ x: view.x + view.w / 2,
9988
+ y: view.y + view.h / 2,
9989
+ color: data.color ?? this.pulseColor
9990
+ });
9991
+ return true;
9992
+ }
9993
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
9994
+ dispose() {
9995
+ if (this.disposed) return;
9996
+ this.disposed = true;
9997
+ this.overlay?.dispose();
9998
+ }
9999
+ };
10000
+
9490
10001
  // src/tools/hand-tool.ts
9491
10002
  var HandTool = class {
9492
10003
  name = "hand";
@@ -9675,7 +10186,7 @@ function hitTestStroke(stroke, point, radius) {
9675
10186
  }
9676
10187
 
9677
10188
  // src/elements/stroke-erase.ts
9678
- function lerp(a, b, t) {
10189
+ function lerp2(a, b, t) {
9679
10190
  return {
9680
10191
  x: a.x + (b.x - a.x) * t,
9681
10192
  y: a.y + (b.y - a.y) * t,
@@ -9734,13 +10245,13 @@ function erasePoints(points, eraser, radius) {
9734
10245
  erased = true;
9735
10246
  if (tLo > 0) {
9736
10247
  if (current.length === 0) current.push(a);
9737
- current.push(lerp(a, b, tLo));
10248
+ current.push(lerp2(a, b, tLo));
9738
10249
  flush();
9739
10250
  } else {
9740
10251
  flush();
9741
10252
  }
9742
10253
  if (tHi < 1) {
9743
- current = [lerp(a, b, tHi), b];
10254
+ current = [lerp2(a, b, tHi), b];
9744
10255
  }
9745
10256
  }
9746
10257
  flush();
@@ -12196,7 +12707,7 @@ var LaserTool = class {
12196
12707
 
12197
12708
  // src/tools/ping-tool.ts
12198
12709
  var DEFAULT_COLOR6 = "#ff3b30";
12199
- var DEFAULT_DURATION_MS3 = 1800;
12710
+ var DEFAULT_DURATION_MS4 = 1800;
12200
12711
  var DEFAULT_RADIUS4 = 48;
12201
12712
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
12202
12713
  var PingTool = class {
@@ -12213,7 +12724,7 @@ var PingTool = class {
12213
12724
  constructor(options = {}) {
12214
12725
  this.name = options.name ?? "ping";
12215
12726
  this.color = options.color ?? DEFAULT_COLOR6;
12216
- this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
12727
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
12217
12728
  this.radius = options.radius ?? DEFAULT_RADIUS4;
12218
12729
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
12219
12730
  }
@@ -12315,14 +12826,16 @@ var PingTool = class {
12315
12826
  };
12316
12827
 
12317
12828
  // src/index.ts
12318
- var VERSION = "0.59.0";
12829
+ var VERSION = "0.61.0";
12319
12830
  export {
12320
12831
  ArrowTool,
12321
12832
  AutoSave,
12322
12833
  Camera,
12834
+ CameraAnimator,
12323
12835
  DEFAULT_NOTE_FONT_SIZE,
12324
12836
  ElementStore,
12325
12837
  EraserTool,
12838
+ FOCUS_PRESENCE_KIND,
12326
12839
  HandTool,
12327
12840
  HistoryStack,
12328
12841
  ImageTool,
@@ -12340,6 +12853,7 @@ export {
12340
12853
  PencilTool,
12341
12854
  PingInput,
12342
12855
  PingTool,
12856
+ RemoteFocusReceiver,
12343
12857
  RemoteLaserOverlay,
12344
12858
  RemoteMeasureOverlay,
12345
12859
  RemotePingOverlay,
@@ -12350,7 +12864,10 @@ export {
12350
12864
  ToolManager,
12351
12865
  VERSION,
12352
12866
  Viewport,
12867
+ applyCameraView,
12353
12868
  boundsIntersect,
12869
+ cameraOriginForView,
12870
+ captureCameraView,
12354
12871
  createArrow,
12355
12872
  createGrid,
12356
12873
  createHtmlElement,
@@ -12363,6 +12880,7 @@ export {
12363
12880
  drawHexPath,
12364
12881
  exportImage,
12365
12882
  exportSvg,
12883
+ fitZoomForView,
12366
12884
  getActiveFormats,
12367
12885
  getArrowBounds,
12368
12886
  getArrowControlPoint,
@@ -12378,6 +12896,7 @@ export {
12378
12896
  getHexCellsInRectangle,
12379
12897
  getHexCellsInSquare,
12380
12898
  getHexDistance,
12899
+ isFocusPresence,
12381
12900
  isLaserTrailPresence,
12382
12901
  isMeasurePresence,
12383
12902
  isNearBezier,
@@ -12387,6 +12906,7 @@ export {
12387
12906
  snapPoint,
12388
12907
  snapToHexCenter,
12389
12908
  styleToPatch,
12909
+ toFocusPresence,
12390
12910
  toLaserTrailPresence,
12391
12911
  toMeasurePresence,
12392
12912
  toPingPresence,