@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.js CHANGED
@@ -6835,6 +6835,7 @@ var RenderLoop = class {
6835
6835
  // set on recenter/viewport-change; consumed by the grid block
6836
6836
  stats = new RenderStats();
6837
6837
  layerGroups = /* @__PURE__ */ new Map();
6838
+ overlays = /* @__PURE__ */ new Set();
6838
6839
  gridCacheCanvas = null;
6839
6840
  gridCacheCtx = null;
6840
6841
  lastGridRefs = [];
@@ -6893,6 +6894,29 @@ var RenderLoop = class {
6893
6894
  getStats() {
6894
6895
  return this.stats.getSnapshot();
6895
6896
  }
6897
+ /**
6898
+ * Registers a viewport overlay drawn beneath the active tool's own overlay.
6899
+ * Returns an idempotent unsubscribe that also erases the overlay's last
6900
+ * frame.
6901
+ */
6902
+ registerOverlay(draw) {
6903
+ this.overlays.add(draw);
6904
+ this.requestRender();
6905
+ return () => {
6906
+ if (this.overlays.delete(draw)) this.requestRender();
6907
+ };
6908
+ }
6909
+ drawRegisteredOverlays(ctx) {
6910
+ for (const draw of this.overlays) {
6911
+ ctx.save();
6912
+ try {
6913
+ draw(ctx);
6914
+ } catch {
6915
+ } finally {
6916
+ ctx.restore();
6917
+ }
6918
+ }
6919
+ }
6896
6920
  compositeLayerCache(ctx, layerId, opacity) {
6897
6921
  const cached = this.layerCache.getCanvas(layerId);
6898
6922
  const offset = this.marginViewport.compositeOffset(
@@ -7026,7 +7050,8 @@ var RenderLoop = class {
7026
7050
  }
7027
7051
  const activeTool = this.toolManager.activeTool;
7028
7052
  const overlayOrder = visibleElements.length + 1;
7029
- if (hybridActive && activeTool?.renderOverlay) hybridOrders.add(overlayOrder);
7053
+ const hasOverlay = activeTool?.renderOverlay !== void 0 || this.overlays.size > 0;
7054
+ if (hybridActive && hasOverlay) hybridOrders.add(overlayOrder);
7030
7055
  this.hybridSurface.beginFrame(hybridOrders, this.canvasEl.width, this.canvasEl.height);
7031
7056
  for (const [layerId, elements] of this.layerGroups) {
7032
7057
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
@@ -7136,7 +7161,7 @@ var RenderLoop = class {
7136
7161
  hybridCtx.restore();
7137
7162
  }
7138
7163
  const overlayT0 = performance.now();
7139
- if (hybridActive && activeTool?.renderOverlay) {
7164
+ if (hybridActive && hasOverlay) {
7140
7165
  const overlayCtx = this.hybridSurface.getContext(overlayOrder);
7141
7166
  if (overlayCtx) {
7142
7167
  overlayCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
@@ -7144,11 +7169,13 @@ var RenderLoop = class {
7144
7169
  overlayCtx.scale(dpr, dpr);
7145
7170
  overlayCtx.translate(this.camera.position.x, this.camera.position.y);
7146
7171
  overlayCtx.scale(this.camera.zoom, this.camera.zoom);
7147
- activeTool.renderOverlay(overlayCtx);
7172
+ this.drawRegisteredOverlays(overlayCtx);
7173
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(overlayCtx);
7148
7174
  overlayCtx.restore();
7149
7175
  }
7150
- } else if (activeTool?.renderOverlay) {
7151
- activeTool.renderOverlay(ctx);
7176
+ } else if (hasOverlay) {
7177
+ this.drawRegisteredOverlays(ctx);
7178
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(ctx);
7152
7179
  }
7153
7180
  const overlayMs = performance.now() - overlayT0;
7154
7181
  ctx.restore();
@@ -8142,6 +8169,17 @@ var Viewport = class {
8142
8169
  requestRender() {
8143
8170
  this.renderLoop.requestRender();
8144
8171
  }
8172
+ /**
8173
+ * Registers a world-space overlay drawn above elements on every frame,
8174
+ * regardless of the active tool — the surface for remote presence visuals
8175
+ * such as laser trails, cursors, pings, and shared rulers. Overlays draw
8176
+ * beneath the active tool's own `renderOverlay` and never touch elements,
8177
+ * history, or persisted state. Returns an idempotent unsubscribe that also
8178
+ * erases the overlay's last frame.
8179
+ */
8180
+ registerOverlay(draw) {
8181
+ return this.renderLoop.registerOverlay(draw);
8182
+ }
8145
8183
  exportState() {
8146
8184
  return exportState(
8147
8185
  this.store.snapshot(),
@@ -8489,6 +8527,324 @@ var Viewport = class {
8489
8527
  }
8490
8528
  };
8491
8529
 
8530
+ // src/canvas/remote-laser-overlay.ts
8531
+ var LASER_TRAIL_PRESENCE_KIND = "laser";
8532
+ function isFinitePoint(value) {
8533
+ if (typeof value !== "object" || value === null) return false;
8534
+ const point = value;
8535
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
8536
+ }
8537
+ function isLaserTrailPresence(data) {
8538
+ if (typeof data !== "object" || data === null) return false;
8539
+ const payload = data;
8540
+ if (payload.kind !== LASER_TRAIL_PRESENCE_KIND) return false;
8541
+ if (!Array.isArray(payload.points) || !payload.points.every(isFinitePoint)) return false;
8542
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
8543
+ if (payload.width !== void 0 && !(typeof payload.width === "number" && payload.width > 0)) {
8544
+ return false;
8545
+ }
8546
+ if (payload.fadeMs !== void 0 && !(typeof payload.fadeMs === "number" && payload.fadeMs > 0)) {
8547
+ return false;
8548
+ }
8549
+ return true;
8550
+ }
8551
+ function toLaserTrailPresence(emission) {
8552
+ return {
8553
+ kind: LASER_TRAIL_PRESENCE_KIND,
8554
+ points: emission.points.map((point) => ({ x: point.x, y: point.y })),
8555
+ color: emission.color,
8556
+ width: emission.width,
8557
+ fadeMs: emission.fadeMs
8558
+ };
8559
+ }
8560
+ var DEFAULT_COLOR = "#ff3b30";
8561
+ var DEFAULT_WIDTH = 4;
8562
+ var DEFAULT_FADE_MS = 1200;
8563
+ var DEFAULT_MAX_POINTS = 512;
8564
+ var RemoteLaserOverlay = class {
8565
+ host;
8566
+ color;
8567
+ width;
8568
+ fadeMs;
8569
+ maxPointsPerSender;
8570
+ trails = /* @__PURE__ */ new Map();
8571
+ unregister;
8572
+ rafId = null;
8573
+ disposed = false;
8574
+ constructor(host, options = {}) {
8575
+ this.host = host;
8576
+ this.color = options.color ?? DEFAULT_COLOR;
8577
+ this.width = options.width ?? DEFAULT_WIDTH;
8578
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
8579
+ this.maxPointsPerSender = options.maxPointsPerSender ?? DEFAULT_MAX_POINTS;
8580
+ this.unregister = host.registerOverlay((ctx) => this.renderTrails(ctx));
8581
+ }
8582
+ now() {
8583
+ return performance.now();
8584
+ }
8585
+ /**
8586
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
8587
+ * the envelope `from`). Non-laser or malformed payloads are ignored and
8588
+ * reported as `false`, so hosts can feed every presence frame through.
8589
+ */
8590
+ apply(sender, data) {
8591
+ if (this.disposed || !isLaserTrailPresence(data)) return false;
8592
+ let trail = this.trails.get(sender);
8593
+ if (!trail) {
8594
+ trail = { points: [], color: this.color, width: this.width, fadeMs: this.fadeMs };
8595
+ this.trails.set(sender, trail);
8596
+ }
8597
+ trail.color = data.color ?? this.color;
8598
+ trail.width = data.width ?? this.width;
8599
+ trail.fadeMs = data.fadeMs ?? this.fadeMs;
8600
+ const t = this.now();
8601
+ for (const point of data.points) {
8602
+ trail.points.push({ x: point.x, y: point.y, t });
8603
+ }
8604
+ const excess = trail.points.length - this.maxPointsPerSender;
8605
+ if (excess > 0) trail.points.splice(0, excess);
8606
+ this.ensureAnimating();
8607
+ this.host.requestRender();
8608
+ return true;
8609
+ }
8610
+ /** Removes a sender's trail immediately (presence-leave/disconnect). */
8611
+ remove(sender) {
8612
+ if (this.trails.delete(sender)) this.host.requestRender();
8613
+ }
8614
+ /** Removes every trail immediately. */
8615
+ clear() {
8616
+ if (this.trails.size === 0) return;
8617
+ this.trails.clear();
8618
+ this.host.requestRender();
8619
+ }
8620
+ /** Number of senders with a live (unfaded) trail. */
8621
+ get activeSenderCount() {
8622
+ return this.trails.size;
8623
+ }
8624
+ /** Unregisters the overlay and stops the fade loop. Idempotent. */
8625
+ dispose() {
8626
+ if (this.disposed) return;
8627
+ this.disposed = true;
8628
+ if (this.rafId !== null) {
8629
+ cancelAnimationFrame(this.rafId);
8630
+ this.rafId = null;
8631
+ }
8632
+ this.trails.clear();
8633
+ this.unregister?.();
8634
+ this.unregister = null;
8635
+ }
8636
+ ensureAnimating() {
8637
+ if (this.rafId === null) {
8638
+ this.rafId = requestAnimationFrame(() => this.tick());
8639
+ }
8640
+ }
8641
+ tick() {
8642
+ if (this.disposed) return;
8643
+ const now = this.now();
8644
+ for (const [sender, trail] of this.trails) {
8645
+ const cutoff = now - trail.fadeMs;
8646
+ trail.points = trail.points.filter((point) => point.t >= cutoff);
8647
+ if (trail.points.length === 0) this.trails.delete(sender);
8648
+ }
8649
+ this.host.requestRender();
8650
+ this.rafId = this.trails.size > 0 ? requestAnimationFrame(() => this.tick()) : null;
8651
+ }
8652
+ renderTrails(ctx) {
8653
+ if (this.trails.size === 0) return;
8654
+ const now = this.now();
8655
+ for (const trail of this.trails.values()) {
8656
+ if (trail.points.length < 2) continue;
8657
+ ctx.save();
8658
+ ctx.strokeStyle = trail.color;
8659
+ ctx.lineWidth = trail.width;
8660
+ ctx.lineCap = "round";
8661
+ ctx.lineJoin = "round";
8662
+ for (let i = 0; i < trail.points.length - 1; i++) {
8663
+ const a = trail.points[i];
8664
+ const b = trail.points[i + 1];
8665
+ if (!a || !b) continue;
8666
+ const age = now - b.t;
8667
+ ctx.globalAlpha = Math.max(0, 1 - age / trail.fadeMs);
8668
+ ctx.beginPath();
8669
+ ctx.moveTo(a.x, a.y);
8670
+ ctx.lineTo(b.x, b.y);
8671
+ ctx.stroke();
8672
+ }
8673
+ ctx.restore();
8674
+ }
8675
+ }
8676
+ };
8677
+
8678
+ // src/canvas/ping-pulse.ts
8679
+ var RIPPLE_OFFSETS = [0, 0.25];
8680
+ function easeOutCubic(t) {
8681
+ const inv = 1 - t;
8682
+ return 1 - inv * inv * inv;
8683
+ }
8684
+ function renderPingPulse(ctx, x, y, ageMs, style) {
8685
+ if (ageMs < 0 || ageMs >= style.durationMs) return;
8686
+ ctx.save();
8687
+ ctx.strokeStyle = style.color;
8688
+ ctx.fillStyle = style.color;
8689
+ for (const offset of RIPPLE_OFFSETS) {
8690
+ const rippleSpan = style.durationMs * (1 - offset);
8691
+ const rippleAge = ageMs - style.durationMs * offset;
8692
+ if (rippleAge < 0) continue;
8693
+ const progress = Math.min(1, rippleAge / rippleSpan);
8694
+ const rippleRadius = style.radius * easeOutCubic(progress);
8695
+ if (rippleRadius <= 0) continue;
8696
+ ctx.globalAlpha = Math.max(0, 1 - progress);
8697
+ ctx.lineWidth = Math.max(1.5, style.radius / 12);
8698
+ ctx.beginPath();
8699
+ ctx.arc(x, y, rippleRadius, 0, Math.PI * 2);
8700
+ ctx.stroke();
8701
+ }
8702
+ const dotProgress = ageMs / style.durationMs;
8703
+ ctx.globalAlpha = Math.max(0, 1 - Math.max(0, dotProgress - 0.5) * 2);
8704
+ ctx.beginPath();
8705
+ ctx.arc(x, y, Math.max(2, style.radius / 8), 0, Math.PI * 2);
8706
+ ctx.fill();
8707
+ ctx.restore();
8708
+ }
8709
+
8710
+ // src/canvas/remote-ping-overlay.ts
8711
+ var PING_PRESENCE_KIND = "ping";
8712
+ function isPingPresence(data) {
8713
+ if (typeof data !== "object" || data === null) return false;
8714
+ const payload = data;
8715
+ if (payload.kind !== PING_PRESENCE_KIND) return false;
8716
+ if (typeof payload.x !== "number" || !Number.isFinite(payload.x)) return false;
8717
+ if (typeof payload.y !== "number" || !Number.isFinite(payload.y)) return false;
8718
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
8719
+ if (payload.durationMs !== void 0 && !(typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs) && payload.durationMs > 0)) {
8720
+ return false;
8721
+ }
8722
+ if (payload.radius !== void 0 && !(typeof payload.radius === "number" && Number.isFinite(payload.radius) && payload.radius > 0)) {
8723
+ return false;
8724
+ }
8725
+ return true;
8726
+ }
8727
+ function toPingPresence(emission) {
8728
+ return {
8729
+ kind: PING_PRESENCE_KIND,
8730
+ x: emission.x,
8731
+ y: emission.y,
8732
+ color: emission.color,
8733
+ durationMs: emission.durationMs,
8734
+ radius: emission.radius
8735
+ };
8736
+ }
8737
+ var DEFAULT_COLOR2 = "#ff3b30";
8738
+ var DEFAULT_DURATION_MS = 1800;
8739
+ var DEFAULT_RADIUS = 48;
8740
+ var DEFAULT_MAX_PINGS = 8;
8741
+ var RemotePingOverlay = class {
8742
+ host;
8743
+ color;
8744
+ durationMs;
8745
+ radius;
8746
+ maxPingsPerSender;
8747
+ pings = /* @__PURE__ */ new Map();
8748
+ unregister;
8749
+ rafId = null;
8750
+ disposed = false;
8751
+ constructor(host, options = {}) {
8752
+ this.host = host;
8753
+ this.color = options.color ?? DEFAULT_COLOR2;
8754
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS;
8755
+ this.radius = options.radius ?? DEFAULT_RADIUS;
8756
+ this.maxPingsPerSender = options.maxPingsPerSender ?? DEFAULT_MAX_PINGS;
8757
+ this.unregister = host.registerOverlay((ctx) => this.renderPings(ctx));
8758
+ }
8759
+ now() {
8760
+ return performance.now();
8761
+ }
8762
+ /**
8763
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
8764
+ * the envelope `from`). Non-ping or malformed payloads are ignored and
8765
+ * reported as `false`, so hosts can feed every presence frame through.
8766
+ */
8767
+ apply(sender, data) {
8768
+ if (this.disposed || !isPingPresence(data)) return false;
8769
+ let senderPings = this.pings.get(sender);
8770
+ if (!senderPings) {
8771
+ senderPings = [];
8772
+ this.pings.set(sender, senderPings);
8773
+ }
8774
+ senderPings.push({
8775
+ x: data.x,
8776
+ y: data.y,
8777
+ t: this.now(),
8778
+ color: data.color ?? this.color,
8779
+ durationMs: data.durationMs ?? this.durationMs,
8780
+ radius: data.radius ?? this.radius
8781
+ });
8782
+ const excess = senderPings.length - this.maxPingsPerSender;
8783
+ if (excess > 0) senderPings.splice(0, excess);
8784
+ this.ensureAnimating();
8785
+ this.host.requestRender();
8786
+ return true;
8787
+ }
8788
+ /** Removes a sender's pings immediately (presence-leave/disconnect). */
8789
+ remove(sender) {
8790
+ if (this.pings.delete(sender)) this.host.requestRender();
8791
+ }
8792
+ /** Removes every ping immediately. */
8793
+ clear() {
8794
+ if (this.pings.size === 0) return;
8795
+ this.pings.clear();
8796
+ this.host.requestRender();
8797
+ }
8798
+ /** Number of senders with a live (unexpired) ping. */
8799
+ get activeSenderCount() {
8800
+ return this.pings.size;
8801
+ }
8802
+ /** Unregisters the overlay and stops the animation loop. Idempotent. */
8803
+ dispose() {
8804
+ if (this.disposed) return;
8805
+ this.disposed = true;
8806
+ if (this.rafId !== null) {
8807
+ cancelAnimationFrame(this.rafId);
8808
+ this.rafId = null;
8809
+ }
8810
+ this.pings.clear();
8811
+ this.unregister?.();
8812
+ this.unregister = null;
8813
+ }
8814
+ ensureAnimating() {
8815
+ if (this.rafId === null) {
8816
+ this.rafId = requestAnimationFrame(() => this.tick());
8817
+ }
8818
+ }
8819
+ tick() {
8820
+ if (this.disposed) return;
8821
+ const now = this.now();
8822
+ for (const [sender, senderPings] of this.pings) {
8823
+ const live = senderPings.filter((ping) => now - ping.t < ping.durationMs);
8824
+ if (live.length === 0) {
8825
+ this.pings.delete(sender);
8826
+ } else {
8827
+ this.pings.set(sender, live);
8828
+ }
8829
+ }
8830
+ this.host.requestRender();
8831
+ this.rafId = this.pings.size > 0 ? requestAnimationFrame(() => this.tick()) : null;
8832
+ }
8833
+ renderPings(ctx) {
8834
+ if (this.pings.size === 0) return;
8835
+ const now = this.now();
8836
+ for (const senderPings of this.pings.values()) {
8837
+ for (const ping of senderPings) {
8838
+ renderPingPulse(ctx, ping.x, ping.y, now - ping.t, {
8839
+ color: ping.color,
8840
+ durationMs: ping.durationMs,
8841
+ radius: ping.radius
8842
+ });
8843
+ }
8844
+ }
8845
+ }
8846
+ };
8847
+
8492
8848
  // src/tools/hand-tool.ts
8493
8849
  var HandTool = class {
8494
8850
  name = "hand";
@@ -8750,7 +9106,7 @@ function erasePoints(points, eraser, radius) {
8750
9106
  }
8751
9107
 
8752
9108
  // src/tools/eraser-tool.ts
8753
- var DEFAULT_RADIUS = 20;
9109
+ var DEFAULT_RADIUS2 = 20;
8754
9110
  function makeEraserCursor(radius) {
8755
9111
  const size = radius * 2;
8756
9112
  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>`;
@@ -8763,7 +9119,7 @@ var EraserTool = class {
8763
9119
  cursor;
8764
9120
  mode;
8765
9121
  constructor(options = {}) {
8766
- this.radius = options.radius ?? DEFAULT_RADIUS;
9122
+ this.radius = options.radius ?? DEFAULT_RADIUS2;
8767
9123
  this.cursor = makeEraserCursor(this.radius);
8768
9124
  this.mode = options.mode ?? "partial";
8769
9125
  }
@@ -11045,9 +11401,9 @@ var TemplateTool = class {
11045
11401
  };
11046
11402
 
11047
11403
  // src/tools/laser-tool.ts
11048
- var DEFAULT_COLOR = "#ff3b30";
11049
- var DEFAULT_WIDTH = 4;
11050
- var DEFAULT_FADE_MS = 1200;
11404
+ var DEFAULT_COLOR3 = "#ff3b30";
11405
+ var DEFAULT_WIDTH2 = 4;
11406
+ var DEFAULT_FADE_MS2 = 1200;
11051
11407
  var LaserTool = class {
11052
11408
  name;
11053
11409
  color;
@@ -11057,11 +11413,13 @@ var LaserTool = class {
11057
11413
  rafId = null;
11058
11414
  drawing = false;
11059
11415
  optionListeners = /* @__PURE__ */ new Set();
11416
+ trailListeners = /* @__PURE__ */ new Set();
11417
+ pendingEmission = [];
11060
11418
  constructor(options = {}) {
11061
11419
  this.name = options.name ?? "laser";
11062
- this.color = options.color ?? DEFAULT_COLOR;
11063
- this.width = options.width ?? DEFAULT_WIDTH;
11064
- this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
11420
+ this.color = options.color ?? DEFAULT_COLOR3;
11421
+ this.width = options.width ?? DEFAULT_WIDTH2;
11422
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11065
11423
  }
11066
11424
  now() {
11067
11425
  return performance.now();
@@ -11076,6 +11434,7 @@ var LaserTool = class {
11076
11434
  }
11077
11435
  this.trail = [];
11078
11436
  this.drawing = false;
11437
+ this.pendingEmission = [];
11079
11438
  ctx.setCursor?.("default");
11080
11439
  ctx.requestRender();
11081
11440
  }
@@ -11091,6 +11450,16 @@ var LaserTool = class {
11091
11450
  this.optionListeners.add(listener);
11092
11451
  return () => this.optionListeners.delete(listener);
11093
11452
  }
11453
+ /**
11454
+ * Subscribes to coalesced trail emissions. Points recorded by pointer input
11455
+ * are batched and delivered at most once per animation frame, keeping
11456
+ * outgoing presence packets small. Listeners must not throw; a throwing
11457
+ * listener is isolated so it cannot stall the fade loop.
11458
+ */
11459
+ onTrail(listener) {
11460
+ this.trailListeners.add(listener);
11461
+ return () => this.trailListeners.delete(listener);
11462
+ }
11094
11463
  setOptions(options) {
11095
11464
  if (options.color !== void 0) this.color = options.color;
11096
11465
  if (options.width !== void 0) this.width = options.width;
@@ -11099,14 +11468,18 @@ var LaserTool = class {
11099
11468
  }
11100
11469
  onPointerDown(state, ctx) {
11101
11470
  this.drawing = true;
11102
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11103
- this.trail.push({ x: world.x, y: world.y, t: this.now() });
11104
- this.ensureAnimating(ctx);
11471
+ this.recordPoint(state, ctx);
11105
11472
  }
11106
11473
  onPointerMove(state, ctx) {
11107
11474
  if (!this.drawing) return;
11475
+ this.recordPoint(state, ctx);
11476
+ }
11477
+ recordPoint(state, ctx) {
11108
11478
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11109
11479
  this.trail.push({ x: world.x, y: world.y, t: this.now() });
11480
+ if (this.trailListeners.size > 0) {
11481
+ this.pendingEmission.push({ x: world.x, y: world.y });
11482
+ }
11110
11483
  this.ensureAnimating(ctx);
11111
11484
  }
11112
11485
  onPointerUp(_state, _ctx) {
@@ -11138,7 +11511,24 @@ var LaserTool = class {
11138
11511
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11139
11512
  }
11140
11513
  }
11514
+ flushEmission() {
11515
+ if (this.pendingEmission.length === 0) return;
11516
+ const emission = {
11517
+ points: this.pendingEmission,
11518
+ color: this.color,
11519
+ width: this.width,
11520
+ fadeMs: this.fadeMs
11521
+ };
11522
+ this.pendingEmission = [];
11523
+ for (const listener of this.trailListeners) {
11524
+ try {
11525
+ listener(emission);
11526
+ } catch {
11527
+ }
11528
+ }
11529
+ }
11141
11530
  tick(ctx) {
11531
+ this.flushEmission();
11142
11532
  const cutoff = this.now() - this.fadeMs;
11143
11533
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11144
11534
  if (this.trail.length > 0) {
@@ -11154,8 +11544,128 @@ var LaserTool = class {
11154
11544
  }
11155
11545
  };
11156
11546
 
11547
+ // src/tools/ping-tool.ts
11548
+ var DEFAULT_COLOR4 = "#ff3b30";
11549
+ var DEFAULT_DURATION_MS2 = 1800;
11550
+ var DEFAULT_RADIUS3 = 48;
11551
+ var DEFAULT_MIN_INTERVAL_MS = 300;
11552
+ var PingTool = class {
11553
+ name;
11554
+ color;
11555
+ durationMs;
11556
+ radius;
11557
+ minIntervalMs;
11558
+ pings = [];
11559
+ lastEmitAt = -Infinity;
11560
+ rafId = null;
11561
+ optionListeners = /* @__PURE__ */ new Set();
11562
+ pingListeners = /* @__PURE__ */ new Set();
11563
+ constructor(options = {}) {
11564
+ this.name = options.name ?? "ping";
11565
+ this.color = options.color ?? DEFAULT_COLOR4;
11566
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
11567
+ this.radius = options.radius ?? DEFAULT_RADIUS3;
11568
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
11569
+ }
11570
+ now() {
11571
+ return performance.now();
11572
+ }
11573
+ onActivate(ctx) {
11574
+ ctx.setCursor?.("crosshair");
11575
+ }
11576
+ onDeactivate(ctx) {
11577
+ if (this.rafId !== null) {
11578
+ cancelAnimationFrame(this.rafId);
11579
+ this.rafId = null;
11580
+ }
11581
+ this.pings = [];
11582
+ ctx.setCursor?.("default");
11583
+ ctx.requestRender();
11584
+ }
11585
+ getOptions() {
11586
+ return {
11587
+ name: this.name,
11588
+ color: this.color,
11589
+ durationMs: this.durationMs,
11590
+ radius: this.radius,
11591
+ minIntervalMs: this.minIntervalMs
11592
+ };
11593
+ }
11594
+ setOptions(options) {
11595
+ if (options.color !== void 0) this.color = options.color;
11596
+ if (options.durationMs !== void 0) this.durationMs = options.durationMs;
11597
+ if (options.radius !== void 0) this.radius = options.radius;
11598
+ if (options.minIntervalMs !== void 0) this.minIntervalMs = options.minIntervalMs;
11599
+ this.notifyOptionsChange();
11600
+ }
11601
+ onOptionsChange(listener) {
11602
+ this.optionListeners.add(listener);
11603
+ return () => this.optionListeners.delete(listener);
11604
+ }
11605
+ /**
11606
+ * Subscribes to accepted pings. Listeners must not throw; a throwing
11607
+ * listener is isolated so it cannot break the tap handling or other
11608
+ * listeners.
11609
+ */
11610
+ onPing(listener) {
11611
+ this.pingListeners.add(listener);
11612
+ return () => this.pingListeners.delete(listener);
11613
+ }
11614
+ onPointerDown(state, ctx) {
11615
+ const t = this.now();
11616
+ if (t - this.lastEmitAt < this.minIntervalMs) return;
11617
+ this.lastEmitAt = t;
11618
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11619
+ this.pings.push({ x: world.x, y: world.y, t });
11620
+ const emission = {
11621
+ x: world.x,
11622
+ y: world.y,
11623
+ color: this.color,
11624
+ durationMs: this.durationMs,
11625
+ radius: this.radius
11626
+ };
11627
+ for (const listener of this.pingListeners) {
11628
+ try {
11629
+ listener(emission);
11630
+ } catch {
11631
+ }
11632
+ }
11633
+ this.ensureAnimating(ctx);
11634
+ ctx.requestRender();
11635
+ }
11636
+ onPointerMove(_state, _ctx) {
11637
+ }
11638
+ onPointerUp(_state, _ctx) {
11639
+ }
11640
+ renderOverlay(ctx) {
11641
+ if (this.pings.length === 0) return;
11642
+ const now = this.now();
11643
+ for (const ping of this.pings) {
11644
+ renderPingPulse(ctx, ping.x, ping.y, now - ping.t, {
11645
+ color: this.color,
11646
+ durationMs: this.durationMs,
11647
+ radius: this.radius
11648
+ });
11649
+ }
11650
+ }
11651
+ ensureAnimating(ctx) {
11652
+ if (this.rafId === null) {
11653
+ this.rafId = requestAnimationFrame(() => this.tick(ctx));
11654
+ }
11655
+ }
11656
+ tick(ctx) {
11657
+ const cutoff = this.now() - this.durationMs;
11658
+ this.pings = this.pings.filter((ping) => ping.t > cutoff);
11659
+ ctx.requestRender();
11660
+ this.rafId = this.pings.length > 0 ? requestAnimationFrame(() => this.tick(ctx)) : null;
11661
+ }
11662
+ notifyOptionsChange() {
11663
+ for (const listener of this.optionListeners) listener();
11664
+ }
11665
+ };
11666
+
11157
11667
  // src/index.ts
11158
- var VERSION = "0.53.0";
11668
+ var VERSION = "0.55.0";
11159
11669
  export {
11160
11670
  ArrowTool,
11161
11671
  AutoSave,
@@ -11167,13 +11677,18 @@ export {
11167
11677
  HistoryStack,
11168
11678
  ImageTool,
11169
11679
  IndexedDBAdapter,
11680
+ LASER_TRAIL_PRESENCE_KIND,
11170
11681
  LaserTool,
11171
11682
  LayerManager,
11172
11683
  LocalStorageAdapter,
11173
11684
  MeasureTool,
11174
11685
  MemoryAdapter,
11175
11686
  NoteTool,
11687
+ PING_PRESENCE_KIND,
11176
11688
  PencilTool,
11689
+ PingTool,
11690
+ RemoteLaserOverlay,
11691
+ RemotePingOverlay,
11177
11692
  SelectTool,
11178
11693
  ShapeTool,
11179
11694
  TemplateTool,
@@ -11209,12 +11724,16 @@ export {
11209
11724
  getHexCellsInRectangle,
11210
11725
  getHexCellsInSquare,
11211
11726
  getHexDistance,
11727
+ isLaserTrailPresence,
11212
11728
  isNearBezier,
11729
+ isPingPresence,
11213
11730
  setFontSize,
11214
11731
  smartSnap,
11215
11732
  snapPoint,
11216
11733
  snapToHexCenter,
11217
11734
  styleToPatch,
11735
+ toLaserTrailPresence,
11736
+ toPingPresence,
11218
11737
  toggleBold,
11219
11738
  toggleItalic,
11220
11739
  toggleStrikethrough,