@fieldnotes/core 0.55.0 → 0.57.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
@@ -34,13 +34,16 @@ __export(index_exports, {
34
34
  LaserTool: () => LaserTool,
35
35
  LayerManager: () => LayerManager,
36
36
  LocalStorageAdapter: () => LocalStorageAdapter,
37
+ MEASURE_PRESENCE_KIND: () => MEASURE_PRESENCE_KIND,
37
38
  MeasureTool: () => MeasureTool,
38
39
  MemoryAdapter: () => MemoryAdapter,
39
40
  NoteTool: () => NoteTool,
40
41
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
41
42
  PencilTool: () => PencilTool,
43
+ PingInput: () => PingInput,
42
44
  PingTool: () => PingTool,
43
45
  RemoteLaserOverlay: () => RemoteLaserOverlay,
46
+ RemoteMeasureOverlay: () => RemoteMeasureOverlay,
44
47
  RemotePingOverlay: () => RemotePingOverlay,
45
48
  SelectTool: () => SelectTool,
46
49
  ShapeTool: () => ShapeTool,
@@ -78,6 +81,7 @@ __export(index_exports, {
78
81
  getHexCellsInSquare: () => getHexCellsInSquare,
79
82
  getHexDistance: () => getHexDistance,
80
83
  isLaserTrailPresence: () => isLaserTrailPresence,
84
+ isMeasurePresence: () => isMeasurePresence,
81
85
  isNearBezier: () => isNearBezier,
82
86
  isPingPresence: () => isPingPresence,
83
87
  setFontSize: () => setFontSize,
@@ -86,6 +90,7 @@ __export(index_exports, {
86
90
  snapToHexCenter: () => snapToHexCenter,
87
91
  styleToPatch: () => styleToPatch,
88
92
  toLaserTrailPresence: () => toLaserTrailPresence,
93
+ toMeasurePresence: () => toMeasurePresence,
89
94
  toPingPresence: () => toPingPresence,
90
95
  toggleBold: () => toggleBold,
91
96
  toggleItalic: () => toggleItalic,
@@ -8941,6 +8946,399 @@ var RemotePingOverlay = class {
8941
8946
  }
8942
8947
  };
8943
8948
 
8949
+ // src/canvas/measure-render.ts
8950
+ function formatMeasureLabel(feet) {
8951
+ return `${Math.round(feet)} ft`;
8952
+ }
8953
+ function drawMeasurement(ctx, m, opts = {}) {
8954
+ ctx.save();
8955
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
8956
+ ctx.strokeStyle = m.color;
8957
+ ctx.setLineDash([8, 4]);
8958
+ ctx.lineWidth = 2;
8959
+ ctx.beginPath();
8960
+ ctx.moveTo(m.start.x, m.start.y);
8961
+ ctx.lineTo(m.end.x, m.end.y);
8962
+ ctx.stroke();
8963
+ ctx.setLineDash([]);
8964
+ ctx.fillStyle = m.color;
8965
+ const dotRadius = 4;
8966
+ ctx.beginPath();
8967
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
8968
+ ctx.fill();
8969
+ ctx.beginPath();
8970
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
8971
+ ctx.fill();
8972
+ const label = formatMeasureLabel(m.feet);
8973
+ const midX = (m.start.x + m.end.x) / 2;
8974
+ const midY = (m.start.y + m.end.y) / 2;
8975
+ ctx.font = "14px sans-serif";
8976
+ const metrics = ctx.measureText(label);
8977
+ const padX = 6;
8978
+ const padY = 4;
8979
+ const textH = 14;
8980
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
8981
+ ctx.beginPath();
8982
+ ctx.roundRect(
8983
+ midX - metrics.width / 2 - padX,
8984
+ midY - textH / 2 - padY,
8985
+ metrics.width + padX * 2,
8986
+ textH + padY * 2,
8987
+ 4
8988
+ );
8989
+ ctx.fill();
8990
+ ctx.fillStyle = "#FFFFFF";
8991
+ ctx.textAlign = "center";
8992
+ ctx.textBaseline = "middle";
8993
+ ctx.fillText(label, midX, midY);
8994
+ ctx.restore();
8995
+ }
8996
+
8997
+ // src/canvas/remote-measure-overlay.ts
8998
+ var MEASURE_PRESENCE_KIND = "measure";
8999
+ function isFinitePoint2(value) {
9000
+ if (typeof value !== "object" || value === null) return false;
9001
+ const point = value;
9002
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
9003
+ }
9004
+ function isMeasurePresence(data) {
9005
+ if (typeof data !== "object" || data === null) return false;
9006
+ const payload = data;
9007
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
9008
+ if ("cleared" in payload) return payload.cleared === true;
9009
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
9010
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
9011
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
9012
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
9013
+ return true;
9014
+ }
9015
+ function toMeasurePresence(emission) {
9016
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
9017
+ return {
9018
+ kind: MEASURE_PRESENCE_KIND,
9019
+ start: emission.start,
9020
+ end: emission.end,
9021
+ cells: emission.cells,
9022
+ feet: emission.feet,
9023
+ color: emission.color
9024
+ };
9025
+ }
9026
+ var DEFAULT_COLOR3 = "#FF5722";
9027
+ var DEFAULT_HOLD_MS = 1500;
9028
+ var DEFAULT_FADE_MS2 = 400;
9029
+ var DEFAULT_MAX_AGE_MS = 3e4;
9030
+ var RemoteMeasureOverlay = class {
9031
+ host;
9032
+ color;
9033
+ holdMs;
9034
+ fadeMs;
9035
+ maxAgeMs;
9036
+ measurements = /* @__PURE__ */ new Map();
9037
+ unregister;
9038
+ rafId = null;
9039
+ disposed = false;
9040
+ constructor(host, options = {}) {
9041
+ this.host = host;
9042
+ this.color = options.color ?? DEFAULT_COLOR3;
9043
+ this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
9044
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
9045
+ this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
9046
+ this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
9047
+ }
9048
+ now() {
9049
+ return performance.now();
9050
+ }
9051
+ /**
9052
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
9053
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
9054
+ * reported as `false`, so hosts can feed every presence frame through.
9055
+ */
9056
+ apply(sender, data) {
9057
+ if (this.disposed || !isMeasurePresence(data)) return false;
9058
+ if ("cleared" in data) {
9059
+ this.beginLinger(sender);
9060
+ return true;
9061
+ }
9062
+ const existing = this.measurements.get(sender);
9063
+ if (existing?.expiryTimer != null) clearTimeout(existing.expiryTimer);
9064
+ this.measurements.set(sender, {
9065
+ start: data.start,
9066
+ end: data.end,
9067
+ feet: data.feet,
9068
+ color: data.color ?? this.color,
9069
+ clearedAt: null,
9070
+ expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
9071
+ });
9072
+ this.host.requestRender();
9073
+ return true;
9074
+ }
9075
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
9076
+ remove(sender) {
9077
+ const entry = this.measurements.get(sender);
9078
+ if (!entry) return;
9079
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9080
+ this.measurements.delete(sender);
9081
+ this.host.requestRender();
9082
+ }
9083
+ /** Removes every ruler immediately. */
9084
+ clear() {
9085
+ if (this.measurements.size === 0) return;
9086
+ for (const entry of this.measurements.values()) {
9087
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9088
+ }
9089
+ this.measurements.clear();
9090
+ this.host.requestRender();
9091
+ }
9092
+ /** Number of senders with a visible (active or lingering) ruler. */
9093
+ get activeSenderCount() {
9094
+ return this.measurements.size;
9095
+ }
9096
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
9097
+ dispose() {
9098
+ if (this.disposed) return;
9099
+ this.disposed = true;
9100
+ if (this.rafId !== null) {
9101
+ cancelAnimationFrame(this.rafId);
9102
+ this.rafId = null;
9103
+ }
9104
+ for (const entry of this.measurements.values()) {
9105
+ if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
9106
+ }
9107
+ this.measurements.clear();
9108
+ this.unregister?.();
9109
+ this.unregister = null;
9110
+ this.host.requestRender();
9111
+ }
9112
+ beginLinger(sender) {
9113
+ const entry = this.measurements.get(sender);
9114
+ if (!entry || entry.clearedAt !== null) return;
9115
+ if (entry.expiryTimer != null) {
9116
+ clearTimeout(entry.expiryTimer);
9117
+ entry.expiryTimer = null;
9118
+ }
9119
+ entry.clearedAt = this.now();
9120
+ this.ensureAnimating();
9121
+ this.host.requestRender();
9122
+ }
9123
+ ensureAnimating() {
9124
+ if (this.rafId === null) {
9125
+ this.rafId = requestAnimationFrame(() => this.tick());
9126
+ }
9127
+ }
9128
+ tick() {
9129
+ if (this.disposed) return;
9130
+ const now = this.now();
9131
+ let lingering = 0;
9132
+ for (const [sender, entry] of this.measurements) {
9133
+ if (entry.clearedAt === null) continue;
9134
+ if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
9135
+ this.measurements.delete(sender);
9136
+ } else {
9137
+ lingering += 1;
9138
+ }
9139
+ }
9140
+ this.host.requestRender();
9141
+ this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
9142
+ }
9143
+ renderMeasurements(ctx) {
9144
+ if (this.measurements.size === 0) return;
9145
+ const now = this.now();
9146
+ for (const entry of this.measurements.values()) {
9147
+ let alpha = 1;
9148
+ if (entry.clearedAt !== null) {
9149
+ const fadeAge = now - entry.clearedAt - this.holdMs;
9150
+ if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
9151
+ }
9152
+ drawMeasurement(ctx, entry, { alpha });
9153
+ }
9154
+ }
9155
+ };
9156
+
9157
+ // src/canvas/ping-input.ts
9158
+ var DEFAULT_LONG_PRESS_MS = 600;
9159
+ var DEFAULT_SLOP_PX = 8;
9160
+ var DEFAULT_COLOR4 = "#ff3b30";
9161
+ var DEFAULT_DURATION_MS2 = 1800;
9162
+ var DEFAULT_RADIUS2 = 48;
9163
+ var DEFAULT_MIN_INTERVAL_MS = 300;
9164
+ var PingInput = class {
9165
+ element;
9166
+ host;
9167
+ longPressEnabled;
9168
+ longPressMs;
9169
+ slopPx;
9170
+ color;
9171
+ durationMs;
9172
+ radius;
9173
+ minIntervalMs;
9174
+ shouldPing;
9175
+ press = null;
9176
+ downPointers = /* @__PURE__ */ new Set();
9177
+ lastPointerScreen = null;
9178
+ lastEmitAt = -Infinity;
9179
+ disposed = false;
9180
+ optionListeners = /* @__PURE__ */ new Set();
9181
+ pingListeners = /* @__PURE__ */ new Set();
9182
+ handlePointerDown = (e) => this.onPointerDown(e);
9183
+ handlePointerMove = (e) => this.onPointerMove(e);
9184
+ handlePointerUp = (e) => this.onPointerEnd(e);
9185
+ handlePointerCancel = (e) => this.onPointerEnd(e);
9186
+ handlePointerLeave = (e) => this.onPointerEnd(e);
9187
+ constructor(element, host, options = {}) {
9188
+ this.element = element;
9189
+ this.host = host;
9190
+ this.longPressEnabled = options.longPressEnabled ?? false;
9191
+ this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
9192
+ this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
9193
+ this.color = options.color ?? DEFAULT_COLOR4;
9194
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
9195
+ this.radius = options.radius ?? DEFAULT_RADIUS2;
9196
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
9197
+ this.shouldPing = options.shouldPing;
9198
+ const opts = { passive: true };
9199
+ element.addEventListener("pointerdown", this.handlePointerDown, opts);
9200
+ element.addEventListener("pointermove", this.handlePointerMove, opts);
9201
+ element.addEventListener("pointerup", this.handlePointerUp, opts);
9202
+ element.addEventListener("pointercancel", this.handlePointerCancel, opts);
9203
+ element.addEventListener("pointerleave", this.handlePointerLeave, opts);
9204
+ }
9205
+ now() {
9206
+ return performance.now();
9207
+ }
9208
+ getOptions() {
9209
+ return {
9210
+ longPressEnabled: this.longPressEnabled,
9211
+ longPressMs: this.longPressMs,
9212
+ slopPx: this.slopPx,
9213
+ color: this.color,
9214
+ durationMs: this.durationMs,
9215
+ radius: this.radius,
9216
+ minIntervalMs: this.minIntervalMs,
9217
+ ...this.shouldPing ? { shouldPing: this.shouldPing } : {}
9218
+ };
9219
+ }
9220
+ setOptions(options) {
9221
+ if (options.longPressEnabled !== void 0) {
9222
+ this.longPressEnabled = options.longPressEnabled;
9223
+ if (!this.longPressEnabled) this.cancelPress();
9224
+ }
9225
+ if (options.longPressMs !== void 0) this.longPressMs = options.longPressMs;
9226
+ if (options.slopPx !== void 0) this.slopPx = options.slopPx;
9227
+ if (options.color !== void 0) this.color = options.color;
9228
+ if (options.durationMs !== void 0) this.durationMs = options.durationMs;
9229
+ if (options.radius !== void 0) this.radius = options.radius;
9230
+ if (options.minIntervalMs !== void 0) this.minIntervalMs = options.minIntervalMs;
9231
+ if (options.shouldPing !== void 0) this.shouldPing = options.shouldPing;
9232
+ for (const listener of this.optionListeners) listener();
9233
+ }
9234
+ onOptionsChange(listener) {
9235
+ this.optionListeners.add(listener);
9236
+ return () => this.optionListeners.delete(listener);
9237
+ }
9238
+ /**
9239
+ * Subscribes to emitted pings. Listeners must not throw; a throwing
9240
+ * listener is isolated so it cannot break input handling or other
9241
+ * listeners.
9242
+ */
9243
+ onPing(listener) {
9244
+ this.pingListeners.add(listener);
9245
+ return () => this.pingListeners.delete(listener);
9246
+ }
9247
+ /**
9248
+ * Pings at the last tracked pointer position (hover moves and presses),
9249
+ * world-converted at call time. Returns `false` when no pointer has been
9250
+ * seen yet, the host vetoes, or the rate limit drops the ping.
9251
+ */
9252
+ pingAtPointer() {
9253
+ if (this.disposed || this.lastPointerScreen === null) return false;
9254
+ return this.emit(this.host.screenToWorld(this.lastPointerScreen));
9255
+ }
9256
+ /** Pings a world position directly. Same veto and rate limit as every path. */
9257
+ pingAt(world) {
9258
+ if (this.disposed) return false;
9259
+ return this.emit(world);
9260
+ }
9261
+ /** Removes all DOM listeners and cancels any pending press. Idempotent. */
9262
+ dispose() {
9263
+ if (this.disposed) return;
9264
+ this.disposed = true;
9265
+ this.cancelPress();
9266
+ this.downPointers.clear();
9267
+ this.element.removeEventListener("pointerdown", this.handlePointerDown);
9268
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
9269
+ this.element.removeEventListener("pointerup", this.handlePointerUp);
9270
+ this.element.removeEventListener("pointercancel", this.handlePointerCancel);
9271
+ this.element.removeEventListener("pointerleave", this.handlePointerLeave);
9272
+ this.optionListeners.clear();
9273
+ this.pingListeners.clear();
9274
+ }
9275
+ toLocal(e) {
9276
+ const rect = this.element.getBoundingClientRect();
9277
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
9278
+ }
9279
+ onPointerDown(e) {
9280
+ const local = this.toLocal(e);
9281
+ this.lastPointerScreen = local;
9282
+ const hadPointers = this.downPointers.size > 0;
9283
+ this.downPointers.add(e.pointerId);
9284
+ if (hadPointers) {
9285
+ this.cancelPress();
9286
+ return;
9287
+ }
9288
+ if (!this.longPressEnabled) return;
9289
+ if (e.pointerType === "mouse" && e.button !== 0) return;
9290
+ this.press = {
9291
+ pointerId: e.pointerId,
9292
+ x: local.x,
9293
+ y: local.y,
9294
+ timer: setTimeout(() => this.firePress(), this.longPressMs)
9295
+ };
9296
+ }
9297
+ onPointerMove(e) {
9298
+ const local = this.toLocal(e);
9299
+ this.lastPointerScreen = local;
9300
+ if (this.press === null || e.pointerId !== this.press.pointerId) return;
9301
+ const dx = local.x - this.press.x;
9302
+ const dy = local.y - this.press.y;
9303
+ if (Math.hypot(dx, dy) > this.slopPx) this.cancelPress();
9304
+ }
9305
+ onPointerEnd(e) {
9306
+ this.downPointers.delete(e.pointerId);
9307
+ if (this.press !== null && e.pointerId === this.press.pointerId) this.cancelPress();
9308
+ }
9309
+ cancelPress() {
9310
+ if (this.press === null) return;
9311
+ clearTimeout(this.press.timer);
9312
+ this.press = null;
9313
+ }
9314
+ firePress() {
9315
+ if (this.press === null) return;
9316
+ const screen = { x: this.press.x, y: this.press.y };
9317
+ this.press = null;
9318
+ this.emit(this.host.screenToWorld(screen));
9319
+ }
9320
+ emit(world) {
9321
+ if (this.shouldPing && !this.shouldPing()) return false;
9322
+ const t = this.now();
9323
+ if (t - this.lastEmitAt < this.minIntervalMs) return false;
9324
+ this.lastEmitAt = t;
9325
+ const emission = {
9326
+ x: world.x,
9327
+ y: world.y,
9328
+ color: this.color,
9329
+ durationMs: this.durationMs,
9330
+ radius: this.radius
9331
+ };
9332
+ for (const listener of this.pingListeners) {
9333
+ try {
9334
+ listener(emission);
9335
+ } catch {
9336
+ }
9337
+ }
9338
+ return true;
9339
+ }
9340
+ };
9341
+
8944
9342
  // src/tools/hand-tool.ts
8945
9343
  var HandTool = class {
8946
9344
  name = "hand";
@@ -9202,7 +9600,7 @@ function erasePoints(points, eraser, radius) {
9202
9600
  }
9203
9601
 
9204
9602
  // src/tools/eraser-tool.ts
9205
- var DEFAULT_RADIUS2 = 20;
9603
+ var DEFAULT_RADIUS3 = 20;
9206
9604
  function makeEraserCursor(radius) {
9207
9605
  const size = radius * 2;
9208
9606
  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>`;
@@ -9215,7 +9613,7 @@ var EraserTool = class {
9215
9613
  cursor;
9216
9614
  mode;
9217
9615
  constructor(options = {}) {
9218
- this.radius = options.radius ?? DEFAULT_RADIUS2;
9616
+ this.radius = options.radius ?? DEFAULT_RADIUS3;
9219
9617
  this.cursor = makeEraserCursor(this.radius);
9220
9618
  this.mode = options.mode ?? "partial";
9221
9619
  }
@@ -11082,21 +11480,37 @@ var MeasureTool = class {
11082
11480
  gridType;
11083
11481
  hexOrientation;
11084
11482
  feetPerCell;
11483
+ color;
11085
11484
  optionListeners = /* @__PURE__ */ new Set();
11485
+ measurementListeners = /* @__PURE__ */ new Set();
11486
+ emissionRafId = null;
11086
11487
  constructor(options = {}) {
11087
11488
  this.feetPerCell = options.feetPerCell ?? 5;
11489
+ this.color = options.color ?? "#FF5722";
11088
11490
  }
11089
11491
  getOptions() {
11090
- return { feetPerCell: this.feetPerCell };
11492
+ return { feetPerCell: this.feetPerCell, color: this.color };
11091
11493
  }
11092
11494
  setOptions(options) {
11093
11495
  if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
11496
+ if (options.color !== void 0) this.color = options.color;
11094
11497
  this.notifyOptionsChange();
11095
11498
  }
11096
11499
  onOptionsChange(listener) {
11097
11500
  this.optionListeners.add(listener);
11098
11501
  return () => this.optionListeners.delete(listener);
11099
11502
  }
11503
+ /**
11504
+ * Subscribes to raf-coalesced measurement snapshots. While a measurement is
11505
+ * in progress, listeners receive at most one snapshot per animation frame
11506
+ * carrying the latest state; `null` is delivered synchronously when the
11507
+ * measurement clears (pointer-up or deactivate). Emissions are ephemeral by
11508
+ * contract: presence only — never elements, history, or persisted state.
11509
+ */
11510
+ onMeasurement(listener) {
11511
+ this.measurementListeners.add(listener);
11512
+ return () => this.measurementListeners.delete(listener);
11513
+ }
11100
11514
  onPointerDown(state, ctx) {
11101
11515
  this.gridSize = ctx.gridSize ?? 1;
11102
11516
  this.gridType = ctx.gridType;
@@ -11104,22 +11518,27 @@ var MeasureTool = class {
11104
11518
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11105
11519
  this.start = this.snapToGrid(world, ctx);
11106
11520
  this.end = { ...this.start };
11521
+ this.scheduleEmission();
11107
11522
  }
11108
11523
  onPointerMove(state, ctx) {
11109
11524
  if (!this.start) return;
11110
11525
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
11111
11526
  this.end = this.snapToGrid(world, ctx);
11112
11527
  ctx.requestRender();
11528
+ this.scheduleEmission();
11113
11529
  }
11114
11530
  onPointerUp(_state, ctx) {
11115
11531
  if (!this.start) return;
11116
11532
  this.start = null;
11117
11533
  this.end = null;
11118
11534
  ctx.requestRender();
11535
+ this.emitClear();
11119
11536
  }
11120
11537
  onDeactivate(_ctx) {
11538
+ const wasActive = this.start !== null;
11121
11539
  this.start = null;
11122
11540
  this.end = null;
11541
+ if (wasActive) this.emitClear();
11123
11542
  }
11124
11543
  getMeasurement() {
11125
11544
  if (!this.start || !this.end) return null;
@@ -11145,46 +11564,7 @@ var MeasureTool = class {
11145
11564
  renderOverlay(ctx) {
11146
11565
  const m = this.getMeasurement();
11147
11566
  if (!m) return;
11148
- ctx.save();
11149
- ctx.strokeStyle = "#FF5722";
11150
- ctx.setLineDash([8, 4]);
11151
- ctx.lineWidth = 2;
11152
- ctx.beginPath();
11153
- ctx.moveTo(m.start.x, m.start.y);
11154
- ctx.lineTo(m.end.x, m.end.y);
11155
- ctx.stroke();
11156
- ctx.setLineDash([]);
11157
- ctx.fillStyle = "#FF5722";
11158
- const dotRadius = 4;
11159
- ctx.beginPath();
11160
- ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
11161
- ctx.fill();
11162
- ctx.beginPath();
11163
- ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
11164
- ctx.fill();
11165
- const label = `${Math.round(m.feet)} ft`;
11166
- const midX = (m.start.x + m.end.x) / 2;
11167
- const midY = (m.start.y + m.end.y) / 2;
11168
- ctx.font = "14px sans-serif";
11169
- const metrics = ctx.measureText(label);
11170
- const padX = 6;
11171
- const padY = 4;
11172
- const textH = 14;
11173
- ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11174
- ctx.beginPath();
11175
- ctx.roundRect(
11176
- midX - metrics.width / 2 - padX,
11177
- midY - textH / 2 - padY,
11178
- metrics.width + padX * 2,
11179
- textH + padY * 2,
11180
- 4
11181
- );
11182
- ctx.fill();
11183
- ctx.fillStyle = "#FFFFFF";
11184
- ctx.textAlign = "center";
11185
- ctx.textBaseline = "middle";
11186
- ctx.fillText(label, midX, midY);
11187
- ctx.restore();
11567
+ drawMeasurement(ctx, { start: m.start, end: m.end, feet: m.feet, color: this.color });
11188
11568
  }
11189
11569
  snapToGrid(point, ctx) {
11190
11570
  if (!ctx.gridSize) return point;
@@ -11202,6 +11582,32 @@ var MeasureTool = class {
11202
11582
  notifyOptionsChange() {
11203
11583
  for (const listener of this.optionListeners) listener();
11204
11584
  }
11585
+ scheduleEmission() {
11586
+ if (this.measurementListeners.size === 0) return;
11587
+ if (this.emissionRafId !== null) return;
11588
+ this.emissionRafId = requestAnimationFrame(() => {
11589
+ this.emissionRafId = null;
11590
+ const m = this.getMeasurement();
11591
+ if (!m) return;
11592
+ this.emit({ ...m, color: this.color });
11593
+ });
11594
+ }
11595
+ emitClear() {
11596
+ if (this.emissionRafId !== null) {
11597
+ cancelAnimationFrame(this.emissionRafId);
11598
+ this.emissionRafId = null;
11599
+ }
11600
+ if (this.measurementListeners.size === 0) return;
11601
+ this.emit(null);
11602
+ }
11603
+ emit(emission) {
11604
+ for (const listener of this.measurementListeners) {
11605
+ try {
11606
+ listener(emission);
11607
+ } catch {
11608
+ }
11609
+ }
11610
+ }
11205
11611
  };
11206
11612
 
11207
11613
  // src/tools/template-tool.ts
@@ -11497,9 +11903,9 @@ var TemplateTool = class {
11497
11903
  };
11498
11904
 
11499
11905
  // src/tools/laser-tool.ts
11500
- var DEFAULT_COLOR3 = "#ff3b30";
11906
+ var DEFAULT_COLOR5 = "#ff3b30";
11501
11907
  var DEFAULT_WIDTH2 = 4;
11502
- var DEFAULT_FADE_MS2 = 1200;
11908
+ var DEFAULT_FADE_MS3 = 1200;
11503
11909
  var LaserTool = class {
11504
11910
  name;
11505
11911
  color;
@@ -11513,9 +11919,9 @@ var LaserTool = class {
11513
11919
  pendingEmission = [];
11514
11920
  constructor(options = {}) {
11515
11921
  this.name = options.name ?? "laser";
11516
- this.color = options.color ?? DEFAULT_COLOR3;
11922
+ this.color = options.color ?? DEFAULT_COLOR5;
11517
11923
  this.width = options.width ?? DEFAULT_WIDTH2;
11518
- this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11924
+ this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
11519
11925
  }
11520
11926
  now() {
11521
11927
  return performance.now();
@@ -11641,10 +12047,10 @@ var LaserTool = class {
11641
12047
  };
11642
12048
 
11643
12049
  // 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;
12050
+ var DEFAULT_COLOR6 = "#ff3b30";
12051
+ var DEFAULT_DURATION_MS3 = 1800;
12052
+ var DEFAULT_RADIUS4 = 48;
12053
+ var DEFAULT_MIN_INTERVAL_MS2 = 300;
11648
12054
  var PingTool = class {
11649
12055
  name;
11650
12056
  color;
@@ -11658,10 +12064,10 @@ var PingTool = class {
11658
12064
  pingListeners = /* @__PURE__ */ new Set();
11659
12065
  constructor(options = {}) {
11660
12066
  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;
12067
+ this.color = options.color ?? DEFAULT_COLOR6;
12068
+ this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS3;
12069
+ this.radius = options.radius ?? DEFAULT_RADIUS4;
12070
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
11665
12071
  }
11666
12072
  now() {
11667
12073
  return performance.now();
@@ -11761,7 +12167,7 @@ var PingTool = class {
11761
12167
  };
11762
12168
 
11763
12169
  // src/index.ts
11764
- var VERSION = "0.55.0";
12170
+ var VERSION = "0.57.0";
11765
12171
  // Annotate the CommonJS export names for ESM import in node:
11766
12172
  0 && (module.exports = {
11767
12173
  ArrowTool,
@@ -11778,13 +12184,16 @@ var VERSION = "0.55.0";
11778
12184
  LaserTool,
11779
12185
  LayerManager,
11780
12186
  LocalStorageAdapter,
12187
+ MEASURE_PRESENCE_KIND,
11781
12188
  MeasureTool,
11782
12189
  MemoryAdapter,
11783
12190
  NoteTool,
11784
12191
  PING_PRESENCE_KIND,
11785
12192
  PencilTool,
12193
+ PingInput,
11786
12194
  PingTool,
11787
12195
  RemoteLaserOverlay,
12196
+ RemoteMeasureOverlay,
11788
12197
  RemotePingOverlay,
11789
12198
  SelectTool,
11790
12199
  ShapeTool,
@@ -11822,6 +12231,7 @@ var VERSION = "0.55.0";
11822
12231
  getHexCellsInSquare,
11823
12232
  getHexDistance,
11824
12233
  isLaserTrailPresence,
12234
+ isMeasurePresence,
11825
12235
  isNearBezier,
11826
12236
  isPingPresence,
11827
12237
  setFontSize,
@@ -11830,6 +12240,7 @@ var VERSION = "0.55.0";
11830
12240
  snapToHexCenter,
11831
12241
  styleToPatch,
11832
12242
  toLaserTrailPresence,
12243
+ toMeasurePresence,
11833
12244
  toPingPresence,
11834
12245
  toggleBold,
11835
12246
  toggleItalic,