@fieldnotes/core 0.63.0 → 0.64.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
@@ -44,13 +44,17 @@ __export(index_exports, {
44
44
  MemoryAdapter: () => MemoryAdapter,
45
45
  MinimapController: () => MinimapController,
46
46
  NoteTool: () => NoteTool,
47
+ PATH_PRESENCE_KIND: () => PATH_PRESENCE_KIND,
48
+ PATH_PRESENCE_MAX_POINTS: () => PATH_PRESENCE_MAX_POINTS,
47
49
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
50
+ PathTool: () => PathTool,
48
51
  PencilTool: () => PencilTool,
49
52
  PingInput: () => PingInput,
50
53
  PingTool: () => PingTool,
51
54
  RemoteFocusReceiver: () => RemoteFocusReceiver,
52
55
  RemoteLaserOverlay: () => RemoteLaserOverlay,
53
56
  RemoteMeasureOverlay: () => RemoteMeasureOverlay,
57
+ RemotePathOverlay: () => RemotePathOverlay,
54
58
  RemotePingOverlay: () => RemotePingOverlay,
55
59
  SelectTool: () => SelectTool,
56
60
  ShapeTool: () => ShapeTool,
@@ -78,6 +82,7 @@ __export(index_exports, {
78
82
  exportImage: () => exportImage,
79
83
  exportSvg: () => exportSvg,
80
84
  fitZoomForView: () => fitZoomForView,
85
+ footprintFromSize: () => footprintFromSize,
81
86
  getActiveFormats: () => getActiveFormats,
82
87
  getArrowBounds: () => getArrowBounds,
83
88
  getArrowControlPoint: () => getArrowControlPoint,
@@ -93,20 +98,26 @@ __export(index_exports, {
93
98
  getHexCellsInRectangle: () => getHexCellsInRectangle,
94
99
  getHexCellsInSquare: () => getHexCellsInSquare,
95
100
  getHexDistance: () => getHexDistance,
101
+ gridDistanceCells: () => gridDistanceCells,
96
102
  isFocusPresence: () => isFocusPresence,
97
103
  isLaserTrailPresence: () => isLaserTrailPresence,
98
104
  isMeasurePresence: () => isMeasurePresence,
99
105
  isNearBezier: () => isNearBezier,
106
+ isPathPresence: () => isPathPresence,
100
107
  isPingPresence: () => isPingPresence,
108
+ pathDistanceCells: () => pathDistanceCells,
101
109
  resolveHtmlRouting: () => resolveHtmlRouting,
102
110
  setFontSize: () => setFontSize,
103
111
  smartSnap: () => smartSnap,
112
+ snapFootprintCenter: () => snapFootprintCenter,
104
113
  snapPoint: () => snapPoint,
114
+ snapToCellCenter: () => snapToCellCenter,
105
115
  snapToHexCenter: () => snapToHexCenter,
106
116
  styleToPatch: () => styleToPatch,
107
117
  toFocusPresence: () => toFocusPresence,
108
118
  toLaserTrailPresence: () => toLaserTrailPresence,
109
119
  toMeasurePresence: () => toMeasurePresence,
120
+ toPathPresence: () => toPathPresence,
110
121
  toPingPresence: () => toPingPresence,
111
122
  toggleBold: () => toggleBold,
112
123
  toggleItalic: () => toggleItalic,
@@ -146,6 +157,310 @@ function smartSnap(point, ctx) {
146
157
  }
147
158
  return snapPoint(point, ctx.gridSize);
148
159
  }
160
+ function footprintOf(footprint) {
161
+ return typeof footprint === "number" ? { w: footprint, h: footprint } : footprint;
162
+ }
163
+ function footprintFromSize(size, gridSize) {
164
+ if (!(gridSize > 0)) return 1;
165
+ return {
166
+ w: Math.max(1, Math.round(size.w / gridSize)),
167
+ h: Math.max(1, Math.round(size.h / gridSize))
168
+ };
169
+ }
170
+ function snapAxisToCell(value, gridSize, cells) {
171
+ const n2 = Math.max(1, Math.round(cells));
172
+ if (n2 % 2 === 0) return Math.round(value / gridSize) * gridSize || 0;
173
+ return (Math.round((value - gridSize / 2) / gridSize) + 0.5) * gridSize || 0;
174
+ }
175
+ function snapToCellCenter(point, gridSize, footprint = 1) {
176
+ const { w, h } = footprintOf(footprint);
177
+ return { x: snapAxisToCell(point.x, gridSize, w), y: snapAxisToCell(point.y, gridSize, h) };
178
+ }
179
+ function snapFootprintCenter(point, footprint, ctx) {
180
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
181
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
182
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
183
+ }
184
+ return snapToCellCenter(point, ctx.gridSize, footprint);
185
+ }
186
+
187
+ // src/elements/hex-fill.ts
188
+ function offsetToCube(col, row, orientation) {
189
+ if (orientation === "pointy") {
190
+ return { q: col - (row - (row & 1)) / 2, r: row };
191
+ }
192
+ return { q: col, r: row - (col - (col & 1)) / 2 };
193
+ }
194
+ function cubeToOffset(q, r, orientation) {
195
+ if (orientation === "pointy") {
196
+ return { col: q + (r - (r & 1)) / 2, row: r };
197
+ }
198
+ return { col: q, row: r + (q - (q & 1)) / 2 };
199
+ }
200
+ function offsetToPixel(col, row, cellSize, orientation) {
201
+ if (orientation === "pointy") {
202
+ const hexW = Math.sqrt(3) * cellSize;
203
+ const rowH = 1.5 * cellSize;
204
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
205
+ return { x: col * hexW + offsetX, y: row * rowH };
206
+ }
207
+ const hexH = Math.sqrt(3) * cellSize;
208
+ const colW = 1.5 * cellSize;
209
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
210
+ return { x: col * colW, y: row * hexH + offsetY };
211
+ }
212
+ function pixelToOffset(x, y, cellSize, orientation) {
213
+ if (orientation === "pointy") {
214
+ const hexW = Math.sqrt(3) * cellSize;
215
+ const rowH = 1.5 * cellSize;
216
+ const row = Math.round(y / rowH);
217
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
218
+ return { col: Math.round((x - offsetX) / hexW), row };
219
+ }
220
+ const hexH = Math.sqrt(3) * cellSize;
221
+ const colW = 1.5 * cellSize;
222
+ const col = Math.round(x / colW);
223
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
224
+ return { col, row: Math.round((y - offsetY) / hexH) };
225
+ }
226
+ function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
227
+ const cells = [];
228
+ for (let dq = -n2; dq <= n2; dq++) {
229
+ const rMin = Math.max(-n2, -dq - n2);
230
+ const rMax = Math.min(n2, -dq + n2);
231
+ for (let dr = rMin; dr <= rMax; dr++) {
232
+ const absQ = centerQ + dq;
233
+ const absR = centerR + dr;
234
+ const off = cubeToOffset(absQ, absR, orientation);
235
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
236
+ }
237
+ }
238
+ return cells;
239
+ }
240
+ function getHexDistance(a, b, cellSize, orientation) {
241
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
242
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
243
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
244
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
245
+ const dq = cubeA.q - cubeB.q;
246
+ const dr = cubeA.r - cubeB.r;
247
+ const ds = -dq - dr;
248
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
249
+ }
250
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
251
+ const n2 = Math.round(radiusCells);
252
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
253
+ const cube = offsetToCube(off.col, off.row, orientation);
254
+ if (n2 <= 0) {
255
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
256
+ }
257
+ return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
258
+ }
259
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
260
+ const n2 = Math.round(radiusCells);
261
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
262
+ const cube = offsetToCube(off.col, off.row, orientation);
263
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
264
+ if (n2 <= 0) return [centerPixel];
265
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
266
+ const step = Math.PI / 3;
267
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
268
+ const halfAngle = Math.PI / 6 + 1e-6;
269
+ const cells = [centerPixel];
270
+ for (let dq = -n2; dq <= n2; dq++) {
271
+ const rMin = Math.max(-n2, -dq - n2);
272
+ const rMax = Math.min(n2, -dq + n2);
273
+ for (let dr = rMin; dr <= rMax; dr++) {
274
+ if (dq === 0 && dr === 0) continue;
275
+ const absQ = cube.q + dq;
276
+ const absR = cube.r + dr;
277
+ const pixel = offsetToPixel(
278
+ cubeToOffset(absQ, absR, orientation).col,
279
+ cubeToOffset(absQ, absR, orientation).row,
280
+ cellSize,
281
+ orientation
282
+ );
283
+ const dx = pixel.x - centerPixel.x;
284
+ const dy = pixel.y - centerPixel.y;
285
+ let diff = Math.atan2(dy, dx) - snappedAngle;
286
+ if (diff > Math.PI) diff -= 2 * Math.PI;
287
+ if (diff < -Math.PI) diff += 2 * Math.PI;
288
+ if (Math.abs(diff) <= halfAngle) {
289
+ cells.push(pixel);
290
+ }
291
+ }
292
+ }
293
+ return cells;
294
+ }
295
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
296
+ const n2 = Math.round(radiusCells);
297
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
298
+ const cube = offsetToCube(off.col, off.row, orientation);
299
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
300
+ if (n2 <= 0) return [centerPixel];
301
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
302
+ const step = Math.PI / 3;
303
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
304
+ const cos = Math.cos(snappedAngle);
305
+ const sin = Math.sin(snappedAngle);
306
+ const snapUnit = Math.sqrt(3) * cellSize;
307
+ const lineLength = n2 * snapUnit;
308
+ const halfWidth = snapUnit * 0.5 + 1e-6;
309
+ const cells = [];
310
+ for (let dq = -n2; dq <= n2; dq++) {
311
+ const rMin = Math.max(-n2, -dq - n2);
312
+ const rMax = Math.min(n2, -dq + n2);
313
+ for (let dr = rMin; dr <= rMax; dr++) {
314
+ const absQ = cube.q + dq;
315
+ const absR = cube.r + dr;
316
+ const pixel = offsetToPixel(
317
+ cubeToOffset(absQ, absR, orientation).col,
318
+ cubeToOffset(absQ, absR, orientation).row,
319
+ cellSize,
320
+ orientation
321
+ );
322
+ const dx = pixel.x - centerPixel.x;
323
+ const dy = pixel.y - centerPixel.y;
324
+ const along = dx * cos + dy * sin;
325
+ const perp = Math.abs(-dx * sin + dy * cos);
326
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
327
+ cells.push(pixel);
328
+ }
329
+ }
330
+ }
331
+ return cells;
332
+ }
333
+ function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
334
+ const nLen = Math.round(lengthCells);
335
+ const wCells = Math.max(1, Math.round(widthCells));
336
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
337
+ const cube = offsetToCube(off.col, off.row, orientation);
338
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
339
+ if (nLen <= 0) return [centerPixel];
340
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
341
+ const step = Math.PI / 3;
342
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
343
+ const cos = Math.cos(snappedAngle);
344
+ const sin = Math.sin(snappedAngle);
345
+ const snapUnit = Math.sqrt(3) * cellSize;
346
+ const lineLength = nLen * snapUnit;
347
+ const halfWidth = wCells * snapUnit / 2 + 1e-6;
348
+ const iterM = nLen + Math.ceil(wCells / 2) + 1;
349
+ const cells = [];
350
+ for (let dq = -iterM; dq <= iterM; dq++) {
351
+ const rMin = Math.max(-iterM, -dq - iterM);
352
+ const rMax = Math.min(iterM, -dq + iterM);
353
+ for (let dr = rMin; dr <= rMax; dr++) {
354
+ const absQ = cube.q + dq;
355
+ const absR = cube.r + dr;
356
+ const pixel = offsetToPixel(
357
+ cubeToOffset(absQ, absR, orientation).col,
358
+ cubeToOffset(absQ, absR, orientation).row,
359
+ cellSize,
360
+ orientation
361
+ );
362
+ const dx = pixel.x - centerPixel.x;
363
+ const dy = pixel.y - centerPixel.y;
364
+ const along = dx * cos + dy * sin;
365
+ const perp = Math.abs(-dx * sin + dy * cos);
366
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
367
+ cells.push(pixel);
368
+ }
369
+ }
370
+ }
371
+ return cells;
372
+ }
373
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
374
+ const n2 = Math.round(radiusCells);
375
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
376
+ const cube = offsetToCube(off.col, off.row, orientation);
377
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
378
+ if (n2 <= 0) return [centerPixel];
379
+ const snapUnit = Math.sqrt(3) * cellSize;
380
+ const halfSide = n2 * snapUnit / 2;
381
+ const cells = [];
382
+ for (let dq = -n2; dq <= n2; dq++) {
383
+ const rMin = Math.max(-n2, -dq - n2);
384
+ const rMax = Math.min(n2, -dq + n2);
385
+ for (let dr = rMin; dr <= rMax; dr++) {
386
+ const absQ = cube.q + dq;
387
+ const absR = cube.r + dr;
388
+ const pixel = offsetToPixel(
389
+ cubeToOffset(absQ, absR, orientation).col,
390
+ cubeToOffset(absQ, absR, orientation).row,
391
+ cellSize,
392
+ orientation
393
+ );
394
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
395
+ cells.push(pixel);
396
+ }
397
+ }
398
+ }
399
+ return cells;
400
+ }
401
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
402
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
403
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
404
+ for (let i = 1; i < 6; i++) {
405
+ const a = angleOffset + Math.PI / 3 * i;
406
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
407
+ }
408
+ ctx.closePath();
409
+ }
410
+
411
+ // src/core/grid-metric.ts
412
+ function squareCost(rule, straight, diag) {
413
+ switch (rule) {
414
+ case "alternate":
415
+ return straight + diag + Math.floor(diag / 2);
416
+ case "manhattan":
417
+ return straight + 2 * diag;
418
+ case "chebyshev":
419
+ case "euclidean":
420
+ default:
421
+ return straight + diag;
422
+ }
423
+ }
424
+ function pathDistanceCells(points, grid) {
425
+ const cumulative = [];
426
+ const first = points[0];
427
+ if (first === void 0) return { total: 0, cumulative };
428
+ const gridSize = grid.gridSize > 0 ? grid.gridSize : 1;
429
+ const rule = grid.diagonalRule ?? "euclidean";
430
+ const orientation = grid.hexOrientation;
431
+ const hex = grid.gridType === "hex" && orientation !== void 0;
432
+ const squareRule = grid.gridType === "square" && rule !== "euclidean";
433
+ let straight = 0;
434
+ let diag = 0;
435
+ let euclid = 0;
436
+ let hexCells = 0;
437
+ cumulative.push(0);
438
+ let prev = first;
439
+ for (let i = 1; i < points.length; i++) {
440
+ const next = points[i];
441
+ if (next === void 0) break;
442
+ if (hex && orientation !== void 0) {
443
+ hexCells += getHexDistance(prev, next, gridSize, orientation);
444
+ cumulative.push(hexCells);
445
+ } else if (squareRule) {
446
+ const dx = Math.abs(Math.round((next.x - prev.x) / gridSize));
447
+ const dy = Math.abs(Math.round((next.y - prev.y) / gridSize));
448
+ const d = Math.min(dx, dy);
449
+ diag += d;
450
+ straight += Math.max(dx, dy) - d;
451
+ cumulative.push(squareCost(rule, straight, diag));
452
+ } else {
453
+ euclid += Math.hypot(next.x - prev.x, next.y - prev.y) / gridSize;
454
+ cumulative.push(euclid);
455
+ }
456
+ prev = next;
457
+ }
458
+ const total = cumulative[cumulative.length - 1] ?? 0;
459
+ return { total, cumulative };
460
+ }
461
+ function gridDistanceCells(a, b, grid) {
462
+ return pathDistanceCells([a, b], grid).total;
463
+ }
149
464
 
