@fieldnotes/core 0.52.2 → 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();
@@ -8184,6 +8215,7 @@ var Viewport = class {
8184
8215
  noteEditor;
8185
8216
  arrowLabelEditor;
8186
8217
  historyRecorder;
8218
+ transactionDepth = 0;
8187
8219
  selectionOps;
8188
8220
  toolContext;
8189
8221
  marginViewport;
@@ -8228,6 +8260,17 @@ var Viewport = class {
8228
8260
  requestRender() {
8229
8261
  this.renderLoop.requestRender();
8230
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
+ }
8231
8274
  exportState() {
8232
8275
  return exportState(
8233
8276
  this.store.snapshot(),
@@ -8297,6 +8340,35 @@ var Viewport = class {
8297
8340
  get shortcuts() {
8298
8341
  return this.inputHandler.shortcuts;
8299
8342
  }
8343
+ /**
8344
+ * Groups synchronous local store and layer mutations into one undo step.
8345
+ * Nested calls join the outer transaction. If the callback throws, mutations
8346
+ * already applied remain undoable and the original error is rethrown.
8347
+ */
8348
+ transaction(operation) {
8349
+ const isOuterTransaction = this.transactionDepth === 0;
8350
+ if (isOuterTransaction) {
8351
+ this.inputHandler.flushPendingHistory();
8352
+ this.historyRecorder.begin();
8353
+ }
8354
+ this.transactionDepth += 1;
8355
+ try {
8356
+ return operation();
8357
+ } finally {
8358
+ this.transactionDepth -= 1;
8359
+ if (isOuterTransaction) this.historyRecorder.commit();
8360
+ }
8361
+ }
8362
+ /** Removes existing elements as one undoable operation and returns the number removed. */
8363
+ removeElements(ids) {
8364
+ const existingIds = [...new Set(ids)].filter((id) => this.store.getById(id) !== void 0);
8365
+ if (existingIds.length === 0) return 0;
8366
+ this.transaction(() => {
8367
+ for (const id of existingIds) this.store.remove(id);
8368
+ });
8369
+ this.requestRender();
8370
+ return existingIds.length;
8371
+ }
8300
8372
  undo() {
8301
8373
  this.inputHandler.flushPendingHistory();
8302
8374
  this.historyRecorder.pause();
@@ -8546,6 +8618,154 @@ var Viewport = class {
8546
8618
  }
8547
8619
  };
8548
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
+
8549
8769
  // src/tools/hand-tool.ts
8550
8770
  var HandTool = class {
8551
8771
  name = "hand";
@@ -11102,9 +11322,9 @@ var TemplateTool = class {
11102
11322
  };
11103
11323
 
11104
11324
  // src/tools/laser-tool.ts
11105
- var DEFAULT_COLOR = "#ff3b30";
11106
- var DEFAULT_WIDTH = 4;
11107
- var DEFAULT_FADE_MS = 1200;
11325
+ var DEFAULT_COLOR2 = "#ff3b30";
11326
+ var DEFAULT_WIDTH2 = 4;
11327
+ var DEFAULT_FADE_MS2 = 1200;
11108
11328
  var LaserTool = class {
11109
11329
  name;
11110
11330
  color;
@@ -11114,11 +11334,13 @@ var LaserTool = class {
11114
11334
  rafId = null;
11115
11335
  drawing = false;
11116
11336
  optionListeners = /* @__PURE__ */ new Set();
11337
+ trailListeners = /* @__PURE__ */ new Set();
11338
+ pendingEmission = [];
11117
11339
  constructor(options = {}) {
11118
11340
  this.name = options.name ?? "laser";
11119
- this.color = options.color ?? DEFAULT_COLOR;
11120
- this.width = options.width ?? DEFAULT_WIDTH;
11121
- 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;
11122
11344
  }
11123
11345
  now() {
11124
11346
  return performance.now();
@@ -11133,6 +11355,7 @@ var LaserTool = class {
11133
11355
  }
11134
11356
  this.trail = [];
11135
11357
  this.drawing = false;
11358
+ this.pendingEmission = [];
11136
11359
  ctx.setCursor?.("default");
11137
11360
  ctx.requestRender();
11138
11361
  }
@@ -11148,6 +11371,16 @@ var LaserTool = class {
11148
11371
  this.optionListeners.add(listener);
11149
11372
  return () => this.optionListeners.delete(listener);
11150
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
+ }
11151
11384
  setOptions(options) {
11152
11385
  if (options.color !== void 0) this.color = options.color;
11153
11386
  if (options.width !== void 0) this.width = options.width;
@@ -11156,14 +11389,18 @@ var LaserTool = class {
11156
11389
  }
11157
11390
  onPointerDown(state, ctx) {
11158
11391
  this.drawing = true;
11159
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11160
- this.trail.push({ x: world.x, y: world.y, t: this.now() });
11161
- this.ensureAnimating(ctx);
11392
+ this.recordPoint(state, ctx);
11162
11393
  }
11163
11394
  onPointerMove(state, ctx) {
11164
11395
  if (!this.drawing) return;
11396
+ this.recordPoint(state, ctx);
11397
+ }
11398
+ recordPoint(state, ctx) {
11165
11399
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11166
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
+ }
11167
11404
  this.ensureAnimating(ctx);
11168
11405
  }
11169
11406
  onPointerUp(_state, _ctx) {
@@ -11195,7 +11432,24 @@ var LaserTool = class {
11195
11432
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11196
11433
  }
11197
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
+ }
11198
11451
  tick(ctx) {
11452
+ this.flushEmission();
11199
11453
  const cutoff = this.now() - this.fadeMs;
11200
11454
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11201
11455
  if (this.trail.length > 0) {
@@ -11212,7 +11466,7 @@ var LaserTool = class {
11212
11466
  };
11213
11467
 
11214
11468
  // src/index.ts
11215
- var VERSION = "0.52.2";
11469
+ var VERSION = "0.54.0";
11216
11470
  // Annotate the CommonJS export names for ESM import in node:
11217
11471
  0 && (module.exports = {
11218
11472
  ArrowTool,
@@ -11225,6 +11479,7 @@ var VERSION = "0.52.2";
11225
11479
  HistoryStack,
11226
11480
  ImageTool,
11227
11481
  IndexedDBAdapter,
11482
+ LASER_TRAIL_PRESENCE_KIND,
11228
11483
  LaserTool,
11229
11484
  LayerManager,
11230
11485
  LocalStorageAdapter,
@@ -11232,6 +11487,7 @@ var VERSION = "0.52.2";
11232
11487
  MemoryAdapter,
11233
11488
  NoteTool,
11234
11489
  PencilTool,
11490
+ RemoteLaserOverlay,
11235
11491
  SelectTool,
11236
11492
  ShapeTool,
11237
11493
  TemplateTool,
@@ -11267,12 +11523,14 @@ var VERSION = "0.52.2";
11267
11523
  getHexCellsInRectangle,
11268
11524
  getHexCellsInSquare,
11269
11525
  getHexDistance,
11526
+ isLaserTrailPresence,
11270
11527
  isNearBezier,
11271
11528
  setFontSize,
11272
11529
  smartSnap,
11273
11530
  snapPoint,
11274
11531
  snapToHexCenter,
11275
11532
  styleToPatch,
11533
+ toLaserTrailPresence,
11276
11534
  toggleBold,
11277
11535
  toggleItalic,
11278
11536
  toggleStrikethrough,