@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.cjs CHANGED
@@ -37,8 +37,11 @@ __export(index_exports, {
37
37
  MeasureTool: () => MeasureTool,
38
38
  MemoryAdapter: () => MemoryAdapter,
39
39
  NoteTool: () => NoteTool,
40
+ PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
40
41
  PencilTool: () => PencilTool,
42
+ PingTool: () => PingTool,
41
43
  RemoteLaserOverlay: () => RemoteLaserOverlay,
44
+ RemotePingOverlay: () => RemotePingOverlay,
42
45
  SelectTool: () => SelectTool,
43
46
  ShapeTool: () => ShapeTool,
44
47
  TemplateTool: () => TemplateTool,
@@ -76,12 +79,14 @@ __export(index_exports, {
76
79
  getHexDistance: () => getHexDistance,
77
80
  isLaserTrailPresence: () => isLaserTrailPresence,
78
81
  isNearBezier: () => isNearBezier,
82
+ isPingPresence: () => isPingPresence,
79
83
  setFontSize: () => setFontSize,
80
84
  smartSnap: () => smartSnap,
81
85
  snapPoint: () => snapPoint,
82
86
  snapToHexCenter: () => snapToHexCenter,
83
87
  styleToPatch: () => styleToPatch,
84
88
  toLaserTrailPresence: () => toLaserTrailPresence,
89
+ toPingPresence: () => toPingPresence,
85
90
  toggleBold: () => toggleBold,
86
91
  toggleItalic: () => toggleItalic,
87
92
  toggleStrikethrough: () => toggleStrikethrough,
@@ -8766,6 +8771,176 @@ var RemoteLaserOverlay = class {
8766
8771
  }
8767
8772
  };
8768
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
+
8769
8944
  // src/tools/hand-tool.ts
8770
8945
  var HandTool = class {
8771
8946
  name = "hand";
@@ -9027,7 +9202,7 @@ function erasePoints(points, eraser, radius) {
9027
9202
  }
9028
9203
 
9029
9204
  // src/tools/eraser-tool.ts
9030
- var DEFAULT_RADIUS = 20;
9205
+ var DEFAULT_RADIUS2 = 20;
9031
9206
  function makeEraserCursor(radius) {
9032
9207
  const size = radius * 2;
9033
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>`;
@@ -9040,7 +9215,7 @@ var EraserTool = class {
9040
9215
  cursor;
9041
9216
  mode;
9042
9217
  constructor(options = {}) {
9043
- this.radius = options.radius ?? DEFAULT_RADIUS;
9218
+ this.radius = options.radius ?? DEFAULT_RADIUS2;
9044
9219
  this.cursor = makeEraserCursor(this.radius);
9045
9220
  this.mode = options.mode ?? "partial";
9046
9221
  }
@@ -11322,7 +11497,7 @@ var TemplateTool = class {
11322
11497
  };
11323
11498
 
11324
11499
  // src/tools/laser-tool.ts
11325
- var DEFAULT_COLOR2 = "#ff3b30";
11500
+ var DEFAULT_COLOR3 = "#ff3b30";
11326
11501
  var DEFAULT_WIDTH2 = 4;
11327
11502
  var DEFAULT_FADE_MS2 = 1200;
11328
11503
  var LaserTool = class {
@@ -11338,7 +11513,7 @@ var LaserTool = class {
11338
11513
  pendingEmission = [];
11339
11514
  constructor(options = {}) {
11340
11515
  this.name = options.name ?? "laser";
11341
- this.color = options.color ?? DEFAULT_COLOR2;
11516
+ this.color = options.color ?? DEFAULT_COLOR3;
11342
11517
  this.width = options.width ?? DEFAULT_WIDTH2;
11343
11518
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11344
11519
  }
@@ -11465,8 +11640,128 @@ var LaserTool = class {
11465
11640
  }
11466
11641
  };
11467
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
+
11468
11763
  // src/index.ts
11469
- var VERSION = "0.54.0";
11764
+ var VERSION = "0.55.0";
11470
11765
  // Annotate the CommonJS export names for ESM import in node:
11471
11766
  0 && (module.exports = {
11472
11767
  ArrowTool,
@@ -11486,8 +11781,11 @@ var VERSION = "0.54.0";
11486
11781
  MeasureTool,
11487
11782
  MemoryAdapter,
11488
11783
  NoteTool,
11784
+ PING_PRESENCE_KIND,
11489
11785
  PencilTool,
11786
+ PingTool,
11490
11787
  RemoteLaserOverlay,
11788
+ RemotePingOverlay,
11491
11789
  SelectTool,
11492
11790
  ShapeTool,
11493
11791
  TemplateTool,
@@ -11525,12 +11823,14 @@ var VERSION = "0.54.0";
11525
11823
  getHexDistance,
11526
11824
  isLaserTrailPresence,
11527
11825
  isNearBezier,
11826
+ isPingPresence,
11528
11827
  setFontSize,
11529
11828
  smartSnap,
11530
11829
  snapPoint,
11531
11830
  snapToHexCenter,
11532
11831
  styleToPatch,
11533
11832
  toLaserTrailPresence,
11833
+ toPingPresence,
11534
11834
  toggleBold,
11535
11835
  toggleItalic,
11536
11836
  toggleStrikethrough,