150
465
  // src/elements/note-sanitizer.ts
151
466
  var BOLD_TAGS = /* @__PURE__ */ new Set(["b", "strong"]);
@@ -1672,6 +1987,12 @@ var KeyboardHandler = class {
1672
1987
  if (e.key === " ") {
1673
1988
  this.deps.setSpaceHeld(true);
1674
1989
  }
1990
+ const tool = this.deps.getActiveTool();
1991
+ const ctx = this.deps.getToolContext();
1992
+ if (tool?.onKeyDown && ctx && tool.onKeyDown(e, ctx)) {
1993
+ e.preventDefault();
1994
+ return;
1995
+ }
1675
1996
  const action = this.shortcutMap.match(e);
1676
1997
  if (action !== null) {
1677
1998
  this.runAction(action, e);
@@ -1967,6 +2288,7 @@ var InputHandler = class {
1967
2288
  abortSignal: this.abortController.signal,
1968
2289
  getToolContext: () => this.toolContext,
1969
2290
  getIsToolActive: () => this.isToolActive,
2291
+ getActiveTool: () => this.toolManager?.activeTool ?? null,
1970
2292
  getLastPointerEvent: () => this.lastPointerEvent,
1971
2293
  setSpaceHeld: (v) => {
1972
2294
  this.spaceHeld = v;
@@ -2048,7 +2370,7 @@ var InputHandler = class {
2048
2370
  this.element.addEventListener("pointermove", this.onPointerMove, opts);
2049
2371
  this.element.addEventListener("pointerup", this.onPointerUp, opts);
2050
2372
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
2051
- this.element.addEventListener("pointercancel", this.onPointerUp, opts);
2373
+ this.element.addEventListener("pointercancel", this.onPointerCancel, opts);
2052
2374
  this.element.addEventListener("contextmenu", this.onContextMenu, opts);
2053
2375
  window.addEventListener("blur", this.onCoastInterrupt, opts);
2054
2376
  window.addEventListener("visibilitychange", this.onCoastInterrupt, opts);
@@ -2127,6 +2449,12 @@ var InputHandler = class {
2127
2449
  }
2128
2450
  };
2129
2451
  onPointerUp = (e) => {
2452
+ this.finishPointer(e, false);
2453
+ };
2454
+ onPointerCancel = (e) => {
2455
+ this.finishPointer(e, true);
2456
+ };
2457
+ finishPointer(e, cancelled) {
2130
2458
  this.cancelLongPress();
2131
2459
  try {
2132
2460
  this.element.releasePointerCapture(e.pointerId);
@@ -2149,16 +2477,18 @@ var InputHandler = class {
2149
2477
  if (this.activePointers.size === 0) this.coastStoppedByPointer = false;
2150
2478
  const upResult = this.inputFilter.filterUp(e);
2151
2479
  if (this.isToolActive) {
2152
- this.dispatchToolUp(e);
2480
+ if (cancelled) this.dispatchToolCancel(e);
2481
+ else this.dispatchToolUp(e);
2153
2482
  this.isToolActive = false;
2154
2483
  } else if (this.deferredDown && upResult.pendingTap) {
2155
2484
  this.dispatchToolDown(this.deferredDown);
2156
- this.dispatchToolUp(e);
2485
+ if (cancelled) this.dispatchToolCancel(e);
2486
+ else this.dispatchToolUp(e);
2157
2487
  this.deferredDown = null;
2158
2488
  } else {
2159
2489
  this.deferredDown = null;
2160
2490
  }
2161
- };
2491
+ }
2162
2492
  runAction(action, e) {
2163
2493
  this.keyboard.runAction(action, e);
2164
2494
  }
@@ -2265,6 +2595,11 @@ var InputHandler = class {
2265
2595
  this.toolManager.handlePointerUp(this.toPointerState(e), this.toolContext);
2266
2596
  this.historyRecorder?.commit();
2267
2597
  }
2598
+ dispatchToolCancel(e) {
2599
+ if (!this.toolManager || !this.toolContext) return;
2600
+ this.toolManager.handlePointerCancel(this.toPointerState(e), this.toolContext);
2601
+ this.historyRecorder?.commit();
2602
+ }
2268
2603
  isInScope() {
2269
2604
  if (this.scope === "window") return true;
2270
2605
  const active = document.activeElement;
@@ -2277,7 +2612,7 @@ var InputHandler = class {
2277
2612
  cancelToolIfActive(e) {
2278
2613
  this.cancelLongPress();
2279
2614
  if (this.isToolActive) {
2280
- this.dispatchToolUp(e);
2615
+ this.dispatchToolCancel(e);
2281
2616
  this.isToolActive = false;
2282
2617
  }
2283
2618
  this.deferredDown = null;
@@ -3375,230 +3710,6 @@ function getImage(src, imageCache, onImageLoad, onImageError) {
3375
3710
  return null;
3376
3711
  }
3377
3712
 
3378
- // src/elements/hex-fill.ts
3379
- function offsetToCube(col, row, orientation) {
3380
- if (orientation === "pointy") {
3381
- return { q: col - (row - (row & 1)) / 2, r: row };
3382
- }
3383
- return { q: col, r: row - (col - (col & 1)) / 2 };
3384
- }
3385
- function cubeToOffset(q, r, orientation) {
3386
- if (orientation === "pointy") {
3387
- return { col: q + (r - (r & 1)) / 2, row: r };
3388
- }
3389
- return { col: q, row: r + (q - (q & 1)) / 2 };
3390
- }
3391
- function offsetToPixel(col, row, cellSize, orientation) {
3392
- if (orientation === "pointy") {
3393
- const hexW = Math.sqrt(3) * cellSize;
3394
- const rowH = 1.5 * cellSize;
3395
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
3396
- return { x: col * hexW + offsetX, y: row * rowH };
3397
- }
3398
- const hexH = Math.sqrt(3) * cellSize;
3399
- const colW = 1.5 * cellSize;
3400
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
3401
- return { x: col * colW, y: row * hexH + offsetY };
3402
- }
3403
- function pixelToOffset(x, y, cellSize, orientation) {
3404
- if (orientation === "pointy") {
3405
- const hexW = Math.sqrt(3) * cellSize;
3406
- const rowH = 1.5 * cellSize;
3407
- const row = Math.round(y / rowH);
3408
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
3409
- return { col: Math.round((x - offsetX) / hexW), row };
3410
- }
3411
- const hexH = Math.sqrt(3) * cellSize;
3412
- const colW = 1.5 * cellSize;
3413
- const col = Math.round(x / colW);
3414
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
3415
- return { col, row: Math.round((y - offsetY) / hexH) };
3416
- }
3417
- function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
3418
- const cells = [];
3419
- for (let dq = -n2; dq <= n2; dq++) {
3420
- const rMin = Math.max(-n2, -dq - n2);
3421
- const rMax = Math.min(n2, -dq + n2);
3422
- for (let dr = rMin; dr <= rMax; dr++) {
3423
- const absQ = centerQ + dq;
3424
- const absR = centerR + dr;
3425
- const off = cubeToOffset(absQ, absR, orientation);
3426
- cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
3427
- }
3428
- }
3429
- return cells;
3430
- }
3431
- function getHexDistance(a, b, cellSize, orientation) {
3432
- const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
3433
- const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
3434
- const cubeA = offsetToCube(offA.col, offA.row, orientation);
3435
- const cubeB = offsetToCube(offB.col, offB.row, orientation);
3436
- const dq = cubeA.q - cubeB.q;
3437
- const dr = cubeA.r - cubeB.r;
3438
- const ds = -dq - dr;
3439
- return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
3440
- }
3441
- function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
3442
- const n2 = Math.round(radiusCells);
3443
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3444
- const cube = offsetToCube(off.col, off.row, orientation);
3445
- if (n2 <= 0) {
3446
- return [offsetToPixel(off.col, off.row, cellSize, orientation)];
3447
- }
3448
- return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
3449
- }
3450
- function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
3451
- const n2 = Math.round(radiusCells);
3452
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3453
- const cube = offsetToCube(off.col, off.row, orientation);
3454
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3455
- if (n2 <= 0) return [centerPixel];
3456
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3457
- const step = Math.PI / 3;
3458
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3459
- const halfAngle = Math.PI / 6 + 1e-6;
3460
- const cells = [centerPixel];
3461
- for (let dq = -n2; dq <= n2; dq++) {
3462
- const rMin = Math.max(-n2, -dq - n2);
3463
- const rMax = Math.min(n2, -dq + n2);
3464
- for (let dr = rMin; dr <= rMax; dr++) {
3465
- if (dq === 0 && dr === 0) continue;
3466
- const absQ = cube.q + dq;
3467
- const absR = cube.r + dr;
3468
- const pixel = offsetToPixel(
3469
- cubeToOffset(absQ, absR, orientation).col,
3470
- cubeToOffset(absQ, absR, orientation).row,
3471
- cellSize,
3472
- orientation
3473
- );
3474
- const dx = pixel.x - centerPixel.x;
3475
- const dy = pixel.y - centerPixel.y;
3476
- let diff = Math.atan2(dy, dx) - snappedAngle;
3477
- if (diff > Math.PI) diff -= 2 * Math.PI;
3478
- if (diff < -Math.PI) diff += 2 * Math.PI;
3479
- if (Math.abs(diff) <= halfAngle) {
3480
- cells.push(pixel);
3481
- }
3482
- }
3483
- }
3484
- return cells;
3485
- }
3486
- function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
3487
- const n2 = Math.round(radiusCells);
3488
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3489
- const cube = offsetToCube(off.col, off.row, orientation);
3490
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3491
- if (n2 <= 0) return [centerPixel];
3492
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3493
- const step = Math.PI / 3;
3494
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3495
- const cos = Math.cos(snappedAngle);
3496
- const sin = Math.sin(snappedAngle);
3497
- const snapUnit = Math.sqrt(3) * cellSize;
3498
- const lineLength = n2 * snapUnit;
3499
- const halfWidth = snapUnit * 0.5 + 1e-6;
3500
- const cells = [];
3501
- for (let dq = -n2; dq <= n2; dq++) {
3502
- const rMin = Math.max(-n2, -dq - n2);
3503
- const rMax = Math.min(n2, -dq + n2);
3504
- for (let dr = rMin; dr <= rMax; dr++) {
3505
- const absQ = cube.q + dq;
3506
- const absR = cube.r + dr;
3507
- const pixel = offsetToPixel(
3508
- cubeToOffset(absQ, absR, orientation).col,
3509
- cubeToOffset(absQ, absR, orientation).row,
3510
- cellSize,
3511
- orientation
3512
- );
3513
- const dx = pixel.x - centerPixel.x;
3514
- const dy = pixel.y - centerPixel.y;
3515
- const along = dx * cos + dy * sin;
3516
- const perp = Math.abs(-dx * sin + dy * cos);
3517
- if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
3518
- cells.push(pixel);
3519
- }
3520
- }
3521
- }
3522
- return cells;
3523
- }
3524
- function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
3525
- const nLen = Math.round(lengthCells);
3526
- const wCells = Math.max(1, Math.round(widthCells));
3527
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3528
- const cube = offsetToCube(off.col, off.row, orientation);
3529
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3530
- if (nLen <= 0) return [centerPixel];
3531
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3532
- const step = Math.PI / 3;
3533
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3534
- const cos = Math.cos(snappedAngle);
3535
- const sin = Math.sin(snappedAngle);
3536
- const snapUnit = Math.sqrt(3) * cellSize;
3537
- const lineLength = nLen * snapUnit;
3538
- const halfWidth = wCells * snapUnit / 2 + 1e-6;
3539
- const iterM = nLen + Math.ceil(wCells / 2) + 1;
3540
- const cells = [];
3541
- for (let dq = -iterM; dq <= iterM; dq++) {
3542
- const rMin = Math.max(-iterM, -dq - iterM);
3543
- const rMax = Math.min(iterM, -dq + iterM);
3544
- for (let dr = rMin; dr <= rMax; dr++) {
3545
- const absQ = cube.q + dq;
3546
- const absR = cube.r + dr;
3547
- const pixel = offsetToPixel(
3548
- cubeToOffset(absQ, absR, orientation).col,
3549
- cubeToOffset(absQ, absR, orientation).row,
3550
- cellSize,
3551
- orientation
3552
- );
3553
- const dx = pixel.x - centerPixel.x;
3554
- const dy = pixel.y - centerPixel.y;
3555
- const along = dx * cos + dy * sin;
3556
- const perp = Math.abs(-dx * sin + dy * cos);
3557
- if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
3558
- cells.push(pixel);
3559
- }
3560
- }
3561
- }
3562
- return cells;
3563
- }
3564
- function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
3565
- const n2 = Math.round(radiusCells);
3566
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3567
- const cube = offsetToCube(off.col, off.row, orientation);
3568
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3569
- if (n2 <= 0) return [centerPixel];
3570
- const snapUnit = Math.sqrt(3) * cellSize;
3571
- const halfSide = n2 * snapUnit / 2;
3572
- const cells = [];
3573
- for (let dq = -n2; dq <= n2; dq++) {
3574
- const rMin = Math.max(-n2, -dq - n2);
3575
- const rMax = Math.min(n2, -dq + n2);
3576
- for (let dr = rMin; dr <= rMax; dr++) {
3577
- const absQ = cube.q + dq;
3578
- const absR = cube.r + dr;
3579
- const pixel = offsetToPixel(
3580
- cubeToOffset(absQ, absR, orientation).col,
3581
- cubeToOffset(absQ, absR, orientation).row,
3582
- cellSize,
3583
- orientation
3584
- );
3585
- if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
3586
- cells.push(pixel);
3587
- }
3588
- }
3589
- }
3590
- return cells;
3591
- }
3592
- function drawHexPath(ctx, cx, cy, cellSize, orientation) {
3593
- const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3594
- ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
3595
- for (let i = 1; i < 6; i++) {
3596
- const a = angleOffset + Math.PI / 3 * i;
3597
- ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
3598
- }
3599
- ctx.closePath();
3600
- }
3601
-
3602
3713
  // src/elements/renderers/template-measure.ts
