@fieldnotes/core 0.53.0 → 0.54.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,6 +30,7 @@ __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,
@@ -37,6 +38,7 @@ __export(index_exports, {
37
38
  MemoryAdapter: () => MemoryAdapter,
38
39
  NoteTool: () => NoteTool,
39
40
  PencilTool: () => PencilTool,
41
+ RemoteLaserOverlay: () => RemoteLaserOverlay,
40
42
  SelectTool: () => SelectTool,
41
43
  ShapeTool: () => ShapeTool,
42
44
  TemplateTool: () => TemplateTool,
@@ -72,12 +74,14 @@ __export(index_exports, {
72
74
  getHexCellsInRectangle: () => getHexCellsInRectangle,
73
75
  getHexCellsInSquare: () => getHexCellsInSquare,
74
76
  getHexDistance: () => getHexDistance,
77
+ isLaserTrailPresence: () => isLaserTrailPresence,
75
78
  isNearBezier: () => isNearBezier,
76
79
  setFontSize: () => setFontSize,
77
80
  smartSnap: () => smartSnap,
78
81
  snapPoint: () => snapPoint,
79
82
  snapToHexCenter: () => snapToHexCenter,
80
83
  styleToPatch: () => styleToPatch,
84
+ toLaserTrailPresence: () => toLaserTrailPresence,
81
85
  toggleBold: () => toggleBold,
82
86
  toggleItalic: () => toggleItalic,
83
87
  toggleStrikethrough: () => toggleStrikethrough,
@@ -6922,6 +6926,7 @@ var RenderLoop = class {
6922
6926
  // set on recenter/viewport-change; consumed by the grid block
6923
6927
  stats = new RenderStats();
6924
6928
  layerGroups = /* @__PURE__ */ new Map();
6929
+ overlays = /* @__PURE__ */ new Set();
6925
6930
  gridCacheCanvas = null;
6926
6931
  gridCacheCtx = null;
6927
6932
  lastGridRefs = [];
@@ -6980,6 +6985,29 @@ var RenderLoop = class {
6980
6985
  getStats() {
6981
6986
  return this.stats.getSnapshot();
6982
6987
  }
6988
+ /**
6989
+ * Registers a viewport overlay drawn beneath the active tool's own overlay.
6990
+ * Returns an idempotent unsubscribe that also erases the overlay's last
6991
+ * frame.
6992
+ */
6993
+ registerOverlay(draw) {
6994
+ this.overlays.add(draw);
6995
+ this.requestRender();
6996
+ return () => {
6997
+ if (this.overlays.delete(draw)) this.requestRender();
6998
+ };
6999
+ }
7000
+ drawRegisteredOverlays(ctx) {
7001
+ for (const draw of this.overlays) {
7002
+ ctx.save();
7003
+ try {
7004
+ draw(ctx);
7005
+ } catch {
7006
+ } finally {
7007
+ ctx.restore();
7008
+ }
7009
+ }
7010
+ }
6983
7011
  compositeLayerCache(ctx, layerId, opacity) {
6984
7012
  const cached = this.layerCache.getCanvas(layerId);
6985
7013
  const offset = this.marginViewport.compositeOffset(
@@ -7113,7 +7141,8 @@ var RenderLoop = class {
7113
7141
  }
7114
7142
  const activeTool = this.toolManager.activeTool;
7115
7143
  const overlayOrder = visibleElements.length + 1;
7116
- if (hybridActive && activeTool?.renderOverlay) hybridOrders.add(overlayOrder);
7144
+ const hasOverlay = activeTool?.renderOverlay !== void 0 || this.overlays.size > 0;
7145
+ if (hybridActive && hasOverlay) hybridOrders.add(overlayOrder);
7117
7146
  this.hybridSurface.beginFrame(hybridOrders, this.canvasEl.width, this.canvasEl.height);
7118
7147
  for (const [layerId, elements] of this.layerGroups) {
7119
7148
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
@@ -7223,7 +7252,7 @@ var RenderLoop = class {
7223
7252
  hybridCtx.restore();
7224
7253
  }
7225
7254
  const overlayT0 = performance.now();
7226
- if (hybridActive && activeTool?.renderOverlay) {
7255
+ if (hybridActive && hasOverlay) {
7227
7256
  const overlayCtx = this.hybridSurface.getContext(overlayOrder);
7228
7257
  if (overlayCtx) {
7229
7258
  overlayCtx.clearRect(0, 0, this.canvasEl.width, this.canvasEl.height);
@@ -7231,11 +7260,13 @@ var RenderLoop = class {
7231
7260
  overlayCtx.scale(dpr, dpr);
7232
7261
  overlayCtx.translate(this.camera.position.x, this.camera.position.y);
7233
7262
  overlayCtx.scale(this.camera.zoom, this.camera.zoom);
7234
- activeTool.renderOverlay(overlayCtx);
7263
+ this.drawRegisteredOverlays(overlayCtx);
7264
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(overlayCtx);
7235
7265
  overlayCtx.restore();
7236
7266
  }
7237
- } else if (activeTool?.renderOverlay) {
7238
- activeTool.renderOverlay(ctx);
7267
+ } else if (hasOverlay) {
7268
+ this.drawRegisteredOverlays(ctx);
7269
+ if (activeTool?.renderOverlay) activeTool.renderOverlay(ctx);
7239
7270
  }
7240
7271
  const overlayMs = performance.now() - overlayT0;
7241
7272
  ctx.restore();
@@ -8229,6 +8260,17 @@ var Viewport = class {
8229
8260
  requestRender() {
8230
8261
  this.renderLoop.requestRender();
8231
8262
  }
8263
+ /**
8264
+ * Registers a world-space overlay drawn above elements on every frame,
8265
+ * regardless of the active tool — the surface for remote presence visuals
8266
+ * such as laser trails, cursors, pings, and shared rulers. Overlays draw
8267
+ * beneath the active tool's own `renderOverlay` and never touch elements,
8268
+ * history, or persisted state. Returns an idempotent unsubscribe that also
8269
+ * erases the overlay's last frame.
8270
+ */
8271
+ registerOverlay(draw) {
8272
+ return this.renderLoop.registerOverlay(draw);
8273
+ }
8232
8274
  exportState() {
8233
8275
  return exportState(
8234
8276
  this.store.snapshot(),
@@ -8576,6 +8618,154 @@ var Viewport = class {
8576
8618
  }
8577
8619
  };
8578
8620
 
8621
+ // src/canvas/remote-laser-overlay.ts
8622
+ var LASER_TRAIL_PRESENCE_KIND = "laser";
8623
+ function isFinitePoint(value) {
8624
+ if (typeof value !== "object" || value === null) return false;
8625
+ const point = value;
8626
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
8627
+ }
8628
+ function isLaserTrailPresence(data) {
8629
+ if (typeof data !== "object" || data === null) return false;
8630
+ const payload = data;
8631
+ if (payload.kind !== LASER_TRAIL_PRESENCE_KIND) return false;
8632
+ if (!Array.isArray(payload.points) || !payload.points.every(isFinitePoint)) return false;
8633
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
8634
+ if (payload.width !== void 0 && !(typeof payload.width === "number" && payload.width > 0)) {
8635
+ return false;
8636
+ }
8637
+ if (payload.fadeMs !== void 0 && !(typeof payload.fadeMs === "number" && payload.fadeMs > 0)) {
8638
+ return false;
8639
+ }
8640
+ return true;
8641
+ }
8642
+ function toLaserTrailPresence(emission) {
8643
+ return {
8644
+ kind: LASER_TRAIL_PRESENCE_KIND,
8645
+ points: emission.points.map((point) => ({ x: point.x, y: point.y })),
8646
+ color: emission.color,
8647
+ width: emission.width,
8648
+ fadeMs: emission.fadeMs
8649
+ };
8650
+ }
8651
+ var DEFAULT_COLOR = "#ff3b30";
8652
+ var DEFAULT_WIDTH = 4;
8653
+ var DEFAULT_FADE_MS = 1200;
8654
+ var DEFAULT_MAX_POINTS = 512;
8655
+ var RemoteLaserOverlay = class {
8656
+ host;
8657
+ color;
8658
+ width;
8659
+ fadeMs;
8660
+ maxPointsPerSender;
8661
+ trails = /* @__PURE__ */ new Map();
8662
+ unregister;
8663
+ rafId = null;
8664
+ disposed = false;
8665
+ constructor(host, options = {}) {
8666
+ this.host = host;
8667
+ this.color = options.color ?? DEFAULT_COLOR;
8668
+ this.width = options.width ?? DEFAULT_WIDTH;
8669
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS;
8670
+ this.maxPointsPerSender = options.maxPointsPerSender ?? DEFAULT_MAX_POINTS;
8671
+ this.unregister = host.registerOverlay((ctx) => this.renderTrails(ctx));
8672
+ }
8673
+ now() {
8674
+ return performance.now();
8675
+ }
8676
+ /**
8677
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
8678
+ * the envelope `from`). Non-laser or malformed payloads are ignored and
8679
+ * reported as `false`, so hosts can feed every presence frame through.
8680
+ */
8681
+ apply(sender, data) {
8682
+ if (this.disposed || !isLaserTrailPresence(data)) return false;
8683
+ let trail = this.trails.get(sender);
8684
+ if (!trail) {
8685
+ trail = { points: [], color: this.color, width: this.width, fadeMs: this.fadeMs };
8686
+ this.trails.set(sender, trail);
8687
+ }
8688
+ trail.color = data.color ?? this.color;
8689
+ trail.width = data.width ?? this.width;
8690
+ trail.fadeMs = data.fadeMs ?? this.fadeMs;
8691
+ const t = this.now();
8692
+ for (const point of data.points) {
8693
+ trail.points.push({ x: point.x, y: point.y, t });
8694
+ }
8695
+ const excess = trail.points.length - this.maxPointsPerSender;
8696
+ if (excess > 0) trail.points.splice(0, excess);
8697
+ this.ensureAnimating();
8698
+ this.host.requestRender();
8699
+ return true;
8700
+ }
8701
+ /** Removes a sender's trail immediately (presence-leave/disconnect). */
8702
+ remove(sender) {
8703
+ if (this.trails.delete(sender)) this.host.requestRender();
8704
+ }
8705
+ /** Removes every trail immediately. */
8706
+ clear() {
8707
+ if (this.trails.size === 0) return;
8708
+ this.trails.clear();
8709
+ this.host.requestRender();
8710
+ }
8711
+ /** Number of senders with a live (unfaded) trail. */
8712
+ get activeSenderCount() {
8713
+ return this.trails.size;
8714
+ }
8715
+ /** Unregisters the overlay and stops the fade loop. Idempotent. */
8716
+ dispose() {
8717
+ if (this.disposed) return;
8718
+ this.disposed = true;
8719
+ if (this.rafId !== null) {
8720
+ cancelAnimationFrame(this.rafId);
8721
+ this.rafId = null;
8722
+ }
8723
+ this.trails.clear();
8724
+ this.unregister?.();
8725
+ this.unregister = null;
8726
+ }
8727
+ ensureAnimating() {
8728
+ if (this.rafId === null) {
8729
+ this.rafId = requestAnimationFrame(() => this.tick());
8730
+ }
8731
+ }
8732
+ tick() {
8733
+ if (this.disposed) return;
8734
+ const now = this.now();
8735
+ for (const [sender, trail] of this.trails) {
8736
+ const cutoff = now - trail.fadeMs;
8737
+ trail.points = trail.points.filter((point) => point.t >= cutoff);
8738
+ if (trail.points.length === 0) this.trails.delete(sender);
8739
+ }
8740
+ this.host.requestRender();
8741
+ this.rafId = this.trails.size > 0 ? requestAnimationFrame(() => this.tick()) : null;
8742
+ }
8743
+ renderTrails(ctx) {
8744
+ if (this.trails.size === 0) return;
8745
+ const now = this.now();
8746
+ for (const trail of this.trails.values()) {
8747
+ if (trail.points.length < 2) continue;
8748
+ ctx.save();
8749
+ ctx.strokeStyle = trail.color;
8750
+ ctx.lineWidth = trail.width;
8751
+ ctx.lineCap = "round";
8752
+ ctx.lineJoin = "round";
8753
+ for (let i = 0; i < trail.points.length - 1; i++) {
8754
+ const a = trail.points[i];
8755
+ const b = trail.points[i + 1];
8756
+ if (!a || !b) continue;
8757
+ const age = now - b.t;
8758
+ ctx.globalAlpha = Math.max(0, 1 - age / trail.fadeMs);
8759
+ ctx.beginPath();
8760
+ ctx.moveTo(a.x, a.y);
8761
+ ctx.lineTo(b.x, b.y);
8762
+ ctx.stroke();
8763
+ }
8764
+ ctx.restore();
8765
+ }
8766
+ }
8767
+ };
8768
+
8579
8769
  // src/tools/hand-tool.ts
8580
8770
  var HandTool = class {
8581
8771
  name = "hand";
@@ -11132,9 +11322,9 @@ var TemplateTool = class {
11132
11322
  };
11133
11323
 
11134
11324
  // src/tools/laser-tool.ts
11135
- var DEFAULT_COLOR = "#ff3b30";
11136
- var DEFAULT_WIDTH = 4;
11137
- var DEFAULT_FADE_MS = 1200;
11325
+ var DEFAULT_COLOR2 = "#ff3b30";
11326
+ var DEFAULT_WIDTH2 = 4;
11327
+ var DEFAULT_FADE_MS2 = 1200;
11138
11328
  var LaserTool = class {
11139
11329
  name;
11140
11330
  color;
@@ -11144,11 +11334,13 @@ var LaserTool = class {
11144
11334
  rafId = null;
11145
11335
  drawing = false;
11146
11336
  optionListeners = /* @__PURE__ */ new Set();
11337
+ trailListeners = /* @__PURE__ */ new Set();
11338
+ pendingEmission = [];
11147
11339
  constructor(options = {}) {
11148
11340
  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;
11341
+ this.color = options.color ?? DEFAULT_COLOR2;
11342
+ this.width = options.width ?? DEFAULT_WIDTH2;
11343
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11152
11344
  }
11153
11345
  now() {
11154
11346
  return performance.now();
@@ -11163,6 +11355,7 @@ var LaserTool = class {
11163
11355
  }
11164
11356
  this.trail = [];
11165
11357
  this.drawing = false;
11358
+ this.pendingEmission = [];
11166
11359
  ctx.setCursor?.("default");
11167
11360
  ctx.requestRender();
11168
11361
  }
@@ -11178,6 +11371,16 @@ var LaserTool = class {
11178
11371
  this.optionListeners.add(listener);
11179
11372
  return () => this.optionListeners.delete(listener);
11180
11373
  }
11374
+ /**
11375
+ * Subscribes to coalesced trail emissions. Points recorded by pointer input
11376
+ * are batched and delivered at most once per animation frame, keeping
11377
+ * outgoing presence packets small. Listeners must not throw; a throwing
11378
+ * listener is isolated so it cannot stall the fade loop.
11379
+ */
11380
+ onTrail(listener) {
11381
+ this.trailListeners.add(listener);
11382
+ return () => this.trailListeners.delete(listener);
11383
+ }
11181
11384
  setOptions(options) {
11182
11385
  if (options.color !== void 0) this.color = options.color;
11183
11386
  if (options.width !== void 0) this.width = options.width;
@@ -11186,14 +11389,18 @@ var LaserTool = class {
11186
11389
  }
11187
11390
  onPointerDown(state, ctx) {
11188
11391
  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);
11392
+ this.recordPoint(state, ctx);
11192
11393
  }
11193
11394
  onPointerMove(state, ctx) {
11194
11395
  if (!this.drawing) return;
11396
+ this.recordPoint(state, ctx);
11397
+ }
11398
+ recordPoint(state, ctx) {
11195
11399
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11196
11400
  this.trail.push({ x: world.x, y: world.y, t: this.now() });
11401
+ if (this.trailListeners.size > 0) {
11402
+ this.pendingEmission.push({ x: world.x, y: world.y });
11403
+ }
11197
11404
  this.ensureAnimating(ctx);
11198
11405
  }
11199
11406
  onPointerUp(_state, _ctx) {
@@ -11225,7 +11432,24 @@ var LaserTool = class {
11225
11432
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11226
11433
  }
11227
11434
  }
11435
+ flushEmission() {
11436
+ if (this.pendingEmission.length === 0) return;
11437
+ const emission = {
11438
+ points: this.pendingEmission,
11439
+ color: this.color,
11440
+ width: this.width,
11441
+ fadeMs: this.fadeMs
11442
+ };
11443
+ this.pendingEmission = [];
11444
+ for (const listener of this.trailListeners) {
11445
+ try {
11446
+ listener(emission);
11447
+ } catch {
11448
+ }
11449
+ }
11450
+ }
11228
11451
  tick(ctx) {
11452
+ this.flushEmission();
11229
11453
  const cutoff = this.now() - this.fadeMs;
11230
11454
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11231
11455
  if (this.trail.length > 0) {
@@ -11242,7 +11466,7 @@ var LaserTool = class {
11242
11466
  };
11243
11467
 
11244
11468
  // src/index.ts
11245
- var VERSION = "0.53.0";
11469
+ var VERSION = "0.54.0";
11246
11470
  // Annotate the CommonJS export names for ESM import in node:
11247
11471
  0 && (module.exports = {
11248
11472
  ArrowTool,
@@ -11255,6 +11479,7 @@ var VERSION = "0.53.0";
11255
11479
  HistoryStack,
11256
11480
  ImageTool,
11257
11481
  IndexedDBAdapter,
11482
+ LASER_TRAIL_PRESENCE_KIND,
11258
11483
  LaserTool,
11259
11484
  LayerManager,
11260
11485
  LocalStorageAdapter,
@@ -11262,6 +11487,7 @@ var VERSION = "0.53.0";
11262
11487
  MemoryAdapter,
11263
11488
  NoteTool,
11264
11489
  PencilTool,
11490
+ RemoteLaserOverlay,
11265
11491
  SelectTool,
11266
11492
  ShapeTool,
11267
11493
  TemplateTool,
@@ -11297,12 +11523,14 @@ var VERSION = "0.53.0";
11297
11523
  getHexCellsInRectangle,
11298
11524
  getHexCellsInSquare,
11299
11525
  getHexDistance,
11526
+ isLaserTrailPresence,
11300
11527
  isNearBezier,
11301
11528
  setFontSize,
11302
11529
  smartSnap,
11303
11530
  snapPoint,
11304
11531
  snapToHexCenter,
11305
11532
  styleToPatch,
11533
+ toLaserTrailPresence,
11306
11534
  toggleBold,
11307
11535
  toggleItalic,
11308
11536
  toggleStrikethrough,