@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.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,154 @@ 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
+
8492
8678
  // src/tools/hand-tool.ts
8493
8679
  var HandTool = class {
8494
8680
  name = "hand";
@@ -11045,9 +11231,9 @@ var TemplateTool = class {
11045
11231
  };
11046
11232
 
11047
11233
  // src/tools/laser-tool.ts
11048
- var DEFAULT_COLOR = "#ff3b30";
11049
- var DEFAULT_WIDTH = 4;
11050
- var DEFAULT_FADE_MS = 1200;
11234
+ var DEFAULT_COLOR2 = "#ff3b30";
11235
+ var DEFAULT_WIDTH2 = 4;
11236
+ var DEFAULT_FADE_MS2 = 1200;
11051
11237
  var LaserTool = class {
11052
11238
  name;
11053
11239
  color;
@@ -11057,11 +11243,13 @@ var LaserTool = class {
11057
11243
  rafId = null;
11058
11244
  drawing = false;
11059
11245
  optionListeners = /* @__PURE__ */ new Set();
11246
+ trailListeners = /* @__PURE__ */ new Set();
11247
+ pendingEmission = [];
11060
11248
  constructor(options = {}) {
11061
11249
  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;
11250
+ this.color = options.color ?? DEFAULT_COLOR2;
11251
+ this.width = options.width ?? DEFAULT_WIDTH2;
11252
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11065
11253
  }
11066
11254
  now() {
11067
11255
  return performance.now();
@@ -11076,6 +11264,7 @@ var LaserTool = class {
11076
11264
  }
11077
11265
  this.trail = [];
11078
11266
  this.drawing = false;
11267
+ this.pendingEmission = [];
11079
11268
  ctx.setCursor?.("default");
11080
11269
  ctx.requestRender();
11081
11270
  }
@@ -11091,6 +11280,16 @@ var LaserTool = class {
11091
11280
  this.optionListeners.add(listener);
11092
11281
  return () => this.optionListeners.delete(listener);
11093
11282
  }
11283
+ /**
11284
+ * Subscribes to coalesced trail emissions. Points recorded by pointer input
11285
+ * are batched and delivered at most once per animation frame, keeping
11286
+ * outgoing presence packets small. Listeners must not throw; a throwing
11287
+ * listener is isolated so it cannot stall the fade loop.
11288
+ */
11289
+ onTrail(listener) {
11290
+ this.trailListeners.add(listener);
11291
+ return () => this.trailListeners.delete(listener);
11292
+ }
11094
11293
  setOptions(options) {
11095
11294
  if (options.color !== void 0) this.color = options.color;
11096
11295
  if (options.width !== void 0) this.width = options.width;
@@ -11099,14 +11298,18 @@ var LaserTool = class {
11099
11298
  }
11100
11299
  onPointerDown(state, ctx) {
11101
11300
  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);
11301
+ this.recordPoint(state, ctx);
11105
11302
  }
11106
11303
  onPointerMove(state, ctx) {
11107
11304
  if (!this.drawing) return;
11305
+ this.recordPoint(state, ctx);
11306
+ }
11307
+ recordPoint(state, ctx) {
11108
11308
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11109
11309
  this.trail.push({ x: world.x, y: world.y, t: this.now() });
11310
+ if (this.trailListeners.size > 0) {
11311
+ this.pendingEmission.push({ x: world.x, y: world.y });
11312
+ }
11110
11313
  this.ensureAnimating(ctx);
11111
11314
  }
11112
11315
  onPointerUp(_state, _ctx) {
@@ -11138,7 +11341,24 @@ var LaserTool = class {
11138
11341
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11139
11342
  }
11140
11343
  }
11344
+ flushEmission() {
11345
+ if (this.pendingEmission.length === 0) return;
11346
+ const emission = {
11347
+ points: this.pendingEmission,
11348
+ color: this.color,
11349
+ width: this.width,
11350
+ fadeMs: this.fadeMs
11351
+ };
11352
+ this.pendingEmission = [];
11353
+ for (const listener of this.trailListeners) {
11354
+ try {
11355
+ listener(emission);
11356
+ } catch {
11357
+ }
11358
+ }
11359
+ }
11141
11360
  tick(ctx) {
11361
+ this.flushEmission();
11142
11362
  const cutoff = this.now() - this.fadeMs;
11143
11363
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11144
11364
  if (this.trail.length > 0) {
@@ -11155,7 +11375,7 @@ var LaserTool = class {
11155
11375
  };
11156
11376
 
11157
11377
  // src/index.ts
11158
- var VERSION = "0.53.0";
11378
+ var VERSION = "0.54.0";
11159
11379
  export {
11160
11380
  ArrowTool,
11161
11381
  AutoSave,
@@ -11167,6 +11387,7 @@ export {
11167
11387
  HistoryStack,
11168
11388
  ImageTool,
11169
11389
  IndexedDBAdapter,
11390
+ LASER_TRAIL_PRESENCE_KIND,
11170
11391
  LaserTool,
11171
11392
  LayerManager,
11172
11393
  LocalStorageAdapter,
@@ -11174,6 +11395,7 @@ export {
11174
11395
  MemoryAdapter,
11175
11396
  NoteTool,
11176
11397
  PencilTool,
11398
+ RemoteLaserOverlay,
11177
11399
  SelectTool,
11178
11400
  ShapeTool,
11179
11401
  TemplateTool,
@@ -11209,12 +11431,14 @@ export {
11209
11431
  getHexCellsInRectangle,
11210
11432
  getHexCellsInSquare,
11211
11433
  getHexDistance,
11434
+ isLaserTrailPresence,
11212
11435
  isNearBezier,
11213
11436
  setFontSize,
11214
11437
  smartSnap,
11215
11438
  snapPoint,
11216
11439
  snapToHexCenter,
11217
11440
  styleToPatch,
11441
+ toLaserTrailPresence,
11218
11442
  toggleBold,
11219
11443
  toggleItalic,
11220
11444
  toggleStrikethrough,