3603
3714
  function renderTemplateFeetLabel(ctx, p) {
3604
3715
  if (p.feet <= 0) return;
@@ -5573,6 +5684,13 @@ var ToolManager = class {
5573
5684
  handlePointerUp(state, ctx) {
5574
5685
  this.current?.onPointerUp(state, ctx);
5575
5686
  }
5687
+ /** Cancels the active gesture; falls back to `onPointerUp` for tools without `onPointerCancel`. */
5688
+ handlePointerCancel(state, ctx) {
5689
+ const tool = this.current;
5690
+ if (!tool) return;
5691
+ if (tool.onPointerCancel) tool.onPointerCancel(state, ctx);
5692
+ else tool.onPointerUp(state, ctx);
5693
+ }
5576
5694
  onChange(listener) {
5577
5695
  this.changeListeners.add(listener);
5578
5696
  return () => this.changeListeners.delete(listener);
@@ -11182,145 +11300,119 @@ function drawMeasurement(ctx, m, opts = {}) {
11182
11300
  ctx.restore();
11183
11301
  }
11184
11302
 
11185
- // src/canvas/remote-measure-overlay.ts
11186
- var MEASURE_PRESENCE_KIND = "measure";
11187
- function isFinitePoint2(value) {
11188
- if (typeof value !== "object" || value === null) return false;
11189
- const point = value;
11190
- return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11191
- }
11192
- function isMeasurePresence(data) {
11193
- if (typeof data !== "object" || data === null) return false;
11194
- const payload = data;
11195
- if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
11196
- if ("cleared" in payload) return payload.cleared === true;
11197
- if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
11198
- if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
11199
- if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11200
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11201
- return true;
11202
- }
11203
- function toMeasurePresence(emission) {
11204
- if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
11205
- return {
11206
- kind: MEASURE_PRESENCE_KIND,
11207
- start: emission.start,
11208
- end: emission.end,
11209
- cells: emission.cells,
11210
- feet: emission.feet,
11211
- color: emission.color
11212
- };
11213
- }
11214
- var DEFAULT_COLOR3 = "#FF5722";
11303
+ // src/canvas/linger-overlay.ts
11215
11304
  var DEFAULT_HOLD_MS = 1500;
11216
11305
  var DEFAULT_FADE_MS2 = 400;
11217
11306
  var DEFAULT_MAX_AGE_MS = 3e4;
11218
- var RemoteMeasureOverlay = class {
11307
+ var LingerOverlay = class {
11219
11308
  host;
11220
- color;
11309
+ draw;
11310
+ clock;
11221
11311
  holdMs;
11222
11312
  fadeMs;
11223
11313
  maxAgeMs;
11224
- measurements = /* @__PURE__ */ new Map();
11314
+ entries = /* @__PURE__ */ new Map();
11225
11315
  unregister;
11226
11316
  rafId = null;
11227
- disposed = false;
11228
- constructor(host, options = {}) {
11317
+ isDisposed = false;
11318
+ /**
11319
+ * `clock` exists only as a test seam; production callers leave it at
11320
+ * `performance.now`. Owners that expose their own `now()` seam pass it
11321
+ * through so a spy on the owner still drives this overlay's timing.
11322
+ */
11323
+ constructor(host, options, draw, clock = () => performance.now()) {
11229
11324
  this.host = host;
11230
- this.color = options.color ?? DEFAULT_COLOR3;
11325
+ this.draw = draw;
11326
+ this.clock = clock;
11231
11327
  this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
11232
11328
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11233
11329
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
11234
- this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
11330
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
11235
11331
  }
11236
11332
  now() {
11237
- return performance.now();
11333
+ return this.clock();
11238
11334
  }
11239
11335
  /**
11240
- * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11241
- * the envelope `from`). Non-measure or malformed payloads are ignored and
11242
- * reported as `false`, so hosts can feed every presence frame through.
11336
+ * Stores `sender`'s current entry as active, replacing any previous one and
11337
+ * cancelling an in-flight linger, and restarts the `maxAgeMs` expiry timer.
11243
11338
  */
11244
- apply(sender, data) {
11245
- if (this.disposed || !isMeasurePresence(data)) return false;
11246
- if ("cleared" in data) {
11247
- this.beginLinger(sender);
11248
- return true;
11249
- }
11250
- const existing = this.measurements.get(sender);
11339
+ set(sender, entry) {
11340
+ if (this.isDisposed) return;
11341
+ const existing = this.entries.get(sender);
11251
11342
  if (existing?.expiryTimer != null) clearTimeout(existing.expiryTimer);
11252
- this.measurements.set(sender, {
11253
- start: data.start,
11254
- end: data.end,
11255
- feet: data.feet,
11256
- color: data.color ?? this.color,
11343
+ this.entries.set(sender, {
11344
+ entry,
11257
11345
  clearedAt: null,
11258
- expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
11346
+ expiryTimer: setTimeout(() => this.linger(sender), this.maxAgeMs)
11259
11347
  });
11260
11348
  this.host.requestRender();
11261
- return true;
11262
11349
  }
11263
- /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11350
+ /** Starts the hold-then-fade lifetime for `sender`'s entry. */
11351
+ linger(sender) {
11352
+ const entry = this.entries.get(sender);
11353
+ if (!entry || entry.clearedAt !== null) return;
11354
+ if (entry.expiryTimer != null) {
11355
+ clearTimeout(entry.expiryTimer);
11356
+ entry.expiryTimer = null;
11357
+ }
11358
+ entry.clearedAt = this.now();
11359
+ this.ensureAnimating();
11360
+ this.host.requestRender();
11361
+ }
11362
+ /** Removes a sender's entry immediately (presence-leave/disconnect). */
11264
11363
  remove(sender) {
11265
- const entry = this.measurements.get(sender);
11364
+ const entry = this.entries.get(sender);
11266
11365
  if (!entry) return;
11267
11366
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11268
- this.measurements.delete(sender);
11367
+ this.entries.delete(sender);
11269
11368
  this.host.requestRender();
11270
11369
  }
11271
- /** Removes every ruler immediately. */
11370
+ /** Removes every entry immediately. */
11272
11371
  clear() {
11273
- if (this.measurements.size === 0) return;
11274
- for (const entry of this.measurements.values()) {
11372
+ if (this.entries.size === 0) return;
11373
+ for (const entry of this.entries.values()) {
11275
11374
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11276
11375
  }
11277
- this.measurements.clear();
11376
+ this.entries.clear();
11278
11377
  this.host.requestRender();
11279
11378
  }
11280
- /** Number of senders with a visible (active or lingering) ruler. */
11379
+ /** Number of senders with a visible (active or lingering) entry. */
11281
11380
  get activeSenderCount() {
11282
- return this.measurements.size;
11381
+ return this.entries.size;
11382
+ }
11383
+ /** True once `dispose` has run; the overlay accepts no further entries. */
11384
+ get disposed() {
11385
+ return this.isDisposed;
11283
11386
  }
11284
11387
  /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11285
11388
  dispose() {
11286
- if (this.disposed) return;
11287
- this.disposed = true;
11389
+ if (this.isDisposed) return;
11390
+ this.isDisposed = true;
11288
11391
  if (this.rafId !== null) {
11289
11392
  cancelAnimationFrame(this.rafId);
11290
11393
  this.rafId = null;
11291
11394
  }
11292
- for (const entry of this.measurements.values()) {
11395
+ for (const entry of this.entries.values()) {
11293
11396
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11294
11397
  }
11295
- this.measurements.clear();
11398
+ this.entries.clear();
11296
11399
  this.unregister?.();
11297
11400
  this.unregister = null;
11298
11401
  this.host.requestRender();
11299
11402
  }
11300
- beginLinger(sender) {
11301
- const entry = this.measurements.get(sender);
11302
- if (!entry || entry.clearedAt !== null) return;
11303
- if (entry.expiryTimer != null) {
11304
- clearTimeout(entry.expiryTimer);
11305
- entry.expiryTimer = null;
11306
- }
11307
- entry.clearedAt = this.now();
11308
- this.ensureAnimating();
11309
- this.host.requestRender();
11310
- }
11311
11403
  ensureAnimating() {
11312
11404
  if (this.rafId === null) {
11313
11405
  this.rafId = requestAnimationFrame(() => this.tick());
11314
11406
  }
11315
11407
  }
11316
11408
  tick() {
11317
- if (this.disposed) return;
11409
+ if (this.isDisposed) return;
11318
11410
  const now = this.now();
11319
11411
  let lingering = 0;
11320
- for (const [sender, entry] of this.measurements) {
11412
+ for (const [sender, entry] of this.entries) {
11321
11413
  if (entry.clearedAt === null) continue;
11322
11414
  if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
11323
- this.measurements.delete(sender);
11415
+ this.entries.delete(sender);
11324
11416
  } else {
11325
11417
  lingering += 1;
11326
11418
  }
@@ -11328,24 +11420,278 @@ var RemoteMeasureOverlay = class {
11328
11420
  this.host.requestRender();
11329
11421
  this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
11330
11422
  }
11331
- renderMeasurements(ctx) {
11332
- if (this.measurements.size === 0) return;
11423
+ render(ctx) {
11424
+ if (this.entries.size === 0) return;
11333
11425
  const now = this.now();
11334
- for (const entry of this.measurements.values()) {
11426
+ for (const entry of this.entries.values()) {
11335
11427
  let alpha = 1;
11336
11428
  if (entry.clearedAt !== null) {
11337
11429
  const fadeAge = now - entry.clearedAt - this.holdMs;
11338
11430
  if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
11339
11431
  }
11340
- drawMeasurement(ctx, entry, { alpha });
11432
+ this.draw(ctx, entry.entry, alpha);
11433
+ }
11434
+ }
11435
+ };
11436
+
11437
+ // src/canvas/remote-measure-overlay.ts
11438
+ var MEASURE_PRESENCE_KIND = "measure";
11439
+ function isFinitePoint2(value) {
11440
+ if (typeof value !== "object" || value === null) return false;
11441
+ const point = value;
11442
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11443
+ }
11444
+ function isMeasurePresence(data) {
11445
+ if (typeof data !== "object" || data === null) return false;
11446
+ const payload = data;
11447
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
11448
+ if ("cleared" in payload) return payload.cleared === true;
11449
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
11450
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
11451
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11452
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11453
+ return true;
11454
+ }
11455
+ function toMeasurePresence(emission) {
11456
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
11457
+ return {
11458
+ kind: MEASURE_PRESENCE_KIND,
11459
+ start: emission.start,
11460
+ end: emission.end,
11461
+ cells: emission.cells,
11462
+ feet: emission.feet,
11463
+ color: emission.color
11464
+ };
11465
+ }
11466
+ var DEFAULT_COLOR3 = "#FF5722";
11467
+ var RemoteMeasureOverlay = class {
11468
+ color;
11469
+ overlay;
11470
+ constructor(host, options = {}) {
11471
+ this.color = options.color ?? DEFAULT_COLOR3;
11472
+ this.overlay = new LingerOverlay(
11473
+ host,
11474
+ options,
11475
+ (ctx, entry, alpha) => drawMeasurement(ctx, entry, { alpha }),
11476
+ () => this.now()
11477
+ );
11478
+ }
11479
+ now() {
11480
+ return performance.now();
11481
+ }
11482
+ /**
11483
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11484
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
11485
+ * reported as `false`, so hosts can feed every presence frame through.
11486
+ */
11487
+ apply(sender, data) {
11488
+ if (this.overlay.disposed || !isMeasurePresence(data)) return false;
11489
+ if ("cleared" in data) {
11490
+ this.overlay.linger(sender);
11491
+ return true;
11492
+ }
11493
+ this.overlay.set(sender, {
11494
+ start: data.start,
11495
+ end: data.end,
11496
+ feet: data.feet,
11497
+ color: data.color ?? this.color
11498
+ });
11499
+ return true;
11500
+ }
11501
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11502
+ remove(sender) {
11503
+ this.overlay.remove(sender);
11504
+ }
11505
+ /** Removes every ruler immediately. */
11506
+ clear() {
11507
+ this.overlay.clear();
11508
+ }
11509
+ /** Number of senders with a visible (active or lingering) ruler. */
11510
+ get activeSenderCount() {
11511
+ return this.overlay.activeSenderCount;
11512
+ }
11513
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11514
+ dispose() {
11515
+ this.overlay.dispose();
11516
+ }
11517
+ };
11518
+
11519
+ // src/canvas/path-render.ts
11520
+ function resolveSegmentColors(cumulativeFeet, bands, color) {
11521
+ const sorted = [...bands].sort((a, b) => a.feet - b.feet);
11522
+ const out = [];
11523
+ for (let i = 1; i < cumulativeFeet.length; i++) {
11524
+ const end = cumulativeFeet[i] ?? 0;
11525
+ const band = sorted.find((b) => b.feet >= end);
11526
+ out.push(band ? band.color : color);
11527
+ }
11528
+ return out;
11529
+ }
11530
+ var LABEL_OFFSET_Y = 18;
11531
+ function drawPath(ctx, m, opts = {}) {
11532
+ const first = m.points[0];
11533
+ if (first === void 0) return;
11534
+ ctx.save();
11535
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
11536
+ ctx.lineWidth = 2;
11537
+ let prev = first;
11538
+ for (let i = 1; i < m.points.length; i++) {
11539
+ const next = m.points[i];
11540
+ if (next === void 0) break;
11541
+ ctx.strokeStyle = m.segmentColors[i - 1] ?? m.color;
11542
+ ctx.setLineDash([8, 4]);
11543
+ ctx.beginPath();
11544
+ ctx.moveTo(prev.x, prev.y);
11545
+ ctx.lineTo(next.x, next.y);
11546
+ ctx.stroke();
11547
+ prev = next;
11548
+ }
11549
+ ctx.setLineDash([]);
11550
+ ctx.fillStyle = m.color;
11551
+ for (const p of m.points) {
11552
+ ctx.beginPath();
11553
+ ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
11554
+ ctx.fill();
11555
+ }
11556
+ if (m.points.length >= 2) {
11557
+ const last = prev;
11558
+ const label = formatMeasureLabel(m.feet);
11559
+ ctx.font = "14px sans-serif";
11560
+ const metrics = ctx.measureText(label);
11561
+ const padX = 6;
11562
+ const padY = 4;
11563
+ const textH = 14;
11564
+ const cx = last.x;
11565
+ const cy = last.y - LABEL_OFFSET_Y;
11566
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11567
+ ctx.beginPath();
11568
+ ctx.roundRect(
11569
+ cx - metrics.width / 2 - padX,
11570
+ cy - textH / 2 - padY,
11571
+ metrics.width + padX * 2,
11572
+ textH + padY * 2,
11573
+ 4
11574
+ );
11575
+ ctx.fill();
11576
+ ctx.fillStyle = "#FFFFFF";
11577
+ ctx.textAlign = "center";
11578
+ ctx.textBaseline = "middle";
11579
+ ctx.fillText(label, cx, cy);
11580
+ }
11581
+ ctx.restore();
11582
+ }
11583
+
11584
+ // src/canvas/remote-path-overlay.ts
11585
+ var PATH_PRESENCE_KIND = "path";
11586
+ var PATH_PRESENCE_MAX_POINTS = 256;
11587
+ var MAX_COLOR_LENGTH = 64;
11588
+ var EPS = 1e-6;
11589
+ function isFinitePoint3(value) {
11590
+ if (typeof value !== "object" || value === null) return false;
11591
+ const point = value;
11592
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11593
+ }
11594
+ function isPathPresence(data) {
11595
+ if (typeof data !== "object" || data === null) return false;
11596
+ const payload = data;
11597
+ if (payload.kind !== PATH_PRESENCE_KIND) return false;
11598
+ if ("cleared" in payload) return payload.cleared === true;
11599
+ if (!Array.isArray(payload.points)) return false;
11600
+ if (payload.points.length === 0 || payload.points.length > PATH_PRESENCE_MAX_POINTS) return false;
11601
+ for (const point of payload.points) {
11602
+ if (!isFinitePoint3(point)) return false;
11603
+ }
11604
+ if (!Array.isArray(payload.segmentColors)) return false;
11605
+ if (payload.segmentColors.length !== payload.points.length - 1) return false;
11606
+ for (const color of payload.segmentColors) {
11607
+ if (typeof color !== "string" || color.length > MAX_COLOR_LENGTH) return false;
11608
+ }
11609
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11610
+ if (payload.color !== void 0) {
11611
+ if (typeof payload.color !== "string" || payload.color.length > MAX_COLOR_LENGTH) return false;
11612
+ }
11613
+ return true;
11614
+ }
11615
+ function samePoint(a, b) {
11616
+ return Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
11617
+ }
11618
+ function toPathPresence(emission) {
11619
+ if (emission === null) return { kind: PATH_PRESENCE_KIND, cleared: true };
11620
+ const points = emission.waypoints.map((point) => ({ x: point.x, y: point.y }));
11621
+ const last = points[points.length - 1];
11622
+ const cursor = emission.cursor;
11623
+ if (cursor && (!last || !samePoint(cursor, last))) points.push({ x: cursor.x, y: cursor.y });
11624
+ const cumulativeFeet = [0];
11625
+ let total = 0;
11626
+ for (let i = 1; i < points.length; i++) {
11627
+ total += emission.segments[i - 1]?.feet ?? 0;
11628
+ cumulativeFeet.push(total);
11629
+ }
11630
+ return {
11631
+ kind: PATH_PRESENCE_KIND,
11632
+ points,
11633
+ segmentColors: resolveSegmentColors(cumulativeFeet, emission.rangeBands, emission.color),
11634
+ feet: emission.totalFeet,
11635
+ color: emission.color
11636
+ };
11637
+ }
11638
+ var DEFAULT_COLOR4 = "#FF5722";
11639
+ var RemotePathOverlay = class {
11640
+ color;
11641
+ overlay;
11642
+ constructor(host, options = {}) {
11643
+ this.color = options.color ?? DEFAULT_COLOR4;
11644
+ this.overlay = new LingerOverlay(
11645
+ host,
11646
+ options,
11647
+ (ctx, entry, alpha) => drawPath(ctx, entry, { alpha }),
11648
+ () => this.now()
11649
+ );
11650
+ }
11651
+ now() {
11652
+ return performance.now();
11653
+ }
11654
+ /**
11655
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11656
+ * the envelope `from`). Non-path or malformed payloads are ignored and
11657
+ * reported as `false`, so hosts can feed every presence frame through.
11658
+ */
11659
+ apply(sender, data) {
11660
+ if (this.overlay.disposed || !isPathPresence(data)) return false;
11661
+ if ("cleared" in data) {
11662
+ this.overlay.linger(sender);
11663
+ return true;
11341
11664
  }
11665
+ this.overlay.set(sender, {
11666
+ points: data.points,
11667
+ segmentColors: data.segmentColors,
11668
+ feet: data.feet,
11669
+ color: data.color ?? this.color
11670
+ });
11671
+ return true;
11672
+ }
11673
+ /** Removes a sender's path immediately (presence-leave/disconnect). */
11674
+ remove(sender) {
11675
+ this.overlay.remove(sender);
11676
+ }
11677
+ /** Removes every path immediately. */
11678
+ clear() {
11679
+ this.overlay.clear();
11680
+ }
11681
+ /** Number of senders with a visible (active or lingering) path. */
11682
+ get activeSenderCount() {
11683
+ return this.overlay.activeSenderCount;
11684
+ }
11685
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11686
+ dispose() {
11687
+ this.overlay.dispose();
11342
11688
  }
11343
11689
  };
11344
11690
 
11345
11691
  // src/canvas/ping-input.ts
11346
11692
  var DEFAULT_LONG_PRESS_MS = 600;
11347
11693
  var DEFAULT_SLOP_PX = 8;
11348
- var DEFAULT_COLOR4 = "#ff3b30";
11694
+ var DEFAULT_COLOR5 = "#ff3b30";
11349
11695
  var DEFAULT_DURATION_MS2 = 1800;
11350
11696
  var DEFAULT_RADIUS2 = 48;
11351
11697
  var DEFAULT_MIN_INTERVAL_MS = 300;
@@ -11378,7 +11724,7 @@ var PingInput = class {
11378
11724
  this.longPressEnabled = options.longPressEnabled ?? false;
11379
11725
  this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
11380
11726
  this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
11381
- this.color = options.color ?? DEFAULT_COLOR4;
11727
+ this.color = options.color ?? DEFAULT_COLOR5;
11382
11728
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
11383
11729
  this.radius = options.radius ?? DEFAULT_RADIUS2;
11384
11730
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -12714,7 +13060,8 @@ var SelectTool = class {
12714
13060
  } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
12715
13061
  const centerX = el.position.x + el.size.w / 2 + adjDx;
12716
13062
  const centerY = el.position.y + el.size.h / 2 + adjDy;
12717
- const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
13063
+ const footprint = footprintFromSize(el.size, ctx.gridSize ?? 0);
13064
+ const snappedCenter = snapFootprintCenter({ x: centerX, y: centerY }, footprint, ctx);
12718
13065
  ctx.store.update(id, {
12719
13066
  position: {
12720
13067
  x: snappedCenter.x - el.size.w / 2,
@@ -13292,15 +13639,15 @@ var ShapeTool = class {
13292
13639
  }
13293
13640
  onActivate(_ctx) {
13294
13641
  if (typeof window !== "undefined") {
13295
- window.addEventListener("keydown", this.onKeyDown);
13296
- window.addEventListener("keyup", this.onKeyUp);
13642
+ window.addEventListener("keydown", this.trackShiftKeyDown);
13643
+ window.addEventListener("keyup", this.trackShiftKeyUp);
13297
13644
  }
13298
13645
  }
13299
13646
  onDeactivate(_ctx) {
13300
13647
  this.shiftHeld = false;
13301
13648
  if (typeof window !== "undefined") {
13302
- window.removeEventListener("keydown", this.onKeyDown);
13303
- window.removeEventListener("keyup", this.onKeyUp);
13649
+ window.removeEventListener("keydown", this.trackShiftKeyDown);
13650
+ window.removeEventListener("keyup", this.trackShiftKeyUp);
13304
13651
  }
13305
13652
  }
13306
13653
  onPointerDown(state, ctx) {
@@ -13393,10 +13740,13 @@ var ShapeTool = class {
13393
13740
  snap(point, ctx) {
13394
13741
  return smartSnap(point, ctx);
13395
13742
  }
13396
- onKeyDown = (e) => {
13743
+ // Deliberately NOT migrated to `Tool.onKeyDown`: that hook is gated by
13744
+ // editable-target and keyboard-scope checks, and a Shift release must be
13745
+ // observed wherever it happens or the constraint sticks after the key is up.
13746
+ trackShiftKeyDown = (e) => {
13397
13747
  if (e.key === "Shift") this.shiftHeld = true;
13398
13748
  };
13399
- onKeyUp = (e) => {
13749
+ trackShiftKeyUp = (e) => {
13400
13750
  if (e.key === "Shift") this.shiftHeld = false;
13401
13751
  };
13402
13752
  };
@@ -13540,6 +13890,353 @@ var MeasureTool = class {
13540
13890
  }
13541
13891
  };
13542
13892
 
13893
+ // src/tools/path-tool.ts
13894
+ var EPS2 = 1e-6;
13895
+ var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
13896
+ function samePoint2(a, b) {
13897
+ return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
13898
+ }
13899
+ var PathTool = class {
13900
+ name = "path";
13901
+ feetPerCell;
13902
+ color;
13903
+ diagonalRule;
13904
+ footprintOption;
13905
+ rangeBands;
13906
+ commitTapRadiusPx;
13907
+ resolveStart;
13908
+ waypoints = [];
13909
+ cursor = null;
13910
+ anchorKey;
13911
+ footprint = 1;
13912
+ pointerDown = false;
13913
+ commitOnUp = false;
13914
+ // Grid state is captured at the opening pointer-down so a path measured on
13915
+ // one grid keeps that metric even if the host reconfigures mid-gesture.
13916
+ gridSize = 0;
13917
+ gridType;
13918
+ hexOrientation;
13919
+ snapEnabled = false;
13920
+ optionListeners = /* @__PURE__ */ new Set();
13921
+ pathListeners = /* @__PURE__ */ new Set();
13922
+ commitListeners = /* @__PURE__ */ new Set();
13923
+ emissionRafId = null;
13924
+ constructor(options = {}) {
13925
+ this.feetPerCell = options.feetPerCell ?? 5;
13926
+ this.color = options.color ?? "#FF5722";
13927
+ this.diagonalRule = options.diagonalRule ?? "euclidean";
13928
+ this.footprintOption = options.footprint ?? 1;
13929
+ this.rangeBands = options.rangeBands ?? [];
13930
+ this.commitTapRadiusPx = options.commitTapRadiusPx ?? DEFAULT_COMMIT_TAP_RADIUS_PX;
13931
+ this.resolveStart = options.resolveStart;
13932
+ }
13933
+ getOptions() {
13934
+ return {
13935
+ feetPerCell: this.feetPerCell,
13936
+ color: this.color,
13937
+ diagonalRule: this.diagonalRule,
13938
+ footprint: this.footprintOption,
13939
+ rangeBands: this.rangeBands,
13940
+ commitTapRadiusPx: this.commitTapRadiusPx,
13941
+ resolveStart: this.resolveStart
13942
+ };
13943
+ }
13944
+ setOptions(options) {
13945
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
13946
+ if (options.color !== void 0) this.color = options.color;
13947
+ if (options.diagonalRule !== void 0) this.diagonalRule = options.diagonalRule;
13948
+ if (options.footprint !== void 0) this.footprintOption = options.footprint;
13949
+ if (options.rangeBands !== void 0) this.rangeBands = options.rangeBands;
13950
+ if (options.commitTapRadiusPx !== void 0) {
13951
+ this.commitTapRadiusPx = options.commitTapRadiusPx;
13952
+ }
13953
+ if (options.resolveStart !== void 0) this.resolveStart = options.resolveStart;
13954
+ this.notifyOptionsChange();
13955
+ }
13956
+ onOptionsChange(listener) {
13957
+ this.optionListeners.add(listener);
13958
+ return () => this.optionListeners.delete(listener);
13959
+ }
13960
+ /**
13961
+ * Subscribes to raf-coalesced path snapshots. While a path is open,
13962
+ * listeners receive at most one snapshot per animation frame carrying the
13963
+ * latest state; `null` is delivered synchronously when the path closes
13964
+ * (commit, cancel, or deactivate).
13965
+ */
13966
+ onPath(listener) {
13967
+ this.pathListeners.add(listener);
13968
+ return () => this.pathListeners.delete(listener);
13969
+ }
13970
+ /**
13971
+ * Subscribes to finished paths. The emission carries `cursor: null` and the
13972
+ * final waypoints; applying the move (and recording history for it) is the
13973
+ * host's job — the tool has already forgotten the path by then.
13974
+ */
13975
+ onCommit(listener) {
13976
+ this.commitListeners.add(listener);
13977
+ return () => this.commitListeners.delete(listener);
13978
+ }
13979
+ get isOpen() {
13980
+ return this.waypoints.length > 0;
13981
+ }
13982
+ /** Synchronous snapshot of the open path; `null` when no path is open. */
13983
+ getEmission() {
13984
+ if (!this.isOpen) return null;
13985
+ return this.buildEmission(this.cursor);
13986
+ }
13987
+ onPointerDown(state, ctx) {
13988
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
13989
+ if (!this.isOpen) {
13990
+ const anchor = this.resolveStart ? this.resolveStart(world, ctx) : { origin: world };
13991
+ if (!anchor) return;
13992
+ this.gridSize = ctx.gridSize ?? 0;
13993
+ this.gridType = ctx.gridType;
13994
+ this.hexOrientation = ctx.hexOrientation;
13995
+ this.snapEnabled = ctx.snapToGrid === true;
13996
+ this.footprint = anchor.footprint ?? this.footprintOption;
13997
+ const origin = this.snap(anchor.origin);
13998
+ this.waypoints = [origin];
13999
+ this.cursor = { ...origin };
14000
+ this.anchorKey = anchor.anchorKey;
14001
+ this.pointerDown = true;
14002
+ this.scheduleEmission();
14003
+ ctx.requestRender();
14004
+ return;
14005
+ }
14006
+ const point = this.snap(world);
14007
+ const last = this.lastWaypoint();
14008
+ if (last && this.withinCommitRadius(point, last, ctx)) {
14009
+ this.commitOnUp = true;
14010
+ this.cursor = { ...last };
14011
+ } else {
14012
+ this.cursor = point;
14013
+ }
14014
+ this.pointerDown = true;
14015
+ this.scheduleEmission();
14016
+ ctx.requestRender();
14017
+ }
14018
+ onPointerMove(state, ctx) {
14019
+ if (!this.isOpen) return;
14020
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14021
+ const point = this.snap(world);
14022
+ const last = this.lastWaypoint();
14023
+ if (this.commitOnUp && last && this.withinCommitRadius(point, last, ctx)) {
14024
+ this.cursor = { ...last };
14025
+ } else {
14026
+ this.commitOnUp = false;
14027
+ this.cursor = point;
14028
+ }
14029
+ this.scheduleEmission();
14030
+ ctx.requestRender();
14031
+ }
14032
+ onPointerUp(_state, ctx) {
14033
+ if (!this.isOpen) return;
14034
+ this.pointerDown = false;
14035
+ if (this.commitOnUp) {
14036
+ this.commit(ctx);
14037
+ return;
14038
+ }
14039
+ const last = this.lastWaypoint();
14040
+ if (this.cursor && last && !samePoint2(this.cursor, last)) {
14041
+ this.waypoints.push({ ...this.cursor });
14042
+ }
14043
+ this.scheduleEmission();
14044
+ ctx.requestRender();
14045
+ }
14046
+ onHover(state, ctx) {
14047
+ if (!this.isOpen || this.pointerDown) return;
14048
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14049
+ this.cursor = this.snap(world);
14050
+ this.scheduleEmission();
14051
+ ctx.requestRender();
14052
+ }
14053
+ /** A takeover (second pointer, platform cancel) abandons the path outright. */
14054
+ onPointerCancel(_state, ctx) {
14055
+ this.cancel(ctx);
14056
+ }
14057
+ onDeactivate(ctx) {
14058
+ this.cancel(ctx);
14059
+ }
14060
+ onKeyDown(event, ctx) {
14061
+ if (!this.isOpen) return false;
14062
+ if (event.key === "Enter") {
14063
+ this.commit(ctx);
14064
+ return true;
14065
+ }
14066
+ if (event.key === "Escape") {
14067
+ this.cancel(ctx);
14068
+ return true;
14069
+ }
14070
+ return false;
14071
+ }
14072
+ renderOverlay(ctx) {
14073
+ if (!this.isOpen) return;
14074
+ const { points, cumulative, total } = this.measure(this.cursor);
14075
+ const cumulativeFeet = cumulative.map((cells) => cells * this.feetPerCell);
14076
+ drawPath(ctx, {
14077
+ points,
14078
+ segmentColors: resolveSegmentColors(cumulativeFeet, this.rangeBands, this.color),
14079
+ color: this.color,
14080
+ feet: total * this.feetPerCell
14081
+ });
14082
+ }
14083
+ lastWaypoint() {
14084
+ return this.waypoints[this.waypoints.length - 1];
14085
+ }
14086
+ /**
14087
+ * Is `point` close enough to `last` to mean "finish here"? EXACT match always
14088
+ * qualifies. The screen-space tolerance is added ONLY when this path has no
14089
+ * active snapping grid: on a snapping grid (`gridType === 'square'`, or
14090
+ * `'hex'` with a captured orientation, and `gridSize > 0`) the adjacent
14091
+ * cell/hex centre can be as little as one grid cell away, and a radius
14092
+ * computed from CSS pixels has no relationship to that distance — at a
14093
+ * sufficiently zoomed-out camera the radius would swallow a whole neighbour
14094
+ * cell and turn a deliberate next-waypoint tap into an unwanted commit.
14095
+ * Snapping already makes a fingertip tap land exactly on the last waypoint,
14096
+ * so the tolerance is unnecessary there; it exists for the gridless and
14097
+ * `snapPoint`/identity paths, where a fingertip can never land on an exact
14098
+ * world point.
14099
+ */
14100
+ withinCommitRadius(point, last, ctx) {
14101
+ if (samePoint2(point, last)) return true;
14102
+ if (this.hasSnappingGrid()) return false;
14103
+ const zoom = ctx.camera.zoom;
14104
+ if (!(zoom > 0)) return false;
14105
+ const radius = this.commitTapRadiusPx / zoom;
14106
+ if (radius <= 0) return false;
14107
+ const dx = point.x - last.x;
14108
+ const dy = point.y - last.y;
14109
+ return dx * dx + dy * dy <= radius * radius;
14110
+ }
14111
+ /**
14112
+ * True when this path's captured grid state snaps every waypoint onto a
14113
+ * cell/hex centre: a `square` grid, or a `hex` grid with a captured
14114
+ * orientation, both with a usable `gridSize`. Mirrors the unconditional
14115
+ * branches of `snap` below — NOT the `snapToGrid`-gated fallback, which
14116
+ * leaves waypoints unsnapped when the user turns snapping off.
14117
+ */
14118
+ hasSnappingGrid() {
14119
+ if (!(this.gridSize > 0)) return false;
14120
+ if (this.gridType === "square") return true;
14121
+ return this.gridType === "hex" && this.hexOrientation !== void 0;
14122
+ }
14123
+ /**
14124
+ * Snapping to cell or hex centres is UNCONDITIONAL for a `square` grid, or a
14125
+ * `hex` grid WITH a captured orientation: a movement path measures in cells,
14126
+ * so an unsnapped waypoint would report a distance the grid does not agree
14127
+ * with. A `hex` grid WITHOUT an orientation falls through to the
14128
+ * `snapToGrid`-gated `snapPoint` (intersection) branch below, same as the
14129
+ * gridType-less case — unlike `smartSnap`, which is off entirely when the
14130
+ * user turns snapping off.
14131
+ */
14132
+ snap(point) {
14133
+ if (this.gridSize <= 0) return point;
14134
+ if (this.gridType === "hex" && this.hexOrientation) {
14135
+ return snapToHexCenter(point, this.gridSize, this.hexOrientation);
14136
+ }
14137
+ if (this.gridType === "square") {
14138
+ return snapToCellCenter(point, this.gridSize, this.footprint);
14139
+ }
14140
+ if (this.snapEnabled) {
14141
+ return snapPoint(point, this.gridSize);
14142
+ }
14143
+ return point;
14144
+ }
14145
+ /** The measured polyline: waypoints, plus the cursor when it adds a leg. */
14146
+ measure(cursor) {
14147
+ const points = this.waypoints.map((p) => ({ ...p }));
14148
+ const last = points[points.length - 1];
14149
+ if (cursor && (!last || !samePoint2(cursor, last))) points.push({ ...cursor });
14150
+ const { total, cumulative } = pathDistanceCells(points, {
14151
+ gridSize: this.gridSize,
14152
+ gridType: this.gridType,
14153
+ hexOrientation: this.hexOrientation,
14154
+ diagonalRule: this.diagonalRule
14155
+ });
14156
+ return { points, cumulative, total };
14157
+ }
14158
+ buildEmission(cursor) {
14159
+ const { cumulative, total } = this.measure(cursor);
14160
+ const segments = [];
14161
+ for (let i = 1; i < cumulative.length; i++) {
14162
+ const cells = (cumulative[i] ?? 0) - (cumulative[i - 1] ?? 0);
14163
+ segments.push({ cells, feet: cells * this.feetPerCell });
14164
+ }
14165
+ return {
14166
+ anchorKey: this.anchorKey,
14167
+ waypoints: this.waypoints.map((p) => ({ ...p })),
14168
+ cursor: cursor ? { ...cursor } : null,
14169
+ segments,
14170
+ totalCells: total,
14171
+ totalFeet: total * this.feetPerCell,
14172
+ color: this.color,
14173
+ rangeBands: this.rangeBands
14174
+ };
14175
+ }
14176
+ commit(ctx) {
14177
+ if (this.waypoints.length < 2) {
14178
+ this.cancel(ctx);
14179
+ return;
14180
+ }
14181
+ const emission = this.buildEmission(null);
14182
+ this.reset();
14183
+ this.emitCommit(emission);
14184
+ this.emitClear();
14185
+ ctx.requestRender();
14186
+ }
14187
+ cancel(ctx) {
14188
+ if (!this.isOpen) return;
14189
+ this.reset();
14190
+ this.emitClear();
14191
+ ctx.requestRender();
14192
+ }
14193
+ reset() {
14194
+ this.waypoints = [];
14195
+ this.cursor = null;
14196
+ this.anchorKey = void 0;
14197
+ this.footprint = this.footprintOption;
14198
+ this.pointerDown = false;
14199
+ this.commitOnUp = false;
14200
+ }
14201
+ notifyOptionsChange() {
14202
+ for (const listener of this.optionListeners) listener();
14203
+ }
14204
+ scheduleEmission() {
14205
+ if (this.pathListeners.size === 0) return;
14206
+ if (this.emissionRafId !== null) return;
14207
+ this.emissionRafId = requestAnimationFrame(() => {
14208
+ this.emissionRafId = null;
14209
+ const emission = this.getEmission();
14210
+ if (!emission) return;
14211
+ this.emitPath(emission);
14212
+ });
14213
+ }
14214
+ emitClear() {
14215
+ if (this.emissionRafId !== null) {
14216
+ cancelAnimationFrame(this.emissionRafId);
14217
+ this.emissionRafId = null;
14218
+ }
14219
+ if (this.pathListeners.size === 0) return;
14220
+ this.emitPath(null);
14221
+ }
14222
+ emitPath(emission) {
14223
+ for (const listener of this.pathListeners) {
14224
+ try {
14225
+ listener(emission);
14226
+ } catch {
14227
+ }
14228
+ }
14229
+ }
14230
+ emitCommit(emission) {
14231
+ for (const listener of this.commitListeners) {
14232
+ try {
14233
+ listener(emission);
14234
+ } catch {
14235
+ }
14236
+ }
14237
+ }
14238
+ };
14239
+
13543
14240
  // src/tools/template-tool.ts
13544
14241
  var MIN_RECT_WIDTH = 20;
13545
14242
  function defaultRectWidth(radius, scaleUnit) {
@@ -13833,7 +14530,7 @@ var TemplateTool = class {
13833
14530
  };
13834
14531
 
13835
14532
  // src/tools/laser-tool.ts
13836
- var DEFAULT_COLOR5 = "#ff3b30";
14533
+ var DEFAULT_COLOR6 = "#ff3b30";
13837
14534
  var DEFAULT_WIDTH3 = 4;
13838
14535
  var DEFAULT_FADE_MS3 = 1200;
13839
14536
  var LaserTool = class {
@@ -13849,7 +14546,7 @@ var LaserTool = class {
13849
14546
  pendingEmission = [];
13850
14547
  constructor(options = {}) {
13851
14548
  this.name = options.name ?? "laser";
13852
- this.color = options.color ?? DEFAULT_COLOR5;
14549
+ this.color = options.color ?? DEFAULT_COLOR6;
13853
14550
  this.width = options.width ?? DEFAULT_WIDTH3;
13854
14551
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
13855
14552
  }
@@ -13977,7 +14674,7 @@ var LaserTool = class {
13977
14674
  };
13978
14675
 
13979
14676
  // src/tools/ping-tool.ts
13980
- var DEFAULT_COLOR6 = "#ff3b30";
14677
+ var DEFAULT_COLOR7 = "#ff3b30";
13981
14678
  var DEFAULT_DURATION_MS4 = 1800;
13982
14679
  var DEFAULT_RADIUS4 = 48;
13983
14680
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -13994,7 +14691,7 @@ var PingTool = class {
13994
14691
  pingListeners = /* @__PURE__ */ new Set();
13995
14692
  constructor(options = {}) {
13996
14693
  this.name = options.name ?? "ping";
13997
- this.color = options.color ?? DEFAULT_COLOR6;
14694
+ this.color = options.color ?? DEFAULT_COLOR7;
13998
14695
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
13999
14696
  this.radius = options.radius ?? DEFAULT_RADIUS4;
14000
14697
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -14097,7 +14794,7 @@ var PingTool = class {
14097
14794
  };
14098
14795
 
14099
14796
  // src/index.ts
14100
- var VERSION = "0.63.0";
14797
+ var VERSION = "0.64.0";
14101
14798
  // Annotate the CommonJS export names for ESM import in node:
14102
14799
  0 && (module.exports = {
14103
14800
  ArrowTool,
@@ -14124,13 +14821,17 @@ var VERSION = "0.63.0";
14124
14821
  MemoryAdapter,
14125
14822
  MinimapController,
14126
14823
  NoteTool,
14824
+ PATH_PRESENCE_KIND,
14825
+ PATH_PRESENCE_MAX_POINTS,
14127
14826
  PING_PRESENCE_KIND,
14827
+ PathTool,
14128
14828
  PencilTool,
14129
14829
  PingInput,
14130
14830
  PingTool,
14131
14831
  RemoteFocusReceiver,
14132
14832
  RemoteLaserOverlay,
14133
14833
  RemoteMeasureOverlay,
14834
+ RemotePathOverlay,
14134
14835
  RemotePingOverlay,
14135
14836
  SelectTool,
14136
14837
  ShapeTool,
@@ -14158,6 +14859,7 @@ var VERSION = "0.63.0";
14158
14859
  exportImage,
14159
14860
  exportSvg,
14160
14861
  fitZoomForView,
14862
+ footprintFromSize,
14161
14863
  getActiveFormats,
14162
14864
  getArrowBounds,
14163
14865
  getArrowControlPoint,
@@ -14173,20 +14875,26 @@ var VERSION = "0.63.0";
14173
14875
  getHexCellsInRectangle,
14174
14876
  getHexCellsInSquare,
14175
14877
  getHexDistance,
14878
+ gridDistanceCells,
14176
14879
  isFocusPresence,
14177
14880
  isLaserTrailPresence,
14178
14881
  isMeasurePresence,
14179
14882
  isNearBezier,
14883
+ isPathPresence,
14180
14884
  isPingPresence,
14885
+ pathDistanceCells,
14181
14886
  resolveHtmlRouting,
14182
14887
  setFontSize,
14183
14888
  smartSnap,
14889
+ snapFootprintCenter,
14184
14890
  snapPoint,
14891
+ snapToCellCenter,
14185
14892
  snapToHexCenter,
14186
14893
  styleToPatch,
14187
14894
  toFocusPresence,
14188
14895
  toLaserTrailPresence,
14189
14896
  toMeasurePresence,
14897
+ toPathPresence,
14190
14898
  toPingPresence,
14191
14899
  toggleBold,
14192
14900
  toggleItalic,