@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.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();
@@ -8097,6 +8124,7 @@ var Viewport = class {
8097
8124
  noteEditor;
8098
8125
  arrowLabelEditor;
8099
8126
  historyRecorder;
8127
+ transactionDepth = 0;
8100
8128
  selectionOps;
8101
8129
  toolContext;
8102
8130
  marginViewport;
@@ -8141,6 +8169,17 @@ var Viewport = class {
8141
8169
  requestRender() {
8142
8170
  this.renderLoop.requestRender();
8143
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
+ }
8144
8183
  exportState() {
8145
8184
  return exportState(
8146
8185
  this.store.snapshot(),
@@ -8210,6 +8249,35 @@ var Viewport = class {
8210
8249
  get shortcuts() {
8211
8250
  return this.inputHandler.shortcuts;
8212
8251
  }
8252
+ /**
8253
+ * Groups synchronous local store and layer mutations into one undo step.
8254
+ * Nested calls join the outer transaction. If the callback throws, mutations
8255
+ * already applied remain undoable and the original error is rethrown.
8256
+ */
8257
+ transaction(operation) {
8258
+ const isOuterTransaction = this.transactionDepth === 0;
8259
+ if (isOuterTransaction) {
8260
+ this.inputHandler.flushPendingHistory();
8261
+ this.historyRecorder.begin();
8262
+ }
8263
+ this.transactionDepth += 1;
8264
+ try {
8265
+ return operation();
8266
+ } finally {
8267
+ this.transactionDepth -= 1;
8268
+ if (isOuterTransaction) this.historyRecorder.commit();
8269
+ }
8270
+ }
8271
+ /** Removes existing elements as one undoable operation and returns the number removed. */
8272
+ removeElements(ids) {
8273
+ const existingIds = [...new Set(ids)].filter((id) => this.store.getById(id) !== void 0);
8274
+ if (existingIds.length === 0) return 0;
8275
+ this.transaction(() => {
8276
+ for (const id of existingIds) this.store.remove(id);
8277
+ });
8278
+ this.requestRender();
8279
+ return existingIds.length;
8280
+ }
8213
8281
  undo() {
8214
8282
  this.inputHandler.flushPendingHistory();
8215
8283
  this.historyRecorder.pause();
@@ -8459,6 +8527,154 @@ var Viewport = class {
8459
8527
  }
8460
8528
  };
8461
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
+
8462
8678
  // src/tools/hand-tool.ts
8463
8679
  var HandTool = class {
8464
8680
  name = "hand";
@@ -11015,9 +11231,9 @@ var TemplateTool = class {
11015
11231
  };
11016
11232
 
11017
11233
  // src/tools/laser-tool.ts
11018
- var DEFAULT_COLOR = "#ff3b30";
11019
- var DEFAULT_WIDTH = 4;
11020
- var DEFAULT_FADE_MS = 1200;
11234
+ var DEFAULT_COLOR2 = "#ff3b30";
11235
+ var DEFAULT_WIDTH2 = 4;
11236
+ var DEFAULT_FADE_MS2 = 1200;
11021
11237
  var LaserTool = class {
11022
11238
  name;
11023
11239
  color;
@@ -11027,11 +11243,13 @@ var LaserTool = class {
11027
11243
  rafId = null;
11028
11244
  drawing = false;
11029
11245
  optionListeners = /* @__PURE__ */ new Set();
11246
+ trailListeners = /* @__PURE__ */ new Set();
11247
+ pendingEmission = [];
11030
11248
  constructor(options = {}) {
11031
11249
  this.name = options.name ?? "laser";
11032
- this.color = options.color ?? DEFAULT_COLOR;
11033
- this.width = options.width ?? DEFAULT_WIDTH;
11034
- 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;
11035
11253
  }
11036
11254
  now() {
11037
11255
  return performance.now();
@@ -11046,6 +11264,7 @@ var LaserTool = class {
11046
11264
  }
11047
11265
  this.trail = [];
11048
11266
  this.drawing = false;
11267
+ this.pendingEmission = [];
11049
11268
  ctx.setCursor?.("default");
11050
11269
  ctx.requestRender();
11051
11270
  }
@@ -11061,6 +11280,16 @@ var LaserTool = class {
11061
11280
  this.optionListeners.add(listener);
11062
11281
  return () => this.optionListeners.delete(listener);
11063
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
+ }
11064
11293
  setOptions(options) {
11065
11294
  if (options.color !== void 0) this.color = options.color;
11066
11295
  if (options.width !== void 0) this.width = options.width;
@@ -11069,14 +11298,18 @@ var LaserTool = class {
11069
11298
  }
11070
11299
  onPointerDown(state, ctx) {
11071
11300
  this.drawing = true;
11072
- const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11073
- this.trail.push({ x: world.x, y: world.y, t: this.now() });
11074
- this.ensureAnimating(ctx);
11301
+ this.recordPoint(state, ctx);
11075
11302
  }
11076
11303
  onPointerMove(state, ctx) {
11077
11304
  if (!this.drawing) return;
11305
+ this.recordPoint(state, ctx);
11306
+ }
11307
+ recordPoint(state, ctx) {
11078
11308
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11079
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
+ }
11080
11313
  this.ensureAnimating(ctx);
11081
11314
  }
11082
11315
  onPointerUp(_state, _ctx) {
@@ -11108,7 +11341,24 @@ var LaserTool = class {
11108
11341
  this.rafId = requestAnimationFrame(() => this.tick(ctx));
11109
11342
  }
11110
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
+ }
11111
11360
  tick(ctx) {
11361
+ this.flushEmission();
11112
11362
  const cutoff = this.now() - this.fadeMs;
11113
11363
  this.trail = this.trail.filter((p) => p.t >= cutoff);
11114
11364
  if (this.trail.length > 0) {
@@ -11125,7 +11375,7 @@ var LaserTool = class {
11125
11375
  };
11126
11376
 
11127
11377
  // src/index.ts
11128
- var VERSION = "0.52.2";
11378
+ var VERSION = "0.54.0";
11129
11379
  export {
11130
11380
  ArrowTool,
11131
11381
  AutoSave,
@@ -11137,6 +11387,7 @@ export {
11137
11387
  HistoryStack,
11138
11388
  ImageTool,
11139
11389
  IndexedDBAdapter,
11390
+ LASER_TRAIL_PRESENCE_KIND,
11140
11391
  LaserTool,
11141
11392
  LayerManager,
11142
11393
  LocalStorageAdapter,
@@ -11144,6 +11395,7 @@ export {
11144
11395
  MemoryAdapter,
11145
11396
  NoteTool,
11146
11397
  PencilTool,
11398
+ RemoteLaserOverlay,
11147
11399
  SelectTool,
11148
11400
  ShapeTool,
11149
11401
  TemplateTool,
@@ -11179,12 +11431,14 @@ export {
11179
11431
  getHexCellsInRectangle,
11180
11432
  getHexCellsInSquare,
11181
11433
  getHexDistance,
11434
+ isLaserTrailPresence,
11182
11435
  isNearBezier,
11183
11436
  setFontSize,
11184
11437
  smartSnap,
11185
11438
  snapPoint,
11186
11439
  snapToHexCenter,
11187
11440
  styleToPatch,
11441
+ toLaserTrailPresence,
11188
11442
  toggleBold,
11189
11443
  toggleItalic,
11190
11444
  toggleStrikethrough,