@fieldnotes/core 0.53.0 → 0.55.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
@@ -30,13 +30,18 @@ __export(index_exports, {
30
30
  HistoryStack: () => HistoryStack,
31
31
  ImageTool: () => ImageTool,
32
32
  IndexedDBAdapter: () => IndexedDBAdapter,
33
+ LASER_TRAIL_PRESENCE_KIND: () => LASER_TRAIL_PRESENCE_KIND,
33
34
  LaserTool: () => LaserTool,
34
35
  LayerManager: () => LayerManager,
35
36
  LocalStorageAdapter: () => LocalStorageAdapter,
36
37
  MeasureTool: () => MeasureTool,
37
38
  MemoryAdapter: () => MemoryAdapter,
38
39
  NoteTool: () => NoteTool,
40
+ PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
39
41
  PencilTool: () => PencilTool,
42
+ PingTool: () => PingTool,
43
+ RemoteLaserOverlay: () => RemoteLaserOverlay,
44
+ RemotePingOverlay: () => RemotePingOverlay,
40
45
  SelectTool: () => SelectTool,
41
46
  ShapeTool: () => ShapeTool,
42
47
  TemplateTool: () => TemplateTool,
@@ -72,12 +77,16 @@ __export(index_exports, {
72
77
  getHexCellsInRectangle: () => getHexCellsInRectangle,
73
78
  getHexCellsInSquare: () => getHexCellsInSquare,
74
79
  getHexDistance: () => getHexDistance,
80
+ isLaserTrailPresence: () => isLaserTrailPresence,
75
81
  isNearBezier: () => isNearBezier,
82
+ isPingPresence: () => isPingPresence,
76
83
  setFontSize: () => setFontSize,
77
84
  smartSnap: () => smartSnap,
78
85
  snapPoint: () => snapPoint,
79
86
  snapToHexCenter: () => snapToHexCenter,
80
87
  styleToPatch: () => styleToPatch,
88
+ toLaserTrailPresence: () => toLaserTrailPresence,
89
+ toPingPresence: () => toPingPresence,
81
90
  toggleBold: () => toggleBold,
82
91
  toggleItalic: () => toggleItalic,
83
92
  toggleStrikethrough: () => toggleStrikethrough,
@@ -6922,6 +6931,7 @@ var RenderLoop = class {
6922
6931
  // set on recenter/viewport-change; consumed by the grid block
6923
6932
  stats = new RenderStats();
6924
6933
  layerGroups = /* @__PURE__ */ new Map();
6934
+ overlays = /* @__PURE__ */ new Set();
6925
6935
  gridCacheCanvas = null;
6926
6936
  gridCacheCtx = null;
6927
6937
  lastGridRefs = [];
@@ -6980,6 +6990,29 @@ var RenderLoop = class {
6980
6990
  getStats() {
6981
6991
  return this.stats.getSnapshot();
6982
6992
  }
6993
+ /**
6994
+ * Registers a viewport overlay drawn beneath the active tool's own overlay.
6995
+ * Returns an idempotent unsubscribe that also erases the overlay's last
6996
+ * frame.
6997
+ */
6998
+ registerOverlay(draw) {
6999
+ this.overlays.add(draw);
7000
+ this.requestRender();
7001
+ return () => {
7002
+ if (this.overlays.delete(draw)) this.requestRender();
7003
+ };
7004
+ }
7005
+ drawRegisteredOverlays(ctx) {
7006
+ for (const draw of this.overlays) {
7007
+ ctx.save();
7008
+ try {
7009
+ draw(ctx);
7010
+ } catch {
7011
+ } finally {
7012
+ ctx.restore();
7013
+ }
7014
+ }
7015
+ }
6983
7016
  compositeLayerCache(ctx, layerId, opacity) {
6984
7017
  const cached = this.layerCache.getCanvas(layerId);
6985
7018
  const offset = this.marginViewport.compositeOffset(
@@ -7113,7 +7146,8 @@ var RenderLoop = class {
7113
7146
  }
7114
7147
  const activeTool = this.toolManager.activeTool;
7115
7148
  const overlayOrder = visibleElements.length + 1;
7116
- if (hybridActive && activeTool?.renderOverlay) hybridOrders.add(overlayOrder);
7149
+ const hasOverlay = activeTool?.renderOverlay !== void 0 || this.overlays.size > 0;
7150
+ if (hybridActive && hasOverlay) hybridOrders.add(overlayOrder);
7117
7151
  this.hybridSurface.beginFrame(hybridOrders, this.canvasEl.width, this.canvasEl.height);
7118
7152
  for (const [layerId, elements] of this.layerGroups) {
7119
7153
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
@@ -7223,7 +7257,7 @@ var RenderLoop = class {
7223
7257
  hybridCtx.restore();
7224
7258
  }
7225
7259
  const overlayT0 = performance.now();
7226
- if (hybridActive && activeTool?.renderOverlay) {
7260
+ if (hybridActive && hasOverlay) {
7227
7261
  const overlayCtx = this.hybridSurface.getContext(overlayOrder);
7228
7262
  if (overlayCtx) {
7229
7263
  overlayCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
@@ -7231,11 +7265,13 @@ var RenderLoop = class {
7231
7265
  overlayCtx.scale(dpr, dpr);
7232
7266
  overlayCtx.translate(this.camera.position.x, this.camera.position.y);
7233
7267
  overlayCtx.scale(this.camera.zoom, this.camera.zoom);
7234
- activeTool.renderOverlay(overlayCtx);
7268
+ this.drawRegisteredOverlays(overlayCtx);
7269
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(overlayCtx);
7235
7270
  overlayCtx.restore();
7236
7271
  }
7237
- } else if (activeTool?.renderOverlay) {
7238
- activeTool.renderOverlay(ctx);
7272
+ } else if (hasOverlay) {
7273
+ this.drawRegisteredOverlays(ctx);
7274
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(ctx);
7239
7275
  }
7240
7276
  const overlayMs = performance.now() - overlayT0;
7241
7277
  ctx.restore();
@@ -8229,6 +8265,17 @@ var Viewport = class {
8229
8265
  requestRender() {
8230
8266
  this.renderLoop.requestRender();
8231
8267
  }
8268
+ /**
8269
+ * Registers a world-space overlay drawn above elements on every frame,
8270
+ * regardless of the active tool — the surface for remote presence visuals
8271
+ * such as laser trails, cursors, pings, and shared rulers. Overlays draw
8272
+ * beneath the active tool's own `renderOverlay` and never touch elements,
8273
+ * history, or persisted state. Returns an idempotent unsubscribe that also
8274
+ * erases the overlay's last frame.
8275
+ */
8276
+ registerOverlay(draw) {
8277
+ return this.renderLoop.registerOverlay(draw);
8278
+ }
8232
8279
  exportState() {
8233
8280
  return exportState(
8234
8281
  this.store.snapshot(),
@@ -8576,6 +8623,324 @@ var Viewport = class {
8576
8623
  }
8577
8624
  };
8578
8625
 
8626
+ // src/canvas/remote-laser-overlay.ts
8627
+ var LASER_TRAIL_PRESENCE_KIND = "laser";
8628
+ function isFinitePoint(value) {
8629
+ if (typeof value !== "object" || value === null) return false;
8630
+ const point = value;
8631
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
8632
+ }
8633
+ function isLaserTrailPresence(data) {
8634
+ if (typeof data !== "object" || data === null) return false;
8635
+ const payload = data;
8636
+ if (payload.kind !== LASER_TRAIL_PRESENCE_KIND) return false;
8637
+ if (!Array.isArray(payload.points) || !payload.points.every(isFinitePoint)) return false;
8638
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
8639
+ if (payload.width !== void 0 && !(typeof payload.width === "number" && payload.width > 0)) {
8640
+ return false;
8641
+ }
8642
+ if (payload.fadeMs !== void 0 && !(typeof payload.fadeMs === "number" && payload.fadeMs > 0)) {
8643
+ return false;
8644
+ }
8645
+ return true;
8646
+ }
8647
+ function toLaserTrailPresence(emission) {
8648
+ return {
8649
+ kind: LASER_TRAIL_PRESENCE_KIND,
8650
+ points: emission.points.map((point) => ({ x: point.x, y: point.y })),
8651
+ color: emission.color,
8652
+ width: emission.width,
8653
+ fadeMs: emission.fadeMs
8654
+ };
8655
+ }
8656
+ var DEFAULT_COLOR = "#ff3b30";
8657
+ var DEFAULT_WIDTH = 4;
8658
+ var DEFAULT_FADE_MS = 1200;
8659
+ var DEFAULT_MAX_POINTS = 512;
8660
+ var RemoteLaserOverlay = class {
8661
+ host;
8662
+ color;
8663
+ width;
8664
+ fadeMs;
8665
+ maxPointsPerSender;
8666
+ trails = /* @__PURE__ */ new Map();
8667
+ unregister;
8668
+ rafId = null;
8669
+ disposed = false;
8670
+ constructor(host, options = {}) {
8671
+ this.host = host;
8672
+ this.color = options.color ?? DEFAULT_COLOR;
8673
+ this.width = options.width ?? DEFAULT_WIDTH;
8674
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
8675
+ this.maxPointsPerSender = options.maxPointsPerSender ?? DEFAULT_MAX_POINTS;
8676
+ this.unregister = host.registerOverlay((ctx) => this.renderTrails(ctx));
8677
+ }
8678
+ now() {
8679
+ return performance.now();
8680
+ }
8681
+ /**
8682
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
8683
+ * the envelope `from`). Non-laser or malformed payloads are ignored and
8684
+ * reported as `false`, so hosts can feed every presence frame through.
8685
+ */
8686
+ apply(sender, data) {
8687
+ if (this.disposed || !isLaserTrailPresence(data)) return false;
8688
+ let trail = this.trails.get(sender);
8689
+ if (!trail) {
8690
+ trail = { points: [], color: this.color, width: this.width, fadeMs: this.fadeMs };
8691
+ this.trails.set(sender, trail);
8692
+ }
8693
+ trail.color = data.color ?? this.color;
8694
+ trail.width = data.width ?? this.width;
8695
+ trail.fadeMs = data.fadeMs ?? this.fadeMs;
8696
+ const t = this.now();
8697
+ for (const point of data.points) {
8698
+ trail.points.push({ x: point.x, y: point.y, t });
8699
+ }
8700
+ const excess = trail.points.length - this.maxPointsPerSender;
8701
+ if (excess > 0) trail.points.splice(0, excess);
8702
+ this.ensureAnimating();
8703
+ this.host.requestRender();
8704
+ return true;
8705
+ }
8706
+ /** Removes a sender's trail immediately (presence-leave/disconnect). */
8707
+ remove(sender) {
8708
+ if (this.trails.delete(sender)) this.host.requestRender();
8709
+ }
8710
+ /** Removes every trail immediately. */
8711
+ clear() {
8712
+ if (this.trails.size === 0) return;
8713
+ this.trails.clear();
8714
+ this.host.requestRender();
8715
+ }
8716
+ /** Number of senders with a live (unfaded) trail. */
8717
+ get activeSenderCount() {
8718
+ return this.trails.size;
8719
+ }
8720
+ /** Unregisters the overlay and stops the fade loop. Idempotent. */
8721
+ dispose() {
8722
+ if (this.disposed) return;
8723
+ this.disposed = true;
8724
+ if (this.rafId !== null) {
8725
+ cancelAnimationFrame(this.rafId);
8726
+ this.rafId = null;
8727
+ }
8728
+ this.trails.clear();
8729
+ this.unregister?.();
8730
+ this.unregister = null;
8731
+ }
8732
+ ensureAnimating() {
8733
+ if (this.rafId === null) {
8734
+ this.rafId = requestAnimationFrame(() => this.tick());
8735
+ }
8736
+ }
8737
+ tick() {
8738
+ if (this.disposed) return;
8739
+ const now = this.now();
8740
+ for (const [sender, trail] of this.trails) {
8741
+ const cutoff = now - trail.fadeMs;
8742
+ trail.points = trail.points.filter((point) => point.t >= cutoff);
8743
+ if (trail.points.length === 0) this.trails.delete(sender);
8744
+ }
8745
+ this.host.requestRender();
8746
+ this.rafId = this.trails.size > 0 ? requestAnimationFrame(() => this.tick()) : null;
8747
+ }
8748
+ renderTrails(ctx) {
8749
+ if (this.trails.size === 0) return;
8750
+ const now = this.now();
8751
+ for (const trail of this.trails.values()) {
8752
+ if (trail.points.length < 2) continue;
8753
+ ctx.save();
8754
+ ctx.strokeStyle = trail.color;
8755
+ ctx.lineWidth = trail.width;
8756
+ ctx.lineCap = "round";
8757
+ ctx.lineJoin = "round";
8758
+ for (let i = 0; i < trail.points.length - 1; i++) {
8759
+ const a = trail.points[i];
8760
+ const b = trail.points[i + 1];
8761
+ if (!a || !b) continue;
8762
+ const age = now - b.t;
8763
+ ctx.globalAlpha = Math.max(0, 1 - age / trail.fadeMs);
8764
+ ctx.beginPath();
8765
+ ctx.moveTo(a.x, a.y);
8766
+ ctx.lineTo(b.x, b.y);
8767
+ ctx.stroke();
8768
+ }
8769
+ ctx.restore();
8770
+ }
8771
+ }
8772
+ };
8773
+
8774
+ // src/canvas/ping-pulse.ts
8775
+ var RIPPLE_OFFSETS = [0, 0.25];
8776
+ function easeOutCubic(t) {
8777
+ const inv = 1 - t;
8778
+ return 1 - inv * inv * inv;
8779
+ }
8780
+ function renderPingPulse(ctx, x, y, ageMs, style) {
8781
+ if (ageMs < 0 || ageMs >= style.durationMs) return;
8782
+ ctx.save();
8783
+ ctx.strokeStyle = style.color;
8784
+ ctx.fillStyle = style.color;
8785
+ for (const offset of RIPPLE_OFFSETS) {
8786
+ const rippleSpan = style.durationMs * (1 - offset);
8787
+ const rippleAge = ageMs - style.durationMs * offset;
8788
+ if (rippleAge < 0) continue;
8789
+ const progress = Math.min(1, rippleAge / rippleSpan);
8790
+ const rippleRadius = style.radius * easeOutCubic(progress);
8791
+ if (rippleRadius <= 0) continue;
8792
+ ctx.globalAlpha = Math.max(0, 1 - progress);
8793
+ ctx.lineWidth = Math.max(1.5, style.radius / 12);
8794
+ ctx.beginPath();
8795
+ ctx.arc(x, y, rippleRadius, 0, Math.PI * 2);
8796
+ ctx.stroke();
8797
+ }
8798
+ const dotProgress = ageMs / style.durationMs;
8799
+ ctx.globalAlpha = Math.max(0, 1 - Math.max(0, dotProgress - 0.5) * 2);
8800
+ ctx.beginPath();
8801
+ ctx.arc(x, y, Math.max(2, style.radius / 8), 0, Math.PI * 2);
8802
+ ctx.fill();
8803
+ ctx.restore();
8804
+ }
8805
+
8806
+ // src/canvas/remote-ping-overlay.ts
8807
+ var PING_PRESENCE_KIND = "ping";
8808
+ function isPingPresence(data) {
8809
+ if (typeof data !== "object" || data === null) return false;
8810
+ const payload = data;
8811
+ if (payload.kind !== PING_PRESENCE_KIND) return false;
8812
+ if (typeof payload.x !== "number" || !Number.isFinite(payload.x)) return false;
8813
+ if (typeof payload.y !== "number" || !Number.isFinite(payload.y)) return false;
8814
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
8815
+ if (payload.durationMs !== void 0 && !(typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs) && payload.durationMs > 0)) {
8816
+ return false;
8817
+ }
8818
+ if (payload.radius !== void 0 && !(typeof payload.radius === "number" && Number.isFinite(payload.radius) && payload.radius > 0)) {
8819
+ return false;
8820
+ }
8821
+ return true;
8822
+ }
8823
+ function toPingPresence(emission) {
8824
+ return {
8825
+ kind: PING_PRESENCE_KIND,
8826
+ x: emission.x,
8827
+ y: emission.y,
8828
+ color: emission.color,
8829
+ durationMs: emission.durationMs,
8830
+ radius: emission.radius
8831
+ };
8832
+ }
8833
+ var DEFAULT_COLOR2 = "#ff3b30";
8834
+ var DEFAULT_DURATION_MS = 1800;
8835
+ var DEFAULT_RADIUS = 48;
8836
+ var DEFAULT_MAX_PINGS = 8;
8837
+ var RemotePingOverlay = class {
8838
+ host;
8839
+ color;
8840
+ durationMs;
8841
+ radius;
8842
+ maxPingsPerSender;
8843
+ pings = /* @__PURE__ */ new Map();
8844
+ unregister;
8845
+ rafId = null;
8846
+ disposed = false;
8847
+ constructor(host, options = {}) {
8848
+ this.host = host;
8849
+ this.color = options.color ?? DEFAULT_COLOR2;
8850
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS;
8851
+ this.radius = options.radius ?? DEFAULT_RADIUS;
8852
+ this.maxPingsPerSender = options.maxPingsPerSender ?? DEFAULT_MAX_PINGS;
8853
+ this.unregister = host.registerOverlay((ctx) => this.renderPings(ctx));
8854
+ }
8855
+ now() {
8856
+ return performance.now();
8857
+ }
8858
+ /**
8859
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
8860
+ * the envelope `from`). Non-ping or malformed payloads are ignored and
8861
+ * reported as `false`, so hosts can feed every presence frame through.
8862
+ */
8863
+ apply(sender, data) {
8864
+ if (this.disposed || !isPingPresence(data)) return false;
8865
+ let senderPings = this.pings.get(sender);
8866
+ if (!senderPings) {
8867
+ senderPings = [];
8868
+ this.pings.set(sender, senderPings);
8869
+ }
8870
+ senderPings.push({
8871
+ x: data.x,
8872
+ y: data.y,
8873
+ t: this.now(),
8874
+ color: data.color ?? this.color,
8875
+ durationMs: data.durationMs ?? this.durationMs,
8876
+ radius: data.radius ?? this.radius
8877
+ });
8878
+ const excess = senderPings.length - this.maxPingsPerSender;
8879
+ if (excess > 0) senderPings.splice(0, excess);
8880
+ this.ensureAnimating();
8881
+ this.host.requestRender();
8882
+ return true;
8883
+ }
8884
+ /** Removes a sender's pings immediately (presence-leave/disconnect). */
8885
+ remove(sender) {
8886
+ if (this.pings.delete(sender)) this.host.requestRender();
8887
+ }
8888
+ /** Removes every ping immediately. */
8889
+ clear() {
8890
+ if (this.pings.size === 0) return;
8891
+ this.pings.clear();
8892
+ this.host.requestRender();
8893
+ }
8894
+ /** Number of senders with a live (unexpired) ping. */
8895
+ get activeSenderCount() {
8896
+ return this.pings.size;
8897
+ }
8898
+ /** Unregisters the overlay and stops the animation loop. Idempotent. */
8899
+ dispose() {
8900
+ if (this.disposed) return;
8901
+ this.disposed = true;
8902
+ if (this.rafId !== null) {
8903
+ cancelAnimationFrame(this.rafId);
8904
+ this.rafId = null;
8905
+ }
8906
+ this.pings.clear();
8907
+ this.unregister?.();
8908
+ this.unregister = null;
8909
+ }
8910
+ ensureAnimating() {
8911
+ if (this.rafId === null) {
8912
+ this.rafId = requestAnimationFrame(() => this.tick());
8913
+ }
8914
+ }
8915
+ tick() {
8916
+ if (this.disposed) return;
8917
+ const now = this.now();
8918
+ for (const [sender, senderPings] of this.pings) {
8919
+ const live = senderPings.filter((ping) => now - ping.t < ping.durationMs);
8920
+ if (live.length === 0) {
8921
+ this.pings.delete(sender);
8922
+ } else {
8923
+ this.pings.set(sender, live);
8924
+ }
8925
+ }
8926
+ this.host.requestRender();
8927
+ this.rafId = this.pings.size > 0 ? requestAnimationFrame(() => this.tick()) : null;
8928
+ }
8929
+ renderPings(ctx) {
8930
+ if (this.pings.size === 0) return;
8931
+ const now = this.now();
8932
+ for (const senderPings of this.pings.values()) {
8933
+ for (const ping of senderPings) {
8934
+ renderPingPulse(ctx, ping.x, ping.y, now - ping.t, {
8935
+ color: ping.color,
8936
+ durationMs: ping.durationMs,
8937
+ radius: ping.radius
8938
+ });
8939
+ }
8940
+ }
8941
+ }
8942
+ };
8943
+
8579
8944
  // src/tools/hand-tool.ts
8580
8945
  var HandTool = class {
8581
8946
  name = "hand";
@@ -8837,7 +9202,7 @@ function erasePoints(points, eraser, radius) {
8837
9202
  }
8838
9203
 
8839
9204
  // src/tools/eraser-tool.ts
8840
- var DEFAULT_RADIUS = 20;
9205
+ var DEFAULT_RADIUS2 = 20;
8841
9206
  function makeEraserCursor(radius) {
8842
9207
  const size = radius * 2;
8843
9208
  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'><circle cx='${radius}' cy='${radius}' r='${radius - 1}' fill='none' stroke='%23666' stroke-width='1.5'/></svg>`;
@@ -8850,7 +9215,7 @@ var EraserTool = class {
8850
9215
  cursor;
8851
9216
  mode;
8852
9217
  constructor(options = {}) {
8853
- this.radius = options.radius ?? DEFAULT_RADIUS;
9218
+ this.radius = options.radius ?? DEFAULT_RADIUS2;
8854
9219
  this.cursor = makeEraserCursor(this.radius);
8855
9220
  this.mode = options.mode ?? "partial";
8856
9221
  }
@@ -11132,9 +11497,9 @@ var TemplateTool = class {
11132
11497
  };
11133
11498
 
11134
11499
  // src/tools/laser-tool.ts
11135
- var DEFAULT_COLOR = "#ff3b30";
11136
- var DEFAULT_WIDTH = 4;
11137
- var DEFAULT_FADE_MS = 1200;
11500
+ var DEFAULT_COLOR3 = "#ff3b30";
11501
+ var DEFAULT_WIDTH2 = 4;
11502
+ var DEFAULT_FADE_MS2 = 1200;
11138
11503
  var LaserTool = class {
11139
11504
  name;
11140
11505
  color;
@@ -11144,11 +11509,13 @@ var LaserTool = class {
11144
11509
  rafId = null;
11145
11510
  drawing = false;
11146
11511
  optionListeners = /* @__PURE__ */ new Set();
11512
+ trailListeners = /* @__PURE__ */ new Set();
11513
+ pendingEmission = [];
11147
11514
  constructor(options = {}) {
11148
11515
  this.name = options.name ?? "laser";
11149
- this.color = options.color ?? DEFAULT_COLOR;
11150
- this.width = options.width ?? DEFAULT_WIDTH;
11151
- this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
11516
+ this.color = options.color ?? DEFAULT_COLOR3;
11517
+ this.width = options.width ?? DEFAULT_WIDTH2;
11518
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11152
11519
  }
11153
11520
  now() {
11154
11521
  return performance.now();
@@ -11163,6 +11530,7 @@ var LaserTool = class {
11163
11530
  }
11164
11531
  this.trail = [];
11165
11532
  this.drawing = false;
11533
+ this.pendingEmission = [];
11166
11534
  ctx.setCursor?.("default");
11167
11535
  ctx.requestRender();
11168
11536
  }
@@ -11178,6 +11546,16 @@ var LaserTool = class {
11178
11546
  this.optionListeners.add(listener);
11179
11547
  return () => this.optionListeners.delete(listener);
11180
11548
  }
11549
+ /**
11550
+ * Subscribes to coalesced trail emissions. Points recorded by pointer input
11551
+ * are batched and delivered at most once per animation frame, keeping
11552
+ * outgoing presence packets small. Listeners must not throw; a throwing
11553
+ * listener is isolated so it cannot stall the fade loop.
11554
+ */
11555
+ onTrail(listener) {
11556
+ this.trailListeners.add(listener);
11557
+ return () => this.trailListeners.delete(listener);
11558
+ }
11181
11559
  setOptions(options) {
11182
11560
  if (options.color !== void 0) this.color = options.color;
11183
11561
  if (options.width !== void 0) this.width = options.width;
@@ -11186,14 +11564,18 @@ var LaserTool = class {
11186
11564
  }
11187
11565
  onPointerDown(state, ctx) {
11188
11566
  this.drawing = true;
11189
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11190
- this.trail.push({ x: world.x, y: world.y, t: this.now() });
11191
- this.ensureAnimating(ctx);
11567
+ this.recordPoint(state, ctx);
11192
11568
  }
11193
11569
  onPointerMove(state, ctx) {
11194
11570
  if (!this.drawing) return;
11571
+ this.recordPoint(state, ctx);
11572
+ }
11573
+ recordPoint(state, ctx) {
11195
11574
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11196
11575
  this.trail.push({ x: world.x, y: world.y, t: this.now() });
11576
+ if (this.trailListeners.size > 0) {
11577
+ this.pendingEmission.push({ x: world.x, y: world.y });
11578
+ }
11197
11579
  this.ensureAnimating(ctx);
11198
11580
  }
11199
11581
  onPointerUp(_state, _ctx) {
@@ -11225,7 +11607,24 @@ var LaserTool = class {
11225
11607
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11226
11608
  }
11227
11609
  }
11610
+ flushEmission() {
11611
+ if (this.pendingEmission.length === 0) return;
11612
+ const emission = {
11613
+ points: this.pendingEmission,
11614
+ color: this.color,
11615
+ width: this.width,
11616
+ fadeMs: this.fadeMs
11617
+ };
11618
+ this.pendingEmission = [];
11619
+ for (const listener of this.trailListeners) {
11620
+ try {
11621
+ listener(emission);
11622
+ } catch {
11623
+ }
11624
+ }
11625
+ }
11228
11626
  tick(ctx) {
11627
+ this.flushEmission();
11229
11628
  const cutoff = this.now() - this.fadeMs;
11230
11629
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11231
11630
  if (this.trail.length > 0) {
@@ -11241,8 +11640,128 @@ var LaserTool = class {
11241
11640
  }
11242
11641
  };
11243
11642
 
11643
+ // src/tools/ping-tool.ts
11644
+ var DEFAULT_COLOR4 = "#ff3b30";
11645
+ var DEFAULT_DURATION_MS2 = 1800;
11646
+ var DEFAULT_RADIUS3 = 48;
11647
+ var DEFAULT_MIN_INTERVAL_MS = 300;
11648
+ var PingTool = class {
11649
+ name;
11650
+ color;
11651
+ durationMs;
11652
+ radius;
11653
+ minIntervalMs;
11654
+ pings = [];
11655
+ lastEmitAt = -Infinity;
11656
+ rafId = null;
11657
+ optionListeners = /* @__PURE__ */ new Set();
11658
+ pingListeners = /* @__PURE__ */ new Set();
11659
+ constructor(options = {}) {
11660
+ this.name = options.name ?? "ping";
11661
+ this.color = options.color ?? DEFAULT_COLOR4;
11662
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
11663
+ this.radius = options.radius ?? DEFAULT_RADIUS3;
11664
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
11665
+ }
11666
+ now() {
11667
+ return performance.now();
11668
+ }
11669
+ onActivate(ctx) {
11670
+ ctx.setCursor?.("crosshair");
11671
+ }
11672
+ onDeactivate(ctx) {
11673
+ if (this.rafId !== null) {
11674
+ cancelAnimationFrame(this.rafId);
11675
+ this.rafId = null;
11676
+ }
11677
+ this.pings = [];
11678
+ ctx.setCursor?.("default");
11679
+ ctx.requestRender();
11680
+ }
11681
+ getOptions() {
11682
+ return {
11683
+ name: this.name,
11684
+ color: this.color,
11685
+ durationMs: this.durationMs,
11686
+ radius: this.radius,
11687
+ minIntervalMs: this.minIntervalMs
11688
+ };
11689
+ }
11690
+ setOptions(options) {
11691
+ if (options.color !== void 0) this.color = options.color;
11692
+ if (options.durationMs !== void 0) this.durationMs = options.durationMs;
11693
+ if (options.radius !== void 0) this.radius = options.radius;
11694
+ if (options.minIntervalMs !== void 0) this.minIntervalMs = options.minIntervalMs;
11695
+ this.notifyOptionsChange();
11696
+ }
11697
+ onOptionsChange(listener) {
11698
+ this.optionListeners.add(listener);
11699
+ return () => this.optionListeners.delete(listener);
11700
+ }
11701
+ /**
11702
+ * Subscribes to accepted pings. Listeners must not throw; a throwing
11703
+ * listener is isolated so it cannot break the tap handling or other
11704
+ * listeners.
11705
+ */
11706
+ onPing(listener) {
11707
+ this.pingListeners.add(listener);
11708
+ return () => this.pingListeners.delete(listener);
11709
+ }
11710
+ onPointerDown(state, ctx) {
11711
+ const t = this.now();
11712
+ if (t - this.lastEmitAt < this.minIntervalMs) return;
11713
+ this.lastEmitAt = t;
11714
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11715
+ this.pings.push({ x: world.x, y: world.y, t });
11716
+ const emission = {
11717
+ x: world.x,
11718
+ y: world.y,
11719
+ color: this.color,
11720
+ durationMs: this.durationMs,
11721
+ radius: this.radius
11722
+ };
11723
+ for (const listener of this.pingListeners) {
11724
+ try {
11725
+ listener(emission);
11726
+ } catch {
11727
+ }
11728
+ }
11729
+ this.ensureAnimating(ctx);
11730
+ ctx.requestRender();
11731
+ }
11732
+ onPointerMove(_state, _ctx) {
11733
+ }
11734
+ onPointerUp(_state, _ctx) {
11735
+ }
11736
+ renderOverlay(ctx) {
11737
+ if (this.pings.length === 0) return;
11738
+ const now = this.now();
11739
+ for (const ping of this.pings) {
11740
+ renderPingPulse(ctx, ping.x, ping.y, now - ping.t, {
11741
+ color: this.color,
11742
+ durationMs: this.durationMs,
11743
+ radius: this.radius
11744
+ });
11745
+ }
11746
+ }
11747
+ ensureAnimating(ctx) {
11748
+ if (this.rafId === null) {
11749
+ this.rafId = requestAnimationFrame(() => this.tick(ctx));
11750
+ }
11751
+ }
11752
+ tick(ctx) {
11753
+ const cutoff = this.now() - this.durationMs;
11754
+ this.pings = this.pings.filter((ping) => ping.t > cutoff);
11755
+ ctx.requestRender();
11756
+ this.rafId = this.pings.length > 0 ? requestAnimationFrame(() => this.tick(ctx)) : null;
11757
+ }
11758
+ notifyOptionsChange() {
11759
+ for (const listener of this.optionListeners) listener();
11760
+ }
11761
+ };
11762
+
11244
11763
  // src/index.ts
11245
- var VERSION = "0.53.0";
11764
+ var VERSION = "0.55.0";
11246
11765
  // Annotate the CommonJS export names for ESM import in node:
11247
11766
  0 && (module.exports = {
11248
11767
  ArrowTool,
@@ -11255,13 +11774,18 @@ var VERSION = "0.53.0";
11255
11774
  HistoryStack,
11256
11775
  ImageTool,
11257
11776
  IndexedDBAdapter,
11777
+ LASER_TRAIL_PRESENCE_KIND,
11258
11778
  LaserTool,
11259
11779
  LayerManager,
11260
11780
  LocalStorageAdapter,
11261
11781
  MeasureTool,
11262
11782
  MemoryAdapter,
11263
11783
  NoteTool,
11784
+ PING_PRESENCE_KIND,
11264
11785
  PencilTool,
11786
+ PingTool,
11787
+ RemoteLaserOverlay,
11788
+ RemotePingOverlay,
11265
11789
  SelectTool,
11266
11790
  ShapeTool,
11267
11791
  TemplateTool,
@@ -11297,12 +11821,16 @@ var VERSION = "0.53.0";
11297
11821
  getHexCellsInRectangle,
11298
11822
  getHexCellsInSquare,
11299
11823
  getHexDistance,
11824
+ isLaserTrailPresence,
11300
11825
  isNearBezier,
11826
+ isPingPresence,
11301
11827
  setFontSize,
11302
11828
  smartSnap,
11303
11829
  snapPoint,
11304
11830
  snapToHexCenter,
11305
11831
  styleToPatch,
11832
+ toLaserTrailPresence,
11833
+ toPingPresence,
11306
11834
  toggleBold,
11307
11835
  toggleItalic,
11308
11836
  toggleStrikethrough,