@fieldnotes/core 0.54.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
@@ -8675,6 +8675,176 @@ var RemoteLaserOverlay = class {
8675
8675
  }
8676
8676
  };
8677
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
+
8678
8848
  // src/tools/hand-tool.ts
8679
8849
  var HandTool = class {
8680
8850
  name = "hand";
@@ -8936,7 +9106,7 @@ function erasePoints(points, eraser, radius) {
8936
9106
  }
8937
9107
 
8938
9108
  // src/tools/eraser-tool.ts
8939
- var DEFAULT_RADIUS = 20;
9109
+ var DEFAULT_RADIUS2 = 20;
8940
9110
  function makeEraserCursor(radius) {
8941
9111
  const size = radius * 2;
8942
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>`;
@@ -8949,7 +9119,7 @@ var EraserTool = class {
8949
9119
  cursor;
8950
9120
  mode;
8951
9121
  constructor(options = {}) {
8952
- this.radius = options.radius ?? DEFAULT_RADIUS;
9122
+ this.radius = options.radius ?? DEFAULT_RADIUS2;
8953
9123
  this.cursor = makeEraserCursor(this.radius);
8954
9124
  this.mode = options.mode ?? "partial";
8955
9125
  }
@@ -11231,7 +11401,7 @@ var TemplateTool = class {
11231
11401
  };
11232
11402
 
11233
11403
  // src/tools/laser-tool.ts
11234
- var DEFAULT_COLOR2 = "#ff3b30";
11404
+ var DEFAULT_COLOR3 = "#ff3b30";
11235
11405
  var DEFAULT_WIDTH2 = 4;
11236
11406
  var DEFAULT_FADE_MS2 = 1200;
11237
11407
  var LaserTool = class {
@@ -11247,7 +11417,7 @@ var LaserTool = class {
11247
11417
  pendingEmission = [];
11248
11418
  constructor(options = {}) {
11249
11419
  this.name = options.name ?? "laser";
11250
- this.color = options.color ?? DEFAULT_COLOR2;
11420
+ this.color = options.color ?? DEFAULT_COLOR3;
11251
11421
  this.width = options.width ?? DEFAULT_WIDTH2;
11252
11422
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11253
11423
  }
@@ -11374,8 +11544,128 @@ var LaserTool = class {
11374
11544
  }
11375
11545
  };
11376
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
+
11377
11667
  // src/index.ts
11378
- var VERSION = "0.54.0";
11668
+ var VERSION = "0.55.0";
11379
11669
  export {
11380
11670
  ArrowTool,
11381
11671
  AutoSave,
@@ -11394,8 +11684,11 @@ export {
11394
11684
  MeasureTool,
11395
11685
  MemoryAdapter,
11396
11686
  NoteTool,
11687
+ PING_PRESENCE_KIND,
11397
11688
  PencilTool,
11689
+ PingTool,
11398
11690
  RemoteLaserOverlay,
11691
+ RemotePingOverlay,
11399
11692
  SelectTool,
11400
11693
  ShapeTool,
11401
11694
  TemplateTool,
@@ -11433,12 +11726,14 @@ export {
11433
11726
  getHexDistance,
11434
11727
  isLaserTrailPresence,
11435
11728
  isNearBezier,
11729
+ isPingPresence,
11436
11730
  setFontSize,
11437
11731
  smartSnap,
11438
11732
  snapPoint,
11439
11733
  snapToHexCenter,
11440
11734
  styleToPatch,
11441
11735
  toLaserTrailPresence,
11736
+ toPingPresence,
11442
11737
  toggleBold,
11443
11738
  toggleItalic,
11444
11739
  toggleStrikethrough,