@fieldnotes/core 0.60.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
@@ -8484,6 +8484,17 @@ var Viewport = class _Viewport {
8484
8484
  getVisibleRect() {
8485
8485
  return this.camera.getVisibleRect(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
8486
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
+ }
8487
8498
  /** Centers the camera on a world point without changing zoom. */
8488
8499
  centerCameraAt(world) {
8489
8500
  const z = this.camera.zoom;
@@ -9632,6 +9643,361 @@ var PingInput = class {
9632
9643
  }
9633
9644
  };
9634
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
+
9635
10001
  // src/tools/hand-tool.ts
9636
10002
  var HandTool = class {
9637
10003
  name = "hand";
@@ -9820,7 +10186,7 @@ function hitTestStroke(stroke, point, radius) {
9820
10186
  }
9821
10187
 
9822
10188
  // src/elements/stroke-erase.ts
9823
- function lerp(a, b, t) {
10189
+ function lerp2(a, b, t) {
9824
10190
  return {
9825
10191
  x: a.x + (b.x - a.x) * t,
9826
10192
  y: a.y + (b.y - a.y) * t,
@@ -9879,13 +10245,13 @@ function erasePoints(points, eraser, radius) {
9879
10245
  erased = true;
9880
10246
  if (tLo > 0) {
9881
10247
  if (current.length === 0) current.push(a);
9882
- current.push(lerp(a, b, tLo));
10248
+ current.push(lerp2(a, b, tLo));
9883
10249
  flush();
9884
10250
  } else {
9885
10251
  flush();
9886
10252
  }
9887
10253
  if (tHi < 1) {
9888
- current = [lerp(a, b, tHi), b];
10254
+ current = [lerp2(a, b, tHi), b];
9889
10255
  }
9890
10256
  }
9891
10257
  flush();
@@ -12341,7 +12707,7 @@ var LaserTool = class {
12341
12707
 
12342
12708
  // src/tools/ping-tool.ts
12343
12709
  var DEFAULT_COLOR6 = "#ff3b30";
12344
- var DEFAULT_DURATION_MS3 = 1800;
12710
+ var DEFAULT_DURATION_MS4 = 1800;
12345
12711
  var DEFAULT_RADIUS4 = 48;
12346
12712
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
12347
12713
  var PingTool = class {
@@ -12358,7 +12724,7 @@ var PingTool = class {
12358
12724
  constructor(options = {}) {
12359
12725
  this.name = options.name ?? "ping";
12360
12726
  this.color = options.color ?? DEFAULT_COLOR6;
12361
- this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
12727
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
12362
12728
  this.radius = options.radius ?? DEFAULT_RADIUS4;
12363
12729
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
12364
12730
  }
@@ -12460,14 +12826,16 @@ var PingTool = class {
12460
12826
  };
12461
12827
 
12462
12828
  // src/index.ts
12463
- var VERSION = "0.60.0";
12829
+ var VERSION = "0.61.0";
12464
12830
  export {
12465
12831
  ArrowTool,
12466
12832
  AutoSave,
12467
12833
  Camera,
12834
+ CameraAnimator,
12468
12835
  DEFAULT_NOTE_FONT_SIZE,
12469
12836
  ElementStore,
12470
12837
  EraserTool,
12838
+ FOCUS_PRESENCE_KIND,
12471
12839
  HandTool,
12472
12840
  HistoryStack,
12473
12841
  ImageTool,
@@ -12485,6 +12853,7 @@ export {
12485
12853
  PencilTool,
12486
12854
  PingInput,
12487
12855
  PingTool,
12856
+ RemoteFocusReceiver,
12488
12857
  RemoteLaserOverlay,
12489
12858
  RemoteMeasureOverlay,
12490
12859
  RemotePingOverlay,
@@ -12495,7 +12864,10 @@ export {
12495
12864
  ToolManager,
12496
12865
  VERSION,
12497
12866
  Viewport,
12867
+ applyCameraView,
12498
12868
  boundsIntersect,
12869
+ cameraOriginForView,
12870
+ captureCameraView,
12499
12871
  createArrow,
12500
12872
  createGrid,
12501
12873
  createHtmlElement,
@@ -12508,6 +12880,7 @@ export {
12508
12880
  drawHexPath,
12509
12881
  exportImage,
12510
12882
  exportSvg,
12883
+ fitZoomForView,
12511
12884
  getActiveFormats,
12512
12885
  getArrowBounds,
12513
12886
  getArrowControlPoint,
@@ -12523,6 +12896,7 @@ export {
12523
12896
  getHexCellsInRectangle,
12524
12897
  getHexCellsInSquare,
12525
12898
  getHexDistance,
12899
+ isFocusPresence,
12526
12900
  isLaserTrailPresence,
12527
12901
  isMeasurePresence,
12528
12902
  isNearBezier,
@@ -12532,6 +12906,7 @@ export {
12532
12906
  snapPoint,
12533
12907
  snapToHexCenter,
12534
12908
  styleToPatch,
12909
+ toFocusPresence,
12535
12910
  toLaserTrailPresence,
12536
12911
  toMeasurePresence,
12537
12912
  toPingPresence,