@fieldnotes/core 0.63.0 → 0.65.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
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AWARENESS_MAX_SELECTION: () => AWARENESS_MAX_SELECTION,
24
+ AWARENESS_PRESENCE_KIND: () => AWARENESS_PRESENCE_KIND,
23
25
  ArrowTool: () => ArrowTool,
24
26
  AutoSave: () => AutoSave,
25
27
  Camera: () => Camera,
@@ -38,20 +40,29 @@ __export(index_exports, {
38
40
  LASER_TRAIL_PRESENCE_KIND: () => LASER_TRAIL_PRESENCE_KIND,
39
41
  LaserTool: () => LaserTool,
40
42
  LayerManager: () => LayerManager,
43
+ LocalAwareness: () => LocalAwareness,
41
44
  LocalStorageAdapter: () => LocalStorageAdapter,
42
45
  MEASURE_PRESENCE_KIND: () => MEASURE_PRESENCE_KIND,
43
46
  MeasureTool: () => MeasureTool,
44
47
  MemoryAdapter: () => MemoryAdapter,
45
48
  MinimapController: () => MinimapController,
46
49
  NoteTool: () => NoteTool,
50
+ PATH_PRESENCE_KIND: () => PATH_PRESENCE_KIND,
51
+ PATH_PRESENCE_MAX_POINTS: () => PATH_PRESENCE_MAX_POINTS,
52
+ PEER_COLORS: () => PEER_COLORS,
47
53
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
54
+ PathTool: () => PathTool,
55
+ PeerRoster: () => PeerRoster,
48
56
  PencilTool: () => PencilTool,
49
57
  PingInput: () => PingInput,
50
58
  PingTool: () => PingTool,
59
+ RemoteCursorOverlay: () => RemoteCursorOverlay,
51
60
  RemoteFocusReceiver: () => RemoteFocusReceiver,
52
61
  RemoteLaserOverlay: () => RemoteLaserOverlay,
53
62
  RemoteMeasureOverlay: () => RemoteMeasureOverlay,
63
+ RemotePathOverlay: () => RemotePathOverlay,
54
64
  RemotePingOverlay: () => RemotePingOverlay,
65
+ RemoteSelectionOverlay: () => RemoteSelectionOverlay,
55
66
  SelectTool: () => SelectTool,
56
67
  ShapeTool: () => ShapeTool,
57
68
  TemplateTool: () => TemplateTool,
@@ -60,6 +71,7 @@ __export(index_exports, {
60
71
  VERSION: () => VERSION,
61
72
  Viewport: () => Viewport,
62
73
  applyCameraView: () => applyCameraView,
74
+ attachAwareness: () => attachAwareness,
63
75
  boundsIntersect: () => boundsIntersect,
64
76
  cameraOriginForView: () => cameraOriginForView,
65
77
  captureCameraView: () => captureCameraView,
@@ -73,11 +85,13 @@ __export(index_exports, {
73
85
  createStroke: () => createStroke,
74
86
  createTemplate: () => createTemplate,
75
87
  createText: () => createText,
88
+ defaultPeerColor: () => defaultPeerColor,
76
89
  drawHexPath: () => drawHexPath,
77
90
  elementRectsEqual: () => elementRectsEqual,
78
91
  exportImage: () => exportImage,
79
92
  exportSvg: () => exportSvg,
80
93
  fitZoomForView: () => fitZoomForView,
94
+ footprintFromSize: () => footprintFromSize,
81
95
  getActiveFormats: () => getActiveFormats,
82
96
  getArrowBounds: () => getArrowBounds,
83
97
  getArrowControlPoint: () => getArrowControlPoint,
@@ -93,20 +107,27 @@ __export(index_exports, {
93
107
  getHexCellsInRectangle: () => getHexCellsInRectangle,
94
108
  getHexCellsInSquare: () => getHexCellsInSquare,
95
109
  getHexDistance: () => getHexDistance,
110
+ gridDistanceCells: () => gridDistanceCells,
111
+ isAwarenessPresence: () => isAwarenessPresence,
96
112
  isFocusPresence: () => isFocusPresence,
97
113
  isLaserTrailPresence: () => isLaserTrailPresence,
98
114
  isMeasurePresence: () => isMeasurePresence,
99
115
  isNearBezier: () => isNearBezier,
116
+ isPathPresence: () => isPathPresence,
100
117
  isPingPresence: () => isPingPresence,
118
+ pathDistanceCells: () => pathDistanceCells,
101
119
  resolveHtmlRouting: () => resolveHtmlRouting,
102
120
  setFontSize: () => setFontSize,
103
121
  smartSnap: () => smartSnap,
122
+ snapFootprintCenter: () => snapFootprintCenter,
104
123
  snapPoint: () => snapPoint,
124
+ snapToCellCenter: () => snapToCellCenter,
105
125
  snapToHexCenter: () => snapToHexCenter,
106
126
  styleToPatch: () => styleToPatch,
107
127
  toFocusPresence: () => toFocusPresence,
108
128
  toLaserTrailPresence: () => toLaserTrailPresence,
109
129
  toMeasurePresence: () => toMeasurePresence,
130
+ toPathPresence: () => toPathPresence,
110
131
  toPingPresence: () => toPingPresence,
111
132
  toggleBold: () => toggleBold,
112
133
  toggleItalic: () => toggleItalic,
@@ -146,6 +167,310 @@ function smartSnap(point, ctx) {
146
167
  }
147
168
  return snapPoint(point, ctx.gridSize);
148
169
  }
170
+ function footprintOf(footprint) {
171
+ return typeof footprint === "number" ? { w: footprint, h: footprint } : footprint;
172
+ }
173
+ function footprintFromSize(size, gridSize) {
174
+ if (!(gridSize > 0)) return 1;
175
+ return {
176
+ w: Math.max(1, Math.round(size.w / gridSize)),
177
+ h: Math.max(1, Math.round(size.h / gridSize))
178
+ };
179
+ }
180
+ function snapAxisToCell(value, gridSize, cells) {
181
+ const n2 = Math.max(1, Math.round(cells));
182
+ if (n2 % 2 === 0) return Math.round(value / gridSize) * gridSize || 0;
183
+ return (Math.round((value - gridSize / 2) / gridSize) + 0.5) * gridSize || 0;
184
+ }
185
+ function snapToCellCenter(point, gridSize, footprint = 1) {
186
+ const { w, h } = footprintOf(footprint);
187
+ return { x: snapAxisToCell(point.x, gridSize, w), y: snapAxisToCell(point.y, gridSize, h) };
188
+ }
189
+ function snapFootprintCenter(point, footprint, ctx) {
190
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
191
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
192
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
193
+ }
194
+ return snapToCellCenter(point, ctx.gridSize, footprint);
195
+ }
196
+
197
+ // src/elements/hex-fill.ts
198
+ function offsetToCube(col, row, orientation) {
199
+ if (orientation === "pointy") {
200
+ return { q: col - (row - (row & 1)) / 2, r: row };
201
+ }
202
+ return { q: col, r: row - (col - (col & 1)) / 2 };
203
+ }
204
+ function cubeToOffset(q, r, orientation) {
205
+ if (orientation === "pointy") {
206
+ return { col: q + (r - (r & 1)) / 2, row: r };
207
+ }
208
+ return { col: q, row: r + (q - (q & 1)) / 2 };
209
+ }
210
+ function offsetToPixel(col, row, cellSize, orientation) {
211
+ if (orientation === "pointy") {
212
+ const hexW = Math.sqrt(3) * cellSize;
213
+ const rowH = 1.5 * cellSize;
214
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
215
+ return { x: col * hexW + offsetX, y: row * rowH };
216
+ }
217
+ const hexH = Math.sqrt(3) * cellSize;
218
+ const colW = 1.5 * cellSize;
219
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
220
+ return { x: col * colW, y: row * hexH + offsetY };
221
+ }
222
+ function pixelToOffset(x, y, cellSize, orientation) {
223
+ if (orientation === "pointy") {
224
+ const hexW = Math.sqrt(3) * cellSize;
225
+ const rowH = 1.5 * cellSize;
226
+ const row = Math.round(y / rowH);
227
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
228
+ return { col: Math.round((x - offsetX) / hexW), row };
229
+ }
230
+ const hexH = Math.sqrt(3) * cellSize;
231
+ const colW = 1.5 * cellSize;
232
+ const col = Math.round(x / colW);
233
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
234
+ return { col, row: Math.round((y - offsetY) / hexH) };
235
+ }
236
+ function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
237
+ const cells = [];
238
+ for (let dq = -n2; dq <= n2; dq++) {
239
+ const rMin = Math.max(-n2, -dq - n2);
240
+ const rMax = Math.min(n2, -dq + n2);
241
+ for (let dr = rMin; dr <= rMax; dr++) {
242
+ const absQ = centerQ + dq;
243
+ const absR = centerR + dr;
244
+ const off = cubeToOffset(absQ, absR, orientation);
245
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
246
+ }
247
+ }
248
+ return cells;
249
+ }
250
+ function getHexDistance(a, b, cellSize, orientation) {
251
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
252
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
253
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
254
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
255
+ const dq = cubeA.q - cubeB.q;
256
+ const dr = cubeA.r - cubeB.r;
257
+ const ds = -dq - dr;
258
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
259
+ }
260
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
261
+ const n2 = Math.round(radiusCells);
262
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
263
+ const cube = offsetToCube(off.col, off.row, orientation);
264
+ if (n2 <= 0) {
265
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
266
+ }
267
+ return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
268
+ }
269
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
270
+ const n2 = Math.round(radiusCells);
271
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
272
+ const cube = offsetToCube(off.col, off.row, orientation);
273
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
274
+ if (n2 <= 0) return [centerPixel];
275
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
276
+ const step = Math.PI / 3;
277
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
278
+ const halfAngle = Math.PI / 6 + 1e-6;
279
+ const cells = [centerPixel];
280
+ for (let dq = -n2; dq <= n2; dq++) {
281
+ const rMin = Math.max(-n2, -dq - n2);
282
+ const rMax = Math.min(n2, -dq + n2);
283
+ for (let dr = rMin; dr <= rMax; dr++) {
284
+ if (dq === 0 && dr === 0) continue;
285
+ const absQ = cube.q + dq;
286
+ const absR = cube.r + dr;
287
+ const pixel = offsetToPixel(
288
+ cubeToOffset(absQ, absR, orientation).col,
289
+ cubeToOffset(absQ, absR, orientation).row,
290
+ cellSize,
291
+ orientation
292
+ );
293
+ const dx = pixel.x - centerPixel.x;
294
+ const dy = pixel.y - centerPixel.y;
295
+ let diff = Math.atan2(dy, dx) - snappedAngle;
296
+ if (diff > Math.PI) diff -= 2 * Math.PI;
297
+ if (diff < -Math.PI) diff += 2 * Math.PI;
298
+ if (Math.abs(diff) <= halfAngle) {
299
+ cells.push(pixel);
300
+ }
301
+ }
302
+ }
303
+ return cells;
304
+ }
305
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
306
+ const n2 = Math.round(radiusCells);
307
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
308
+ const cube = offsetToCube(off.col, off.row, orientation);
309
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
310
+ if (n2 <= 0) return [centerPixel];
311
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
312
+ const step = Math.PI / 3;
313
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
314
+ const cos = Math.cos(snappedAngle);
315
+ const sin = Math.sin(snappedAngle);
316
+ const snapUnit = Math.sqrt(3) * cellSize;
317
+ const lineLength = n2 * snapUnit;
318
+ const halfWidth = snapUnit * 0.5 + 1e-6;
319
+ const cells = [];
320
+ for (let dq = -n2; dq <= n2; dq++) {
321
+ const rMin = Math.max(-n2, -dq - n2);
322
+ const rMax = Math.min(n2, -dq + n2);
323
+ for (let dr = rMin; dr <= rMax; dr++) {
324
+ const absQ = cube.q + dq;
325
+ const absR = cube.r + dr;
326
+ const pixel = offsetToPixel(
327
+ cubeToOffset(absQ, absR, orientation).col,
328
+ cubeToOffset(absQ, absR, orientation).row,
329
+ cellSize,
330
+ orientation
331
+ );
332
+ const dx = pixel.x - centerPixel.x;
333
+ const dy = pixel.y - centerPixel.y;
334
+ const along = dx * cos + dy * sin;
335
+ const perp = Math.abs(-dx * sin + dy * cos);
336
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
337
+ cells.push(pixel);
338
+ }
339
+ }
340
+ }
341
+ return cells;
342
+ }
343
+ function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
344
+ const nLen = Math.round(lengthCells);
345
+ const wCells = Math.max(1, Math.round(widthCells));
346
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
347
+ const cube = offsetToCube(off.col, off.row, orientation);
348
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
349
+ if (nLen <= 0) return [centerPixel];
350
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
351
+ const step = Math.PI / 3;
352
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
353
+ const cos = Math.cos(snappedAngle);
354
+ const sin = Math.sin(snappedAngle);
355
+ const snapUnit = Math.sqrt(3) * cellSize;
356
+ const lineLength = nLen * snapUnit;
357
+ const halfWidth = wCells * snapUnit / 2 + 1e-6;
358
+ const iterM = nLen + Math.ceil(wCells / 2) + 1;
359
+ const cells = [];
360
+ for (let dq = -iterM; dq <= iterM; dq++) {
361
+ const rMin = Math.max(-iterM, -dq - iterM);
362
+ const rMax = Math.min(iterM, -dq + iterM);
363
+ for (let dr = rMin; dr <= rMax; dr++) {
364
+ const absQ = cube.q + dq;
365
+ const absR = cube.r + dr;
366
+ const pixel = offsetToPixel(
367
+ cubeToOffset(absQ, absR, orientation).col,
368
+ cubeToOffset(absQ, absR, orientation).row,
369
+ cellSize,
370
+ orientation
371
+ );
372
+ const dx = pixel.x - centerPixel.x;
373
+ const dy = pixel.y - centerPixel.y;
374
+ const along = dx * cos + dy * sin;
375
+ const perp = Math.abs(-dx * sin + dy * cos);
376
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
377
+ cells.push(pixel);
378
+ }
379
+ }
380
+ }
381
+ return cells;
382
+ }
383
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
384
+ const n2 = Math.round(radiusCells);
385
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
386
+ const cube = offsetToCube(off.col, off.row, orientation);
387
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
388
+ if (n2 <= 0) return [centerPixel];
389
+ const snapUnit = Math.sqrt(3) * cellSize;
390
+ const halfSide = n2 * snapUnit / 2;
391
+ const cells = [];
392
+ for (let dq = -n2; dq <= n2; dq++) {
393
+ const rMin = Math.max(-n2, -dq - n2);
394
+ const rMax = Math.min(n2, -dq + n2);
395
+ for (let dr = rMin; dr <= rMax; dr++) {
396
+ const absQ = cube.q + dq;
397
+ const absR = cube.r + dr;
398
+ const pixel = offsetToPixel(
399
+ cubeToOffset(absQ, absR, orientation).col,
400
+ cubeToOffset(absQ, absR, orientation).row,
401
+ cellSize,
402
+ orientation
403
+ );
404
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
405
+ cells.push(pixel);
406
+ }
407
+ }
408
+ }
409
+ return cells;
410
+ }
411
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
412
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
413
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
414
+ for (let i = 1; i < 6; i++) {
415
+ const a = angleOffset + Math.PI / 3 * i;
416
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
417
+ }
418
+ ctx.closePath();
419
+ }
420
+
421
+ // src/core/grid-metric.ts
422
+ function squareCost(rule, straight, diag) {
423
+ switch (rule) {
424
+ case "alternate":
425
+ return straight + diag + Math.floor(diag / 2);
426
+ case "manhattan":
427
+ return straight + 2 * diag;
428
+ case "chebyshev":
429
+ case "euclidean":
430
+ default:
431
+ return straight + diag;
432
+ }
433
+ }
434
+ function pathDistanceCells(points, grid) {
435
+ const cumulative = [];
436
+ const first = points[0];
437
+ if (first === void 0) return { total: 0, cumulative };
438
+ const gridSize = grid.gridSize > 0 ? grid.gridSize : 1;
439
+ const rule = grid.diagonalRule ?? "euclidean";
440
+ const orientation = grid.hexOrientation;
441
+ const hex = grid.gridType === "hex" && orientation !== void 0;
442
+ const squareRule = grid.gridType === "square" && rule !== "euclidean";
443
+ let straight = 0;
444
+ let diag = 0;
445
+ let euclid = 0;
446
+ let hexCells = 0;
447
+ cumulative.push(0);
448
+ let prev = first;
449
+ for (let i = 1; i < points.length; i++) {
450
+ const next = points[i];
451
+ if (next === void 0) break;
452
+ if (hex && orientation !== void 0) {
453
+ hexCells += getHexDistance(prev, next, gridSize, orientation);
454
+ cumulative.push(hexCells);
455
+ } else if (squareRule) {
456
+ const dx = Math.abs(Math.round((next.x - prev.x) / gridSize));
457
+ const dy = Math.abs(Math.round((next.y - prev.y) / gridSize));
458
+ const d = Math.min(dx, dy);
459
+ diag += d;
460
+ straight += Math.max(dx, dy) - d;
461
+ cumulative.push(squareCost(rule, straight, diag));
462
+ } else {
463
+ euclid += Math.hypot(next.x - prev.x, next.y - prev.y) / gridSize;
464
+ cumulative.push(euclid);
465
+ }
466
+ prev = next;
467
+ }
468
+ const total = cumulative[cumulative.length - 1] ?? 0;
469
+ return { total, cumulative };
470
+ }
471
+ function gridDistanceCells(a, b, grid) {
472
+ return pathDistanceCells([a, b], grid).total;
473
+ }
149
474
 
150
475
  // src/elements/note-sanitizer.ts
151
476
  var BOLD_TAGS = /* @__PURE__ */ new Set(["b", "strong"]);
@@ -1672,6 +1997,12 @@ var KeyboardHandler = class {
1672
1997
  if (e.key === " ") {
1673
1998
  this.deps.setSpaceHeld(true);
1674
1999
  }
2000
+ const tool = this.deps.getActiveTool();
2001
+ const ctx = this.deps.getToolContext();
2002
+ if (tool?.onKeyDown && ctx && tool.onKeyDown(e, ctx)) {
2003
+ e.preventDefault();
2004
+ return;
2005
+ }
1675
2006
  const action = this.shortcutMap.match(e);
1676
2007
  if (action !== null) {
1677
2008
  this.runAction(action, e);
@@ -1967,6 +2298,7 @@ var InputHandler = class {
1967
2298
  abortSignal: this.abortController.signal,
1968
2299
  getToolContext: () => this.toolContext,
1969
2300
  getIsToolActive: () => this.isToolActive,
2301
+ getActiveTool: () => this.toolManager?.activeTool ?? null,
1970
2302
  getLastPointerEvent: () => this.lastPointerEvent,
1971
2303
  setSpaceHeld: (v) => {
1972
2304
  this.spaceHeld = v;
@@ -2048,7 +2380,7 @@ var InputHandler = class {
2048
2380
  this.element.addEventListener("pointermove", this.onPointerMove, opts);
2049
2381
  this.element.addEventListener("pointerup", this.onPointerUp, opts);
2050
2382
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
2051
- this.element.addEventListener("pointercancel", this.onPointerUp, opts);
2383
+ this.element.addEventListener("pointercancel", this.onPointerCancel, opts);
2052
2384
  this.element.addEventListener("contextmenu", this.onContextMenu, opts);
2053
2385
  window.addEventListener("blur", this.onCoastInterrupt, opts);
2054
2386
  window.addEventListener("visibilitychange", this.onCoastInterrupt, opts);
@@ -2127,6 +2459,12 @@ var InputHandler = class {
2127
2459
  }
2128
2460
  };
2129
2461
  onPointerUp = (e) => {
2462
+ this.finishPointer(e, false);
2463
+ };
2464
+ onPointerCancel = (e) => {
2465
+ this.finishPointer(e, true);
2466
+ };
2467
+ finishPointer(e, cancelled) {
2130
2468
  this.cancelLongPress();
2131
2469
  try {
2132
2470
  this.element.releasePointerCapture(e.pointerId);
@@ -2149,16 +2487,18 @@ var InputHandler = class {
2149
2487
  if (this.activePointers.size === 0) this.coastStoppedByPointer = false;
2150
2488
  const upResult = this.inputFilter.filterUp(e);
2151
2489
  if (this.isToolActive) {
2152
- this.dispatchToolUp(e);
2490
+ if (cancelled) this.dispatchToolCancel(e);
2491
+ else this.dispatchToolUp(e);
2153
2492
  this.isToolActive = false;
2154
2493
  } else if (this.deferredDown && upResult.pendingTap) {
2155
2494
  this.dispatchToolDown(this.deferredDown);
2156
- this.dispatchToolUp(e);
2495
+ if (cancelled) this.dispatchToolCancel(e);
2496
+ else this.dispatchToolUp(e);
2157
2497
  this.deferredDown = null;
2158
2498
  } else {
2159
2499
  this.deferredDown = null;
2160
2500
  }
2161
- };
2501
+ }
2162
2502
  runAction(action, e) {
2163
2503
  this.keyboard.runAction(action, e);
2164
2504
  }
@@ -2265,6 +2605,11 @@ var InputHandler = class {
2265
2605
  this.toolManager.handlePointerUp(this.toPointerState(e), this.toolContext);
2266
2606
  this.historyRecorder?.commit();
2267
2607
  }
2608
+ dispatchToolCancel(e) {
2609
+ if (!this.toolManager || !this.toolContext) return;
2610
+ this.toolManager.handlePointerCancel(this.toPointerState(e), this.toolContext);
2611
+ this.historyRecorder?.commit();
2612
+ }
2268
2613
  isInScope() {
2269
2614
  if (this.scope === "window") return true;
2270
2615
  const active = document.activeElement;
@@ -2277,7 +2622,7 @@ var InputHandler = class {
2277
2622
  cancelToolIfActive(e) {
2278
2623
  this.cancelLongPress();
2279
2624
  if (this.isToolActive) {
2280
- this.dispatchToolUp(e);
2625
+ this.dispatchToolCancel(e);
2281
2626
  this.isToolActive = false;
2282
2627
  }
2283
2628
  this.deferredDown = null;
@@ -3375,230 +3720,6 @@ function getImage(src, imageCache, onImageLoad, onImageError) {
3375
3720
  return null;
3376
3721
  }
3377
3722
 
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
3723
  // src/elements/renderers/template-measure.ts
3603
3724
  function renderTemplateFeetLabel(ctx, p) {
3604
3725
  if (p.feet <= 0) return;
@@ -5573,6 +5694,13 @@ var ToolManager = class {
5573
5694
  handlePointerUp(state, ctx) {
5574
5695
  this.current?.onPointerUp(state, ctx);
5575
5696
  }
5697
+ /** Cancels the active gesture; falls back to `onPointerUp` for tools without `onPointerCancel`. */
5698
+ handlePointerCancel(state, ctx) {
5699
+ const tool = this.current;
5700
+ if (!tool) return;
5701
+ if (tool.onPointerCancel) tool.onPointerCancel(state, ctx);
5702
+ else tool.onPointerUp(state, ctx);
5703
+ }
5576
5704
  onChange(listener) {
5577
5705
  this.changeListeners.add(listener);
5578
5706
  return () => this.changeListeners.delete(listener);
@@ -11182,145 +11310,119 @@ function drawMeasurement(ctx, m, opts = {}) {
11182
11310
  ctx.restore();
11183
11311
  }
11184
11312
 
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";
11313
+ // src/canvas/linger-overlay.ts
11215
11314
  var DEFAULT_HOLD_MS = 1500;
11216
11315
  var DEFAULT_FADE_MS2 = 400;
11217
11316
  var DEFAULT_MAX_AGE_MS = 3e4;
11218
- var RemoteMeasureOverlay = class {
11317
+ var LingerOverlay = class {
11219
11318
  host;
11220
- color;
11319
+ draw;
11320
+ clock;
11221
11321
  holdMs;
11222
11322
  fadeMs;
11223
11323
  maxAgeMs;
11224
- measurements = /* @__PURE__ */ new Map();
11324
+ entries = /* @__PURE__ */ new Map();
11225
11325
  unregister;
11226
11326
  rafId = null;
11227
- disposed = false;
11228
- constructor(host, options = {}) {
11327
+ isDisposed = false;
11328
+ /**
11329
+ * `clock` exists only as a test seam; production callers leave it at
11330
+ * `performance.now`. Owners that expose their own `now()` seam pass it
11331
+ * through so a spy on the owner still drives this overlay's timing.
11332
+ */
11333
+ constructor(host, options, draw, clock = () => performance.now()) {
11229
11334
  this.host = host;
11230
- this.color = options.color ?? DEFAULT_COLOR3;
11335
+ this.draw = draw;
11336
+ this.clock = clock;
11231
11337
  this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
11232
11338
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11233
11339
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
11234
- this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
11340
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
11235
11341
  }
11236
11342
  now() {
11237
- return performance.now();
11343
+ return this.clock();
11238
11344
  }
11239
11345
  /**
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.
11346
+ * Stores `sender`'s current entry as active, replacing any previous one and
11347
+ * cancelling an in-flight linger, and restarts the `maxAgeMs` expiry timer.
11243
11348
  */
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);
11349
+ set(sender, entry) {
11350
+ if (this.isDisposed) return;
11351
+ const existing = this.entries.get(sender);
11251
11352
  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,
11353
+ this.entries.set(sender, {
11354
+ entry,
11257
11355
  clearedAt: null,
11258
- expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
11356
+ expiryTimer: setTimeout(() => this.linger(sender), this.maxAgeMs)
11259
11357
  });
11260
11358
  this.host.requestRender();
11261
- return true;
11262
11359
  }
11263
- /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11360
+ /** Starts the hold-then-fade lifetime for `sender`'s entry. */
11361
+ linger(sender) {
11362
+ const entry = this.entries.get(sender);
11363
+ if (!entry || entry.clearedAt !== null) return;
11364
+ if (entry.expiryTimer != null) {
11365
+ clearTimeout(entry.expiryTimer);
11366
+ entry.expiryTimer = null;
11367
+ }
11368
+ entry.clearedAt = this.now();
11369
+ this.ensureAnimating();
11370
+ this.host.requestRender();
11371
+ }
11372
+ /** Removes a sender's entry immediately (presence-leave/disconnect). */
11264
11373
  remove(sender) {
11265
- const entry = this.measurements.get(sender);
11374
+ const entry = this.entries.get(sender);
11266
11375
  if (!entry) return;
11267
11376
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11268
- this.measurements.delete(sender);
11377
+ this.entries.delete(sender);
11269
11378
  this.host.requestRender();
11270
11379
  }
11271
- /** Removes every ruler immediately. */
11380
+ /** Removes every entry immediately. */
11272
11381
  clear() {
11273
- if (this.measurements.size === 0) return;
11274
- for (const entry of this.measurements.values()) {
11382
+ if (this.entries.size === 0) return;
11383
+ for (const entry of this.entries.values()) {
11275
11384
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11276
11385
  }
11277
- this.measurements.clear();
11386
+ this.entries.clear();
11278
11387
  this.host.requestRender();
11279
11388
  }
11280
- /** Number of senders with a visible (active or lingering) ruler. */
11389
+ /** Number of senders with a visible (active or lingering) entry. */
11281
11390
  get activeSenderCount() {
11282
- return this.measurements.size;
11391
+ return this.entries.size;
11392
+ }
11393
+ /** True once `dispose` has run; the overlay accepts no further entries. */
11394
+ get disposed() {
11395
+ return this.isDisposed;
11283
11396
  }
11284
11397
  /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11285
11398
  dispose() {
11286
- if (this.disposed) return;
11287
- this.disposed = true;
11399
+ if (this.isDisposed) return;
11400
+ this.isDisposed = true;
11288
11401
  if (this.rafId !== null) {
11289
11402
  cancelAnimationFrame(this.rafId);
11290
11403
  this.rafId = null;
11291
11404
  }
11292
- for (const entry of this.measurements.values()) {
11405
+ for (const entry of this.entries.values()) {
11293
11406
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11294
11407
  }
11295
- this.measurements.clear();
11408
+ this.entries.clear();
11296
11409
  this.unregister?.();
11297
11410
  this.unregister = null;
11298
11411
  this.host.requestRender();
11299
11412
  }
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
11413
  ensureAnimating() {
11312
11414
  if (this.rafId === null) {
11313
11415
  this.rafId = requestAnimationFrame(() => this.tick());
11314
11416
  }
11315
11417
  }
11316
11418
  tick() {
11317
- if (this.disposed) return;
11419
+ if (this.isDisposed) return;
11318
11420
  const now = this.now();
11319
11421
  let lingering = 0;
11320
- for (const [sender, entry] of this.measurements) {
11422
+ for (const [sender, entry] of this.entries) {
11321
11423
  if (entry.clearedAt === null) continue;
11322
11424
  if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
11323
- this.measurements.delete(sender);
11425
+ this.entries.delete(sender);
11324
11426
  } else {
11325
11427
  lingering += 1;
11326
11428
  }
@@ -11328,24 +11430,278 @@ var RemoteMeasureOverlay = class {
11328
11430
  this.host.requestRender();
11329
11431
  this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
11330
11432
  }
11331
- renderMeasurements(ctx) {
11332
- if (this.measurements.size === 0) return;
11433
+ render(ctx) {
11434
+ if (this.entries.size === 0) return;
11333
11435
  const now = this.now();
11334
- for (const entry of this.measurements.values()) {
11436
+ for (const entry of this.entries.values()) {
11335
11437
  let alpha = 1;
11336
11438
  if (entry.clearedAt !== null) {
11337
11439
  const fadeAge = now - entry.clearedAt - this.holdMs;
11338
11440
  if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
11339
11441
  }
11340
- drawMeasurement(ctx, entry, { alpha });
11442
+ this.draw(ctx, entry.entry, alpha);
11443
+ }
11444
+ }
11445
+ };
11446
+
11447
+ // src/canvas/remote-measure-overlay.ts
11448
+ var MEASURE_PRESENCE_KIND = "measure";
11449
+ function isFinitePoint2(value) {
11450
+ if (typeof value !== "object" || value === null) return false;
11451
+ const point = value;
11452
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11453
+ }
11454
+ function isMeasurePresence(data) {
11455
+ if (typeof data !== "object" || data === null) return false;
11456
+ const payload = data;
11457
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
11458
+ if ("cleared" in payload) return payload.cleared === true;
11459
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
11460
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
11461
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11462
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11463
+ return true;
11464
+ }
11465
+ function toMeasurePresence(emission) {
11466
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
11467
+ return {
11468
+ kind: MEASURE_PRESENCE_KIND,
11469
+ start: emission.start,
11470
+ end: emission.end,
11471
+ cells: emission.cells,
11472
+ feet: emission.feet,
11473
+ color: emission.color
11474
+ };
11475
+ }
11476
+ var DEFAULT_COLOR3 = "#FF5722";
11477
+ var RemoteMeasureOverlay = class {
11478
+ color;
11479
+ overlay;
11480
+ constructor(host, options = {}) {
11481
+ this.color = options.color ?? DEFAULT_COLOR3;
11482
+ this.overlay = new LingerOverlay(
11483
+ host,
11484
+ options,
11485
+ (ctx, entry, alpha) => drawMeasurement(ctx, entry, { alpha }),
11486
+ () => this.now()
11487
+ );
11488
+ }
11489
+ now() {
11490
+ return performance.now();
11491
+ }
11492
+ /**
11493
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11494
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
11495
+ * reported as `false`, so hosts can feed every presence frame through.
11496
+ */
11497
+ apply(sender, data) {
11498
+ if (this.overlay.disposed || !isMeasurePresence(data)) return false;
11499
+ if ("cleared" in data) {
11500
+ this.overlay.linger(sender);
11501
+ return true;
11341
11502
  }
11503
+ this.overlay.set(sender, {
11504
+ start: data.start,
11505
+ end: data.end,
11506
+ feet: data.feet,
11507
+ color: data.color ?? this.color
11508
+ });
11509
+ return true;
11510
+ }
11511
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11512
+ remove(sender) {
11513
+ this.overlay.remove(sender);
11514
+ }
11515
+ /** Removes every ruler immediately. */
11516
+ clear() {
11517
+ this.overlay.clear();
11518
+ }
11519
+ /** Number of senders with a visible (active or lingering) ruler. */
11520
+ get activeSenderCount() {
11521
+ return this.overlay.activeSenderCount;
11522
+ }
11523
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11524
+ dispose() {
11525
+ this.overlay.dispose();
11526
+ }
11527
+ };
11528
+
11529
+ // src/canvas/path-render.ts
11530
+ function resolveSegmentColors(cumulativeFeet, bands, color) {
11531
+ const sorted = [...bands].sort((a, b) => a.feet - b.feet);
11532
+ const out = [];
11533
+ for (let i = 1; i < cumulativeFeet.length; i++) {
11534
+ const end = cumulativeFeet[i] ?? 0;
11535
+ const band = sorted.find((b) => b.feet >= end);
11536
+ out.push(band ? band.color : color);
11537
+ }
11538
+ return out;
11539
+ }
11540
+ var LABEL_OFFSET_Y = 18;
11541
+ function drawPath(ctx, m, opts = {}) {
11542
+ const first = m.points[0];
11543
+ if (first === void 0) return;
11544
+ ctx.save();
11545
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
11546
+ ctx.lineWidth = 2;
11547
+ let prev = first;
11548
+ for (let i = 1; i < m.points.length; i++) {
11549
+ const next = m.points[i];
11550
+ if (next === void 0) break;
11551
+ ctx.strokeStyle = m.segmentColors[i - 1] ?? m.color;
11552
+ ctx.setLineDash([8, 4]);
11553
+ ctx.beginPath();
11554
+ ctx.moveTo(prev.x, prev.y);
11555
+ ctx.lineTo(next.x, next.y);
11556
+ ctx.stroke();
11557
+ prev = next;
11558
+ }
11559
+ ctx.setLineDash([]);
11560
+ ctx.fillStyle = m.color;
11561
+ for (const p of m.points) {
11562
+ ctx.beginPath();
11563
+ ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
11564
+ ctx.fill();
11565
+ }
11566
+ if (m.points.length >= 2) {
11567
+ const last = prev;
11568
+ const label = formatMeasureLabel(m.feet);
11569
+ ctx.font = "14px sans-serif";
11570
+ const metrics = ctx.measureText(label);
11571
+ const padX = 6;
11572
+ const padY = 4;
11573
+ const textH = 14;
11574
+ const cx = last.x;
11575
+ const cy = last.y - LABEL_OFFSET_Y;
11576
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11577
+ ctx.beginPath();
11578
+ ctx.roundRect(
11579
+ cx - metrics.width / 2 - padX,
11580
+ cy - textH / 2 - padY,
11581
+ metrics.width + padX * 2,
11582
+ textH + padY * 2,
11583
+ 4
11584
+ );
11585
+ ctx.fill();
11586
+ ctx.fillStyle = "#FFFFFF";
11587
+ ctx.textAlign = "center";
11588
+ ctx.textBaseline = "middle";
11589
+ ctx.fillText(label, cx, cy);
11590
+ }
11591
+ ctx.restore();
11592
+ }
11593
+
11594
+ // src/canvas/remote-path-overlay.ts
11595
+ var PATH_PRESENCE_KIND = "path";
11596
+ var PATH_PRESENCE_MAX_POINTS = 256;
11597
+ var MAX_COLOR_LENGTH = 64;
11598
+ var EPS = 1e-6;
11599
+ function isFinitePoint3(value) {
11600
+ if (typeof value !== "object" || value === null) return false;
11601
+ const point = value;
11602
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11603
+ }
11604
+ function isPathPresence(data) {
11605
+ if (typeof data !== "object" || data === null) return false;
11606
+ const payload = data;
11607
+ if (payload.kind !== PATH_PRESENCE_KIND) return false;
11608
+ if ("cleared" in payload) return payload.cleared === true;
11609
+ if (!Array.isArray(payload.points)) return false;
11610
+ if (payload.points.length === 0 || payload.points.length > PATH_PRESENCE_MAX_POINTS) return false;
11611
+ for (const point of payload.points) {
11612
+ if (!isFinitePoint3(point)) return false;
11613
+ }
11614
+ if (!Array.isArray(payload.segmentColors)) return false;
11615
+ if (payload.segmentColors.length !== payload.points.length - 1) return false;
11616
+ for (const color of payload.segmentColors) {
11617
+ if (typeof color !== "string" || color.length > MAX_COLOR_LENGTH) return false;
11618
+ }
11619
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11620
+ if (payload.color !== void 0) {
11621
+ if (typeof payload.color !== "string" || payload.color.length > MAX_COLOR_LENGTH) return false;
11622
+ }
11623
+ return true;
11624
+ }
11625
+ function samePoint(a, b) {
11626
+ return Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
11627
+ }
11628
+ function toPathPresence(emission) {
11629
+ if (emission === null) return { kind: PATH_PRESENCE_KIND, cleared: true };
11630
+ const points = emission.waypoints.map((point) => ({ x: point.x, y: point.y }));
11631
+ const last = points[points.length - 1];
11632
+ const cursor = emission.cursor;
11633
+ if (cursor && (!last || !samePoint(cursor, last))) points.push({ x: cursor.x, y: cursor.y });
11634
+ const cumulativeFeet = [0];
11635
+ let total = 0;
11636
+ for (let i = 1; i < points.length; i++) {
11637
+ total += emission.segments[i - 1]?.feet ?? 0;
11638
+ cumulativeFeet.push(total);
11639
+ }
11640
+ return {
11641
+ kind: PATH_PRESENCE_KIND,
11642
+ points,
11643
+ segmentColors: resolveSegmentColors(cumulativeFeet, emission.rangeBands, emission.color),
11644
+ feet: emission.totalFeet,
11645
+ color: emission.color
11646
+ };
11647
+ }
11648
+ var DEFAULT_COLOR4 = "#FF5722";
11649
+ var RemotePathOverlay = class {
11650
+ color;
11651
+ overlay;
11652
+ constructor(host, options = {}) {
11653
+ this.color = options.color ?? DEFAULT_COLOR4;
11654
+ this.overlay = new LingerOverlay(
11655
+ host,
11656
+ options,
11657
+ (ctx, entry, alpha) => drawPath(ctx, entry, { alpha }),
11658
+ () => this.now()
11659
+ );
11660
+ }
11661
+ now() {
11662
+ return performance.now();
11663
+ }
11664
+ /**
11665
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11666
+ * the envelope `from`). Non-path or malformed payloads are ignored and
11667
+ * reported as `false`, so hosts can feed every presence frame through.
11668
+ */
11669
+ apply(sender, data) {
11670
+ if (this.overlay.disposed || !isPathPresence(data)) return false;
11671
+ if ("cleared" in data) {
11672
+ this.overlay.linger(sender);
11673
+ return true;
11674
+ }
11675
+ this.overlay.set(sender, {
11676
+ points: data.points,
11677
+ segmentColors: data.segmentColors,
11678
+ feet: data.feet,
11679
+ color: data.color ?? this.color
11680
+ });
11681
+ return true;
11682
+ }
11683
+ /** Removes a sender's path immediately (presence-leave/disconnect). */
11684
+ remove(sender) {
11685
+ this.overlay.remove(sender);
11686
+ }
11687
+ /** Removes every path immediately. */
11688
+ clear() {
11689
+ this.overlay.clear();
11690
+ }
11691
+ /** Number of senders with a visible (active or lingering) path. */
11692
+ get activeSenderCount() {
11693
+ return this.overlay.activeSenderCount;
11694
+ }
11695
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11696
+ dispose() {
11697
+ this.overlay.dispose();
11342
11698
  }
11343
11699
  };
11344
11700
 
11345
11701
  // src/canvas/ping-input.ts
11346
11702
  var DEFAULT_LONG_PRESS_MS = 600;
11347
11703
  var DEFAULT_SLOP_PX = 8;
11348
- var DEFAULT_COLOR4 = "#ff3b30";
11704
+ var DEFAULT_COLOR5 = "#ff3b30";
11349
11705
  var DEFAULT_DURATION_MS2 = 1800;
11350
11706
  var DEFAULT_RADIUS2 = 48;
11351
11707
  var DEFAULT_MIN_INTERVAL_MS = 300;
@@ -11378,7 +11734,7 @@ var PingInput = class {
11378
11734
  this.longPressEnabled = options.longPressEnabled ?? false;
11379
11735
  this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
11380
11736
  this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
11381
- this.color = options.color ?? DEFAULT_COLOR4;
11737
+ this.color = options.color ?? DEFAULT_COLOR5;
11382
11738
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
11383
11739
  this.radius = options.radius ?? DEFAULT_RADIUS2;
11384
11740
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -11783,105 +12139,899 @@ var CameraAnimator = class {
11783
12139
  this.rafId = null;
11784
12140
  }
11785
12141
  }
11786
- emit(reason) {
11787
- for (const listener of [...this.endListeners]) {
12142
+ emit(reason) {
12143
+ for (const listener of [...this.endListeners]) {
12144
+ try {
12145
+ listener(reason);
12146
+ } catch {
12147
+ }
12148
+ }
12149
+ }
12150
+ };
12151
+
12152
+ // src/canvas/focus-presence.ts
12153
+ var FOCUS_PRESENCE_KIND = "focus";
12154
+ var AUDIENCES = ["all", "players", "display"];
12155
+ function isPositiveFinite(value) {
12156
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
12157
+ }
12158
+ function isFiniteNumber2(value) {
12159
+ return typeof value === "number" && Number.isFinite(value);
12160
+ }
12161
+ function isFocusPresence(data) {
12162
+ if (typeof data !== "object" || data === null) return false;
12163
+ const payload = data;
12164
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
12165
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
12166
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
12167
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
12168
+ return false;
12169
+ }
12170
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
12171
+ return true;
12172
+ }
12173
+ function toFocusPresence(view, audience, color) {
12174
+ return {
12175
+ kind: FOCUS_PRESENCE_KIND,
12176
+ x: view.x,
12177
+ y: view.y,
12178
+ w: view.w,
12179
+ h: view.h,
12180
+ audience,
12181
+ ...color === void 0 ? {} : { color }
12182
+ };
12183
+ }
12184
+
12185
+ // src/canvas/remote-focus-receiver.ts
12186
+ function audienceIncludes(audience, role) {
12187
+ if (role === "dm") return false;
12188
+ if (audience === "all") return true;
12189
+ if (audience === "players") return role === "player";
12190
+ return role === "display";
12191
+ }
12192
+ var RemoteFocusReceiver = class {
12193
+ role;
12194
+ animator;
12195
+ animate;
12196
+ pulseColor;
12197
+ overlay;
12198
+ disposed = false;
12199
+ constructor(host, options) {
12200
+ this.role = options.role;
12201
+ this.animator = options.animator;
12202
+ this.animate = options.animate ?? true;
12203
+ this.pulseColor = options.pulseColor;
12204
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
12205
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
12206
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
12207
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
12208
+ maxPingsPerSender: 1
12209
+ }) : null;
12210
+ }
12211
+ /**
12212
+ * Applies a presence payload from `sender`. Returns `false` for payloads
12213
+ * that are not focus frames, or are addressed to a different role, so hosts
12214
+ * can feed every presence frame through without disturbing other handlers.
12215
+ */
12216
+ apply(from, data) {
12217
+ if (this.disposed || !isFocusPresence(data)) return false;
12218
+ if (!audienceIncludes(data.audience, this.role)) return false;
12219
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
12220
+ if (this.animate) {
12221
+ this.animator.animateTo(view);
12222
+ } else {
12223
+ this.animator.jumpTo(view);
12224
+ }
12225
+ this.overlay?.apply(from, {
12226
+ kind: "ping",
12227
+ x: view.x + view.w / 2,
12228
+ y: view.y + view.h / 2,
12229
+ color: data.color ?? this.pulseColor
12230
+ });
12231
+ return true;
12232
+ }
12233
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
12234
+ dispose() {
12235
+ if (this.disposed) return;
12236
+ this.disposed = true;
12237
+ this.overlay?.dispose();
12238
+ }
12239
+ };
12240
+
12241
+ // src/canvas/awareness-presence.ts
12242
+ var AWARENESS_PRESENCE_KIND = "awareness";
12243
+ var AWARENESS_MAX_SELECTION = 256;
12244
+ var MAX_ID_LENGTH = 128;
12245
+ var MAX_NAME_LENGTH = 64;
12246
+ var MAX_COLOR_LENGTH2 = 64;
12247
+ var MAX_ROLE_LENGTH = 32;
12248
+ var MAX_TOOL_LENGTH = 64;
12249
+ function isBoundedString(value, max) {
12250
+ return typeof value === "string" && value.length <= max;
12251
+ }
12252
+ function isOptionalBoundedString(value, max) {
12253
+ return value === void 0 || isBoundedString(value, max);
12254
+ }
12255
+ function isFinitePoint4(value) {
12256
+ if (typeof value !== "object" || value === null) return false;
12257
+ const point = value;
12258
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
12259
+ }
12260
+ function isAwarenessPresence(data) {
12261
+ if (typeof data !== "object" || data === null) return false;
12262
+ const payload = data;
12263
+ if (payload.kind !== AWARENESS_PRESENCE_KIND) return false;
12264
+ if (!isBoundedString(payload.id, MAX_ID_LENGTH) || payload.id.length === 0) return false;
12265
+ if ("cleared" in payload) return payload.cleared === true;
12266
+ if (!isOptionalBoundedString(payload.name, MAX_NAME_LENGTH)) return false;
12267
+ if (!isOptionalBoundedString(payload.color, MAX_COLOR_LENGTH2)) return false;
12268
+ if (!isOptionalBoundedString(payload.role, MAX_ROLE_LENGTH)) return false;
12269
+ if (!isOptionalBoundedString(payload.tool, MAX_TOOL_LENGTH)) return false;
12270
+ if (payload.cursor !== void 0 && !isFinitePoint4(payload.cursor)) return false;
12271
+ if (payload.selection !== void 0) {
12272
+ if (!Array.isArray(payload.selection)) return false;
12273
+ if (payload.selection.length > AWARENESS_MAX_SELECTION) return false;
12274
+ for (const id of payload.selection) {
12275
+ if (!isBoundedString(id, MAX_ID_LENGTH) || id.length === 0) return false;
12276
+ }
12277
+ }
12278
+ return true;
12279
+ }
12280
+
12281
+ // src/canvas/awareness-roster.ts
12282
+ var DEFAULT_STALE_MS = 45e3;
12283
+ var EMPTY_PEERS = Object.freeze([]);
12284
+ var EMPTY_SELECTION = Object.freeze([]);
12285
+ function sameSelection(a, b) {
12286
+ if (a.length !== b.length) return false;
12287
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
12288
+ return true;
12289
+ }
12290
+ function samePoint2(a, b) {
12291
+ if (a === null || b === null) return a === b;
12292
+ return a.x === b.x && a.y === b.y;
12293
+ }
12294
+ function toPeer(from, data, prev) {
12295
+ const cursor = data.cursor ? { x: data.cursor.x, y: data.cursor.y } : null;
12296
+ const incoming = data.selection ?? EMPTY_SELECTION;
12297
+ const selection = prev && sameSelection(prev.selection, incoming) ? prev.selection : incoming.length === 0 ? EMPTY_SELECTION : Object.freeze([...incoming]);
12298
+ const tool = data.tool ?? null;
12299
+ if (prev && prev.id === data.id && prev.name === data.name && prev.color === data.color && prev.role === data.role && prev.tool === tool && prev.selection === selection && samePoint2(prev.cursor, cursor)) {
12300
+ return prev;
12301
+ }
12302
+ const peer = {
12303
+ from,
12304
+ id: data.id,
12305
+ ...data.name === void 0 ? {} : { name: data.name },
12306
+ ...data.color === void 0 ? {} : { color: data.color },
12307
+ ...data.role === void 0 ? {} : { role: data.role },
12308
+ cursor,
12309
+ selection,
12310
+ tool
12311
+ };
12312
+ return peer;
12313
+ }
12314
+ var PeerRoster = class {
12315
+ staleMs;
12316
+ now;
12317
+ rows = /* @__PURE__ */ new Map();
12318
+ discovered = /* @__PURE__ */ new Map();
12319
+ changeListeners = /* @__PURE__ */ new Set();
12320
+ discoverListeners = /* @__PURE__ */ new Set();
12321
+ leaveListeners = /* @__PURE__ */ new Set();
12322
+ snapshot = EMPTY_PEERS;
12323
+ snapshotDirty = false;
12324
+ staleTimer = null;
12325
+ isDisposed = false;
12326
+ constructor(options = {}) {
12327
+ this.staleMs = options.staleMs ?? DEFAULT_STALE_MS;
12328
+ this.now = options.now ?? (() => Date.now());
12329
+ }
12330
+ get disposed() {
12331
+ return this.isDisposed;
12332
+ }
12333
+ /**
12334
+ * Applies a presence payload from `from`. Non-awareness or malformed payloads
12335
+ * return `false` untouched, so hosts can feed every presence frame through.
12336
+ */
12337
+ apply(from, data) {
12338
+ if (this.isDisposed || !isAwarenessPresence(data)) return false;
12339
+ const isNew = !this.discovered.has(from);
12340
+ this.discovered.set(from, this.now());
12341
+ if ("cleared" in data) {
12342
+ this.dropRow(from, "cleared");
12343
+ } else {
12344
+ const prev = this.rows.get(from);
12345
+ const next = toPeer(from, data, prev);
12346
+ if (next !== prev) {
12347
+ this.rows.set(from, next);
12348
+ this.changed();
12349
+ }
12350
+ }
12351
+ this.armStaleTimer();
12352
+ if (isNew) this.emit(this.discoverListeners, (l) => l(from));
12353
+ return true;
12354
+ }
12355
+ /** Server-authored presence-leave: drops the row AND the discovery entry. */
12356
+ remove(from) {
12357
+ if (this.isDisposed) return;
12358
+ const hadEntry = this.discovered.delete(from);
12359
+ this.dropRow(from, "left");
12360
+ if (hadEntry) this.armStaleTimer();
12361
+ }
12362
+ getPeers() {
12363
+ if (this.snapshotDirty) {
12364
+ this.snapshot = this.rows.size === 0 ? EMPTY_PEERS : Object.freeze([...this.rows.values()]);
12365
+ this.snapshotDirty = false;
12366
+ }
12367
+ return this.snapshot;
12368
+ }
12369
+ getPeer(from) {
12370
+ return this.rows.get(from);
12371
+ }
12372
+ /** Fires only when `getPeers()` would return a new reference. */
12373
+ onChange(listener) {
12374
+ this.changeListeners.add(listener);
12375
+ return () => this.changeListeners.delete(listener);
12376
+ }
12377
+ /** First valid frame from a sender since its discovery entry was last dropped. */
12378
+ onDiscover(listener) {
12379
+ this.discoverListeners.add(listener);
12380
+ return () => this.discoverListeners.delete(listener);
12381
+ }
12382
+ onLeave(listener) {
12383
+ this.leaveListeners.add(listener);
12384
+ return () => this.leaveListeners.delete(listener);
12385
+ }
12386
+ dispose() {
12387
+ if (this.isDisposed) return;
12388
+ this.isDisposed = true;
12389
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12390
+ this.staleTimer = null;
12391
+ this.rows.clear();
12392
+ this.discovered.clear();
12393
+ this.snapshot = EMPTY_PEERS;
12394
+ this.snapshotDirty = false;
12395
+ this.changeListeners.clear();
12396
+ this.discoverListeners.clear();
12397
+ this.leaveListeners.clear();
12398
+ }
12399
+ dropRow(from, reason) {
12400
+ const row = this.rows.get(from);
12401
+ if (!row) return;
12402
+ this.rows.delete(from);
12403
+ this.changed();
12404
+ this.emit(this.leaveListeners, (l) => l(row, reason));
12405
+ }
12406
+ changed() {
12407
+ this.snapshotDirty = true;
12408
+ this.emit(this.changeListeners, (l) => l());
12409
+ }
12410
+ emit(listeners, call) {
12411
+ for (const listener of [...listeners]) {
12412
+ try {
12413
+ call(listener);
12414
+ } catch {
12415
+ }
12416
+ }
12417
+ }
12418
+ armStaleTimer() {
12419
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12420
+ this.staleTimer = null;
12421
+ if (!Number.isFinite(this.staleMs) || this.staleMs <= 0 || this.isDisposed || this.discovered.size === 0) {
12422
+ return;
12423
+ }
12424
+ let earliest = Infinity;
12425
+ for (const seen of this.discovered.values()) if (seen < earliest) earliest = seen;
12426
+ const delay = Math.min(Math.max(0, earliest + this.staleMs - this.now()), 2 ** 31 - 1);
12427
+ this.staleTimer = setTimeout(() => {
12428
+ this.staleTimer = null;
12429
+ this.expireStale();
12430
+ }, delay);
12431
+ }
12432
+ expireStale() {
12433
+ const t = this.now();
12434
+ for (const from of [...this.discovered.keys()]) {
12435
+ const seen = this.discovered.get(from);
12436
+ if (seen === void 0 || t - seen < this.staleMs) continue;
12437
+ this.discovered.delete(from);
12438
+ this.dropRow(from, "stale");
12439
+ }
12440
+ this.armStaleTimer();
12441
+ }
12442
+ };
12443
+
12444
+ // src/canvas/awareness-publisher.ts
12445
+ var DEFAULT_FIELDS = Object.freeze({
12446
+ cursor: true,
12447
+ selection: false,
12448
+ tool: true
12449
+ });
12450
+ var DEFAULT_INTERVAL_MS = 50;
12451
+ var DEFAULT_HEARTBEAT_MS = 15e3;
12452
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
12453
+ var MAX_IDENTITY_ID_LENGTH = 128;
12454
+ var MAX_IDENTITY_NAME_LENGTH = 64;
12455
+ var MAX_IDENTITY_COLOR_LENGTH = 64;
12456
+ var MAX_IDENTITY_ROLE_LENGTH = 32;
12457
+ var MAX_TOOL_LENGTH2 = 64;
12458
+ var MAX_SELECTION_ID_LENGTH = 128;
12459
+ function normalizeIntervalMs(value) {
12460
+ return Number.isFinite(value) && value >= 0 ? value : 0;
12461
+ }
12462
+ function normalizeHeartbeatMs(value) {
12463
+ return Number.isFinite(value) && value > 0 ? value : 0;
12464
+ }
12465
+ var LocalAwareness = class {
12466
+ host;
12467
+ element;
12468
+ send;
12469
+ selectionFilter;
12470
+ onError;
12471
+ intervalMs;
12472
+ heartbeatMs;
12473
+ identity;
12474
+ fields;
12475
+ lastPointer = null;
12476
+ selection = [];
12477
+ selectionFailed = false;
12478
+ tool;
12479
+ dirty = false;
12480
+ lastSentAt = null;
12481
+ throttleTimer = null;
12482
+ heartbeatTimer = null;
12483
+ unsubscribers = [];
12484
+ isDisposed = false;
12485
+ handlePointerMove = (e) => this.onPointerMove(e);
12486
+ handlePointerEnd = (e) => this.onPointerEnd(e);
12487
+ constructor(host, options) {
12488
+ const element = options.element ?? host.domLayer.parentElement;
12489
+ if (!element) throw new Error("LocalAwareness: the viewport wrapper is not mounted");
12490
+ this.host = host;
12491
+ this.element = element;
12492
+ this.send = options.send;
12493
+ this.selectionFilter = options.selectionFilter;
12494
+ this.onError = options.onError;
12495
+ this.intervalMs = normalizeIntervalMs(options.intervalMs ?? DEFAULT_INTERVAL_MS);
12496
+ this.heartbeatMs = normalizeHeartbeatMs(options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
12497
+ this.identity = this.normalizeIdentity(options.identity);
12498
+ this.fields = mergeFields(DEFAULT_FIELDS, options.fields ?? {});
12499
+ this.tool = host.toolManager.activeTool?.name ?? null;
12500
+ if (this.fields.selection) this.refreshSelection();
12501
+ const opts = { passive: true };
12502
+ element.addEventListener("pointermove", this.handlePointerMove, opts);
12503
+ element.addEventListener("pointerleave", this.handlePointerEnd, opts);
12504
+ element.addEventListener("pointercancel", this.handlePointerEnd, opts);
12505
+ this.unsubscribers.push(
12506
+ host.onSelectionChange(() => {
12507
+ if (this.fields.selection) this.schedule();
12508
+ }),
12509
+ host.toolManager.onChange((name) => {
12510
+ this.tool = name;
12511
+ if (this.fields.tool) this.schedule();
12512
+ })
12513
+ );
12514
+ this.armHeartbeat();
12515
+ }
12516
+ get disposed() {
12517
+ return this.isDisposed;
12518
+ }
12519
+ getFields() {
12520
+ return this.fields;
12521
+ }
12522
+ setIdentity(identity) {
12523
+ this.identity = this.normalizeIdentity(identity);
12524
+ this.schedule();
12525
+ }
12526
+ /** Merges the given flags into the current policy; `undefined` keys are ignored. */
12527
+ setFields(fields) {
12528
+ this.fields = mergeFields(this.fields, fields);
12529
+ this.schedule();
12530
+ }
12531
+ /**
12532
+ * Requests a full frame: immediate when idle, otherwise folded into the
12533
+ * pending trailing frame (so N simultaneous requests cost one frame). Hosts
12534
+ * call it when the connection becomes live or reconnects.
12535
+ */
12536
+ announce() {
12537
+ this.schedule();
12538
+ }
12539
+ /**
12540
+ * The complete state a frame carries right now. Side-effecting when
12541
+ * selection publishing is on: re-reads `getSelectedIds()`, re-runs
12542
+ * `selectionFilter`, updates the fail-closed selection state, and may call
12543
+ * `onError`. A no-op with respect to selection while publishing is off.
12544
+ */
12545
+ getState() {
12546
+ const frame = { kind: AWARENESS_PRESENCE_KIND, id: this.identity.id };
12547
+ if (this.identity.name !== void 0) frame.name = this.identity.name;
12548
+ if (this.identity.color !== void 0) frame.color = this.identity.color;
12549
+ if (this.identity.role !== void 0) frame.role = this.identity.role;
12550
+ if (this.fields.cursor && this.lastPointer !== null) {
12551
+ frame.cursor = { x: this.lastPointer.x, y: this.lastPointer.y };
12552
+ }
12553
+ if (this.fields.selection) {
12554
+ this.refreshSelection();
12555
+ if (!this.selectionFailed && this.selection.length > 0) {
12556
+ frame.selection = [...this.selection];
12557
+ }
12558
+ }
12559
+ if (this.fields.tool && this.tool !== null && this.tool.length <= MAX_TOOL_LENGTH2) {
12560
+ frame.tool = this.tool;
12561
+ }
12562
+ return frame;
12563
+ }
12564
+ dispose() {
12565
+ if (this.isDisposed) return;
12566
+ this.isDisposed = true;
12567
+ if (this.throttleTimer !== null) clearTimeout(this.throttleTimer);
12568
+ this.throttleTimer = null;
12569
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12570
+ this.heartbeatTimer = null;
12571
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
12572
+ this.element.removeEventListener("pointerleave", this.handlePointerEnd);
12573
+ this.element.removeEventListener("pointercancel", this.handlePointerEnd);
12574
+ for (const unsub of this.unsubscribers) unsub();
12575
+ this.unsubscribers.length = 0;
12576
+ this.safeSend({ kind: AWARENESS_PRESENCE_KIND, id: this.identity.id, cleared: true });
12577
+ }
12578
+ now() {
12579
+ return Date.now();
12580
+ }
12581
+ onPointerMove(e) {
12582
+ if (!e.isPrimary) return;
12583
+ const rect = this.element.getBoundingClientRect();
12584
+ const world = this.host.camera.screenToWorld({
12585
+ x: e.clientX - rect.left,
12586
+ y: e.clientY - rect.top
12587
+ });
12588
+ this.lastPointer = Number.isFinite(world.x) && Number.isFinite(world.y) ? { x: world.x, y: world.y } : null;
12589
+ if (this.fields.cursor) this.schedule();
12590
+ }
12591
+ onPointerEnd(e) {
12592
+ if (!e.isPrimary || this.lastPointer === null) return;
12593
+ this.lastPointer = null;
12594
+ if (this.fields.cursor) this.schedule();
12595
+ }
12596
+ refreshSelection() {
12597
+ try {
12598
+ const raw = this.host.getSelectedIds();
12599
+ const ids = this.selectionFilter ? this.selectionFilter(raw) : raw;
12600
+ if (!Array.isArray(ids)) throw new TypeError("selectionFilter must return an array");
12601
+ for (const id of ids) {
12602
+ if (typeof id !== "string") throw new TypeError("selectionFilter must return strings");
12603
+ if (id.length === 0 || id.length > MAX_SELECTION_ID_LENGTH) {
12604
+ throw new TypeError("selectionFilter must return ids of 1..128 characters");
12605
+ }
12606
+ }
12607
+ this.selection = ids.slice(0, AWARENESS_MAX_SELECTION);
12608
+ this.selectionFailed = false;
12609
+ } catch (error) {
12610
+ this.selection = [];
12611
+ this.selectionFailed = true;
12612
+ this.report(error);
12613
+ }
12614
+ }
12615
+ schedule() {
12616
+ if (this.isDisposed) return;
12617
+ this.dirty = true;
12618
+ if (this.throttleTimer !== null) return;
12619
+ const elapsed = this.lastSentAt === null ? Infinity : this.now() - this.lastSentAt;
12620
+ if (elapsed >= this.intervalMs) {
12621
+ this.flush();
12622
+ return;
12623
+ }
12624
+ this.throttleTimer = setTimeout(
12625
+ () => {
12626
+ this.throttleTimer = null;
12627
+ if (this.dirty) this.flush();
12628
+ },
12629
+ Math.min(this.intervalMs - elapsed, MAX_TIMER_DELAY_MS)
12630
+ );
12631
+ }
12632
+ flush() {
12633
+ this.dirty = false;
12634
+ this.lastSentAt = this.now();
12635
+ this.safeSend(this.getState());
12636
+ this.armHeartbeat();
12637
+ }
12638
+ armHeartbeat() {
12639
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12640
+ this.heartbeatTimer = null;
12641
+ if (this.heartbeatMs <= 0 || this.isDisposed) return;
12642
+ this.heartbeatTimer = setTimeout(
12643
+ () => {
12644
+ this.heartbeatTimer = null;
12645
+ this.flush();
12646
+ },
12647
+ Math.min(this.heartbeatMs, MAX_TIMER_DELAY_MS)
12648
+ );
12649
+ }
12650
+ safeSend(frame) {
12651
+ try {
12652
+ this.send(frame);
12653
+ } catch (error) {
12654
+ this.report(error);
12655
+ }
12656
+ }
12657
+ report(error) {
12658
+ try {
12659
+ this.onError?.(error);
12660
+ } catch {
12661
+ }
12662
+ }
12663
+ /**
12664
+ * Truncates identity strings to the wire caps and rejects an invalid id, so a
12665
+ * sender can never publish a frame that the wire guard would drop outright.
12666
+ * A truncated field is reported through `onError` (a `RangeError`) rather
12667
+ * than silently shortened, so a caller passing an over-long name finds out.
12668
+ */
12669
+ normalizeIdentity(identity) {
12670
+ if (identity.id.length === 0 || identity.id.length > MAX_IDENTITY_ID_LENGTH) {
12671
+ throw new RangeError("LocalAwareness: identity.id must be 1..128 characters");
12672
+ }
12673
+ const normalized = {
12674
+ id: identity.id
12675
+ };
12676
+ if (identity.name !== void 0) {
12677
+ normalized.name = identity.name.slice(0, MAX_IDENTITY_NAME_LENGTH);
12678
+ if (identity.name.length > MAX_IDENTITY_NAME_LENGTH) {
12679
+ this.report(
12680
+ new RangeError(
12681
+ `LocalAwareness: identity.name truncated to ${MAX_IDENTITY_NAME_LENGTH} characters`
12682
+ )
12683
+ );
12684
+ }
12685
+ }
12686
+ if (identity.color !== void 0) {
12687
+ normalized.color = identity.color.slice(0, MAX_IDENTITY_COLOR_LENGTH);
12688
+ if (identity.color.length > MAX_IDENTITY_COLOR_LENGTH) {
12689
+ this.report(
12690
+ new RangeError(
12691
+ `LocalAwareness: identity.color truncated to ${MAX_IDENTITY_COLOR_LENGTH} characters`
12692
+ )
12693
+ );
12694
+ }
12695
+ }
12696
+ if (identity.role !== void 0) {
12697
+ normalized.role = identity.role.slice(0, MAX_IDENTITY_ROLE_LENGTH);
12698
+ if (identity.role.length > MAX_IDENTITY_ROLE_LENGTH) {
12699
+ this.report(
12700
+ new RangeError(
12701
+ `LocalAwareness: identity.role truncated to ${MAX_IDENTITY_ROLE_LENGTH} characters`
12702
+ )
12703
+ );
12704
+ }
12705
+ }
12706
+ return normalized;
12707
+ }
12708
+ };
12709
+ function mergeFields(current, patch) {
12710
+ return Object.freeze({
12711
+ cursor: patch.cursor ?? current.cursor,
12712
+ selection: patch.selection ?? current.selection,
12713
+ tool: patch.tool ?? current.tool
12714
+ });
12715
+ }
12716
+
12717
+ // src/canvas/remote-cursor-overlay.ts
12718
+ var PEER_COLORS = Object.freeze([
12719
+ "#e11d48",
12720
+ "#ea580c",
12721
+ "#ca8a04",
12722
+ "#16a34a",
12723
+ "#0d9488",
12724
+ "#0284c7",
12725
+ "#2563eb",
12726
+ "#7c3aed",
12727
+ "#c026d3",
12728
+ "#db2777",
12729
+ "#4d7c0f",
12730
+ "#b45309"
12731
+ ]);
12732
+ function defaultPeerColor(seed) {
12733
+ if (seed.length === 0) return PEER_COLORS[0] ?? "#2563eb";
12734
+ let hash = 2166136261;
12735
+ for (let i = 0; i < seed.length; i++) {
12736
+ hash ^= seed.charCodeAt(i);
12737
+ hash = Math.imul(hash, 16777619) >>> 0;
12738
+ }
12739
+ return PEER_COLORS[hash % PEER_COLORS.length] ?? "#2563eb";
12740
+ }
12741
+ var DEFAULT_LABEL_FONT = "12px sans-serif";
12742
+ var LABEL_PAD_X = 6;
12743
+ var LABEL_PAD_Y = 3;
12744
+ var LABEL_HEIGHT = 16;
12745
+ var LABEL_OFFSET = 14;
12746
+ var MAX_LABEL_WIDTH_CACHE = 64;
12747
+ var RemoteCursorOverlay = class {
12748
+ host;
12749
+ roster;
12750
+ colorFor;
12751
+ showLabels;
12752
+ labelFont;
12753
+ labelWidths = /* @__PURE__ */ new Map();
12754
+ unregister;
12755
+ unsubscribe;
12756
+ isDisposed = false;
12757
+ constructor(host, roster, options = {}) {
12758
+ this.host = host;
12759
+ this.roster = roster;
12760
+ this.colorFor = options.colorFor;
12761
+ this.showLabels = options.showLabels ?? true;
12762
+ this.labelFont = options.labelFont ?? DEFAULT_LABEL_FONT;
12763
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12764
+ this.unsubscribe = roster.onChange(() => {
12765
+ if (this.labelWidths.size > MAX_LABEL_WIDTH_CACHE) this.labelWidths.clear();
12766
+ host.requestRender();
12767
+ });
12768
+ }
12769
+ get disposed() {
12770
+ return this.isDisposed;
12771
+ }
12772
+ resolveColor(peer) {
12773
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12774
+ }
12775
+ dispose() {
12776
+ if (this.isDisposed) return;
12777
+ this.isDisposed = true;
12778
+ this.unsubscribe?.();
12779
+ this.unsubscribe = null;
12780
+ this.unregister?.();
12781
+ this.unregister = null;
12782
+ this.labelWidths.clear();
12783
+ this.host.requestRender();
12784
+ }
12785
+ render(ctx) {
12786
+ if (this.isDisposed) return;
12787
+ const zoom = this.host.camera.zoom;
12788
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12789
+ for (const peer of this.roster.getPeers()) {
12790
+ if (peer.cursor === null) continue;
12791
+ const color = this.resolveColor(peer);
12792
+ ctx.save();
12793
+ ctx.translate(peer.cursor.x, peer.cursor.y);
12794
+ ctx.scale(inv, inv);
12795
+ ctx.beginPath();
12796
+ ctx.moveTo(0, 0);
12797
+ ctx.lineTo(0, 16);
12798
+ ctx.lineTo(4.5, 12.5);
12799
+ ctx.lineTo(11, 12.5);
12800
+ ctx.closePath();
12801
+ ctx.fillStyle = color;
12802
+ ctx.fill();
12803
+ ctx.strokeStyle = "#ffffff";
12804
+ ctx.lineWidth = 1;
12805
+ ctx.stroke();
12806
+ if (this.showLabels && peer.name !== void 0 && peer.name.length > 0) {
12807
+ this.drawLabel(ctx, peer.name, color);
12808
+ }
12809
+ ctx.restore();
12810
+ }
12811
+ }
12812
+ drawLabel(ctx, name, color) {
12813
+ ctx.font = this.labelFont;
12814
+ const key = `${this.labelFont} ${name}`;
12815
+ let width = this.labelWidths.get(key);
12816
+ if (width === void 0) {
12817
+ width = ctx.measureText(name).width;
12818
+ this.labelWidths.set(key, width);
12819
+ }
12820
+ const w = width + LABEL_PAD_X * 2;
12821
+ ctx.fillStyle = color;
12822
+ ctx.beginPath();
12823
+ ctx.roundRect(LABEL_OFFSET, LABEL_OFFSET, w, LABEL_HEIGHT + LABEL_PAD_Y, 4);
12824
+ ctx.fill();
12825
+ ctx.fillStyle = "#ffffff";
12826
+ ctx.textAlign = "left";
12827
+ ctx.textBaseline = "middle";
12828
+ ctx.fillText(name, LABEL_OFFSET + LABEL_PAD_X, LABEL_OFFSET + (LABEL_HEIGHT + LABEL_PAD_Y) / 2);
12829
+ }
12830
+ };
12831
+
12832
+ // src/canvas/remote-selection-overlay.ts
12833
+ var DEFAULT_ALPHA = 0.6;
12834
+ var DEFAULT_LINE_WIDTH_PX = 2;
12835
+ var RemoteSelectionOverlay = class {
12836
+ host;
12837
+ roster;
12838
+ colorFor;
12839
+ alpha;
12840
+ lineWidthPx;
12841
+ signatures = [];
12842
+ outlines = [];
12843
+ storeDirty = true;
12844
+ unregister;
12845
+ unsubscribers = [];
12846
+ isDisposed = false;
12847
+ constructor(host, roster, options = {}) {
12848
+ this.host = host;
12849
+ this.roster = roster;
12850
+ this.colorFor = options.colorFor;
12851
+ this.alpha = options.alpha ?? DEFAULT_ALPHA;
12852
+ this.lineWidthPx = options.lineWidthPx ?? DEFAULT_LINE_WIDTH_PX;
12853
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12854
+ const invalidate = () => {
12855
+ this.storeDirty = true;
12856
+ host.requestRender();
12857
+ };
12858
+ this.unsubscribers.push(
12859
+ roster.onChange(() => host.requestRender()),
12860
+ host.store.onChange(invalidate),
12861
+ host.layerManager.on("change", invalidate)
12862
+ );
12863
+ }
12864
+ get disposed() {
12865
+ return this.isDisposed;
12866
+ }
12867
+ dispose() {
12868
+ if (this.isDisposed) return;
12869
+ this.isDisposed = true;
12870
+ for (const unsub of this.unsubscribers) unsub();
12871
+ this.unsubscribers.length = 0;
12872
+ this.unregister?.();
12873
+ this.unregister = null;
12874
+ this.signatures = [];
12875
+ this.outlines = [];
12876
+ this.host.requestRender();
12877
+ }
12878
+ resolveColor(peer) {
12879
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12880
+ }
12881
+ /** Recomputes outlines only when the selection signature or the store/layers changed. */
12882
+ rebuild() {
12883
+ const peers = this.roster.getPeers();
12884
+ const next = [];
12885
+ for (const peer of peers) {
12886
+ if (peer.selection.length === 0) continue;
12887
+ next.push({ from: peer.from, selection: peer.selection, color: this.resolveColor(peer) });
12888
+ }
12889
+ let changed = this.storeDirty || next.length !== this.signatures.length;
12890
+ if (!changed) {
12891
+ for (let i = 0; i < next.length; i++) {
12892
+ const a = next[i];
12893
+ const b = this.signatures[i];
12894
+ if (!a || !b || a.from !== b.from || a.selection !== b.selection || a.color !== b.color) {
12895
+ changed = true;
12896
+ break;
12897
+ }
12898
+ }
12899
+ }
12900
+ if (!changed) return;
12901
+ this.storeDirty = false;
12902
+ this.signatures = next;
12903
+ if (next.length === 0) {
12904
+ this.outlines = [];
12905
+ return;
12906
+ }
12907
+ const colorById = /* @__PURE__ */ new Map();
12908
+ for (const sig of next) {
12909
+ for (const id of sig.selection) if (!colorById.has(id)) colorById.set(id, sig.color);
12910
+ }
12911
+ const layers = this.host.layerManager;
12912
+ const rects = computeElementRects(
12913
+ this.host.store,
12914
+ (element) => colorById.has(element.id) && layers.isLayerVisible(element.layerId) ? element.id : null
12915
+ );
12916
+ this.outlines = rects.map((rect) => ({ rect, color: colorById.get(rect.id) ?? "#2563eb" }));
12917
+ }
12918
+ render(ctx) {
12919
+ if (this.isDisposed) return;
12920
+ this.rebuild();
12921
+ if (this.outlines.length === 0) return;
12922
+ const zoom = this.host.camera.zoom;
12923
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12924
+ ctx.save();
12925
+ ctx.globalAlpha = this.alpha;
12926
+ ctx.lineWidth = this.lineWidthPx * inv;
12927
+ for (const { rect, color } of this.outlines) {
12928
+ ctx.save();
12929
+ ctx.strokeStyle = color;
12930
+ ctx.translate(rect.x + rect.w / 2, rect.y + rect.h / 2);
12931
+ if (rect.rotation !== 0) ctx.rotate(rect.rotation);
12932
+ ctx.strokeRect(-rect.w / 2, -rect.h / 2, rect.w, rect.h);
12933
+ ctx.restore();
12934
+ }
12935
+ ctx.restore();
12936
+ }
12937
+ };
12938
+
12939
+ // src/canvas/attach-awareness.ts
12940
+ function attachAwareness(viewport, channel, options) {
12941
+ const {
12942
+ roster: rosterOptions,
12943
+ cursors: cursorOptions,
12944
+ selections: selectionOptions,
12945
+ publish,
12946
+ ...localOptions
12947
+ } = options;
12948
+ const roster = new PeerRoster(rosterOptions);
12949
+ let local = null;
12950
+ let cursors = null;
12951
+ let selections = null;
12952
+ const unsubscribers = [];
12953
+ try {
12954
+ local = publish === false ? null : new LocalAwareness(viewport, {
12955
+ ...localOptions,
12956
+ send: (data) => channel.sendPresence(data)
12957
+ });
12958
+ cursors = cursorOptions === false ? null : new RemoteCursorOverlay(viewport, roster, cursorOptions ?? {});
12959
+ selections = selectionOptions === void 0 || selectionOptions === false ? null : new RemoteSelectionOverlay(
12960
+ viewport,
12961
+ roster,
12962
+ selectionOptions === true ? {} : selectionOptions
12963
+ );
12964
+ if (publish !== false) unsubscribers.push(roster.onDiscover(() => local?.announce()));
12965
+ unsubscribers.push(
12966
+ channel.onPresence((from, data) => {
12967
+ roster.apply(from, data);
12968
+ })
12969
+ );
12970
+ unsubscribers.push(channel.onPresenceLeave((from) => roster.remove(from)));
12971
+ } catch (error) {
12972
+ for (let i = unsubscribers.length - 1; i >= 0; i--) {
12973
+ try {
12974
+ unsubscribers[i]?.();
12975
+ } catch {
12976
+ }
12977
+ }
12978
+ unsubscribers.length = 0;
12979
+ try {
12980
+ selections?.dispose();
12981
+ } catch {
12982
+ }
12983
+ try {
12984
+ cursors?.dispose();
12985
+ } catch {
12986
+ }
12987
+ try {
12988
+ local?.dispose();
12989
+ } catch {
12990
+ }
12991
+ try {
12992
+ roster.dispose();
12993
+ } catch {
12994
+ }
12995
+ throw error;
12996
+ }
12997
+ let disposed = false;
12998
+ return {
12999
+ roster,
13000
+ local,
13001
+ cursors,
13002
+ selections,
13003
+ announce: () => local?.announce(),
13004
+ setFields: (fields) => local?.setFields(fields),
13005
+ dispose: () => {
13006
+ if (disposed) return;
13007
+ disposed = true;
11788
13008
  try {
11789
- listener(reason);
13009
+ local?.dispose();
13010
+ } catch {
13011
+ }
13012
+ try {
13013
+ cursors?.dispose();
13014
+ } catch {
13015
+ }
13016
+ try {
13017
+ selections?.dispose();
13018
+ } catch {
13019
+ }
13020
+ try {
13021
+ roster.dispose();
11790
13022
  } catch {
11791
13023
  }
13024
+ for (const unsub of unsubscribers) {
13025
+ try {
13026
+ unsub();
13027
+ } catch {
13028
+ }
13029
+ }
13030
+ unsubscribers.length = 0;
11792
13031
  }
11793
- }
11794
- };
11795
-
11796
- // src/canvas/focus-presence.ts
11797
- var FOCUS_PRESENCE_KIND = "focus";
11798
- var AUDIENCES = ["all", "players", "display"];
11799
- function isPositiveFinite(value) {
11800
- return typeof value === "number" && Number.isFinite(value) && value > 0;
11801
- }
11802
- function isFiniteNumber2(value) {
11803
- return typeof value === "number" && Number.isFinite(value);
11804
- }
11805
- function isFocusPresence(data) {
11806
- if (typeof data !== "object" || data === null) return false;
11807
- const payload = data;
11808
- if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
11809
- if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
11810
- if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
11811
- if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
11812
- return false;
11813
- }
11814
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11815
- return true;
11816
- }
11817
- function toFocusPresence(view, audience, color) {
11818
- return {
11819
- kind: FOCUS_PRESENCE_KIND,
11820
- x: view.x,
11821
- y: view.y,
11822
- w: view.w,
11823
- h: view.h,
11824
- audience,
11825
- ...color === void 0 ? {} : { color }
11826
13032
  };
11827
13033
  }
11828
13034
 
11829
- // src/canvas/remote-focus-receiver.ts
11830
- function audienceIncludes(audience, role) {
11831
- if (role === "dm") return false;
11832
- if (audience === "all") return true;
11833
- if (audience === "players") return role === "player";
11834
- return role === "display";
11835
- }
11836
- var RemoteFocusReceiver = class {
11837
- role;
11838
- animator;
11839
- animate;
11840
- pulseColor;
11841
- overlay;
11842
- disposed = false;
11843
- constructor(host, options) {
11844
- this.role = options.role;
11845
- this.animator = options.animator;
11846
- this.animate = options.animate ?? true;
11847
- this.pulseColor = options.pulseColor;
11848
- this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
11849
- ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
11850
- ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
11851
- ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
11852
- maxPingsPerSender: 1
11853
- }) : null;
11854
- }
11855
- /**
11856
- * Applies a presence payload from `sender`. Returns `false` for payloads
11857
- * that are not focus frames, or are addressed to a different role, so hosts
11858
- * can feed every presence frame through without disturbing other handlers.
11859
- */
11860
- apply(from, data) {
11861
- if (this.disposed || !isFocusPresence(data)) return false;
11862
- if (!audienceIncludes(data.audience, this.role)) return false;
11863
- const view = { x: data.x, y: data.y, w: data.w, h: data.h };
11864
- if (this.animate) {
11865
- this.animator.animateTo(view);
11866
- } else {
11867
- this.animator.jumpTo(view);
11868
- }
11869
- this.overlay?.apply(from, {
11870
- kind: "ping",
11871
- x: view.x + view.w / 2,
11872
- y: view.y + view.h / 2,
11873
- color: data.color ?? this.pulseColor
11874
- });
11875
- return true;
11876
- }
11877
- /** Idempotent. Does NOT dispose the animator — the host owns that. */
11878
- dispose() {
11879
- if (this.disposed) return;
11880
- this.disposed = true;
11881
- this.overlay?.dispose();
11882
- }
11883
- };
11884
-
11885
13035
  // src/tools/hand-tool.ts
11886
13036
  var HandTool = class {
11887
13037
  name = "hand";
@@ -12714,7 +13864,8 @@ var SelectTool = class {
12714
13864
  } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
12715
13865
  const centerX = el.position.x + el.size.w / 2 + adjDx;
12716
13866
  const centerY = el.position.y + el.size.h / 2 + adjDy;
12717
- const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
13867
+ const footprint = footprintFromSize(el.size, ctx.gridSize ?? 0);
13868
+ const snappedCenter = snapFootprintCenter({ x: centerX, y: centerY }, footprint, ctx);
12718
13869
  ctx.store.update(id, {
12719
13870
  position: {
12720
13871
  x: snappedCenter.x - el.size.w / 2,
@@ -13292,15 +14443,15 @@ var ShapeTool = class {
13292
14443
  }
13293
14444
  onActivate(_ctx) {
13294
14445
  if (typeof window !== "undefined") {
13295
- window.addEventListener("keydown", this.onKeyDown);
13296
- window.addEventListener("keyup", this.onKeyUp);
14446
+ window.addEventListener("keydown", this.trackShiftKeyDown);
14447
+ window.addEventListener("keyup", this.trackShiftKeyUp);
13297
14448
  }
13298
14449
  }
13299
14450
  onDeactivate(_ctx) {
13300
14451
  this.shiftHeld = false;
13301
14452
  if (typeof window !== "undefined") {
13302
- window.removeEventListener("keydown", this.onKeyDown);
13303
- window.removeEventListener("keyup", this.onKeyUp);
14453
+ window.removeEventListener("keydown", this.trackShiftKeyDown);
14454
+ window.removeEventListener("keyup", this.trackShiftKeyUp);
13304
14455
  }
13305
14456
  }
13306
14457
  onPointerDown(state, ctx) {
@@ -13393,10 +14544,13 @@ var ShapeTool = class {
13393
14544
  snap(point, ctx) {
13394
14545
  return smartSnap(point, ctx);
13395
14546
  }
13396
- onKeyDown = (e) => {
14547
+ // Deliberately NOT migrated to `Tool.onKeyDown`: that hook is gated by
14548
+ // editable-target and keyboard-scope checks, and a Shift release must be
14549
+ // observed wherever it happens or the constraint sticks after the key is up.
14550
+ trackShiftKeyDown = (e) => {
13397
14551
  if (e.key === "Shift") this.shiftHeld = true;
13398
14552
  };
13399
- onKeyUp = (e) => {
14553
+ trackShiftKeyUp = (e) => {
13400
14554
  if (e.key === "Shift") this.shiftHeld = false;
13401
14555
  };
13402
14556
  };
@@ -13540,6 +14694,353 @@ var MeasureTool = class {
13540
14694
  }
13541
14695
  };
13542
14696
 
14697
+ // src/tools/path-tool.ts
14698
+ var EPS2 = 1e-6;
14699
+ var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
14700
+ function samePoint3(a, b) {
14701
+ return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
14702
+ }
14703
+ var PathTool = class {
14704
+ name = "path";
14705
+ feetPerCell;
14706
+ color;
14707
+ diagonalRule;
14708
+ footprintOption;
14709
+ rangeBands;
14710
+ commitTapRadiusPx;
14711
+ resolveStart;
14712
+ waypoints = [];
14713
+ cursor = null;
14714
+ anchorKey;
14715
+ footprint = 1;
14716
+ pointerDown = false;
14717
+ commitOnUp = false;
14718
+ // Grid state is captured at the opening pointer-down so a path measured on
14719
+ // one grid keeps that metric even if the host reconfigures mid-gesture.
14720
+ gridSize = 0;
14721
+ gridType;
14722
+ hexOrientation;
14723
+ snapEnabled = false;
14724
+ optionListeners = /* @__PURE__ */ new Set();
14725
+ pathListeners = /* @__PURE__ */ new Set();
14726
+ commitListeners = /* @__PURE__ */ new Set();
14727
+ emissionRafId = null;
14728
+ constructor(options = {}) {
14729
+ this.feetPerCell = options.feetPerCell ?? 5;
14730
+ this.color = options.color ?? "#FF5722";
14731
+ this.diagonalRule = options.diagonalRule ?? "euclidean";
14732
+ this.footprintOption = options.footprint ?? 1;
14733
+ this.rangeBands = options.rangeBands ?? [];
14734
+ this.commitTapRadiusPx = options.commitTapRadiusPx ?? DEFAULT_COMMIT_TAP_RADIUS_PX;
14735
+ this.resolveStart = options.resolveStart;
14736
+ }
14737
+ getOptions() {
14738
+ return {
14739
+ feetPerCell: this.feetPerCell,
14740
+ color: this.color,
14741
+ diagonalRule: this.diagonalRule,
14742
+ footprint: this.footprintOption,
14743
+ rangeBands: this.rangeBands,
14744
+ commitTapRadiusPx: this.commitTapRadiusPx,
14745
+ resolveStart: this.resolveStart
14746
+ };
14747
+ }
14748
+ setOptions(options) {
14749
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
14750
+ if (options.color !== void 0) this.color = options.color;
14751
+ if (options.diagonalRule !== void 0) this.diagonalRule = options.diagonalRule;
14752
+ if (options.footprint !== void 0) this.footprintOption = options.footprint;
14753
+ if (options.rangeBands !== void 0) this.rangeBands = options.rangeBands;
14754
+ if (options.commitTapRadiusPx !== void 0) {
14755
+ this.commitTapRadiusPx = options.commitTapRadiusPx;
14756
+ }
14757
+ if (options.resolveStart !== void 0) this.resolveStart = options.resolveStart;
14758
+ this.notifyOptionsChange();
14759
+ }
14760
+ onOptionsChange(listener) {
14761
+ this.optionListeners.add(listener);
14762
+ return () => this.optionListeners.delete(listener);
14763
+ }
14764
+ /**
14765
+ * Subscribes to raf-coalesced path snapshots. While a path is open,
14766
+ * listeners receive at most one snapshot per animation frame carrying the
14767
+ * latest state; `null` is delivered synchronously when the path closes
14768
+ * (commit, cancel, or deactivate).
14769
+ */
14770
+ onPath(listener) {
14771
+ this.pathListeners.add(listener);
14772
+ return () => this.pathListeners.delete(listener);
14773
+ }
14774
+ /**
14775
+ * Subscribes to finished paths. The emission carries `cursor: null` and the
14776
+ * final waypoints; applying the move (and recording history for it) is the
14777
+ * host's job — the tool has already forgotten the path by then.
14778
+ */
14779
+ onCommit(listener) {
14780
+ this.commitListeners.add(listener);
14781
+ return () => this.commitListeners.delete(listener);
14782
+ }
14783
+ get isOpen() {
14784
+ return this.waypoints.length > 0;
14785
+ }
14786
+ /** Synchronous snapshot of the open path; `null` when no path is open. */
14787
+ getEmission() {
14788
+ if (!this.isOpen) return null;
14789
+ return this.buildEmission(this.cursor);
14790
+ }
14791
+ onPointerDown(state, ctx) {
14792
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14793
+ if (!this.isOpen) {
14794
+ const anchor = this.resolveStart ? this.resolveStart(world, ctx) : { origin: world };
14795
+ if (!anchor) return;
14796
+ this.gridSize = ctx.gridSize ?? 0;
14797
+ this.gridType = ctx.gridType;
14798
+ this.hexOrientation = ctx.hexOrientation;
14799
+ this.snapEnabled = ctx.snapToGrid === true;
14800
+ this.footprint = anchor.footprint ?? this.footprintOption;
14801
+ const origin = this.snap(anchor.origin);
14802
+ this.waypoints = [origin];
14803
+ this.cursor = { ...origin };
14804
+ this.anchorKey = anchor.anchorKey;
14805
+ this.pointerDown = true;
14806
+ this.scheduleEmission();
14807
+ ctx.requestRender();
14808
+ return;
14809
+ }
14810
+ const point = this.snap(world);
14811
+ const last = this.lastWaypoint();
14812
+ if (last && this.withinCommitRadius(point, last, ctx)) {
14813
+ this.commitOnUp = true;
14814
+ this.cursor = { ...last };
14815
+ } else {
14816
+ this.cursor = point;
14817
+ }
14818
+ this.pointerDown = true;
14819
+ this.scheduleEmission();
14820
+ ctx.requestRender();
14821
+ }
14822
+ onPointerMove(state, ctx) {
14823
+ if (!this.isOpen) return;
14824
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14825
+ const point = this.snap(world);
14826
+ const last = this.lastWaypoint();
14827
+ if (this.commitOnUp && last && this.withinCommitRadius(point, last, ctx)) {
14828
+ this.cursor = { ...last };
14829
+ } else {
14830
+ this.commitOnUp = false;
14831
+ this.cursor = point;
14832
+ }
14833
+ this.scheduleEmission();
14834
+ ctx.requestRender();
14835
+ }
14836
+ onPointerUp(_state, ctx) {
14837
+ if (!this.isOpen) return;
14838
+ this.pointerDown = false;
14839
+ if (this.commitOnUp) {
14840
+ this.commit(ctx);
14841
+ return;
14842
+ }
14843
+ const last = this.lastWaypoint();
14844
+ if (this.cursor && last && !samePoint3(this.cursor, last)) {
14845
+ this.waypoints.push({ ...this.cursor });
14846
+ }
14847
+ this.scheduleEmission();
14848
+ ctx.requestRender();
14849
+ }
14850
+ onHover(state, ctx) {
14851
+ if (!this.isOpen || this.pointerDown) return;
14852
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14853
+ this.cursor = this.snap(world);
14854
+ this.scheduleEmission();
14855
+ ctx.requestRender();
14856
+ }
14857
+ /** A takeover (second pointer, platform cancel) abandons the path outright. */
14858
+ onPointerCancel(_state, ctx) {
14859
+ this.cancel(ctx);
14860
+ }
14861
+ onDeactivate(ctx) {
14862
+ this.cancel(ctx);
14863
+ }
14864
+ onKeyDown(event, ctx) {
14865
+ if (!this.isOpen) return false;
14866
+ if (event.key === "Enter") {
14867
+ this.commit(ctx);
14868
+ return true;
14869
+ }
14870
+ if (event.key === "Escape") {
14871
+ this.cancel(ctx);
14872
+ return true;
14873
+ }
14874
+ return false;
14875
+ }
14876
+ renderOverlay(ctx) {
14877
+ if (!this.isOpen) return;
14878
+ const { points, cumulative, total } = this.measure(this.cursor);
14879
+ const cumulativeFeet = cumulative.map((cells) => cells * this.feetPerCell);
14880
+ drawPath(ctx, {
14881
+ points,
14882
+ segmentColors: resolveSegmentColors(cumulativeFeet, this.rangeBands, this.color),
14883
+ color: this.color,
14884
+ feet: total * this.feetPerCell
14885
+ });
14886
+ }
14887
+ lastWaypoint() {
14888
+ return this.waypoints[this.waypoints.length - 1];
14889
+ }
14890
+ /**
14891
+ * Is `point` close enough to `last` to mean "finish here"? EXACT match always
14892
+ * qualifies. The screen-space tolerance is added ONLY when this path has no
14893
+ * active snapping grid: on a snapping grid (`gridType === 'square'`, or
14894
+ * `'hex'` with a captured orientation, and `gridSize > 0`) the adjacent
14895
+ * cell/hex centre can be as little as one grid cell away, and a radius
14896
+ * computed from CSS pixels has no relationship to that distance — at a
14897
+ * sufficiently zoomed-out camera the radius would swallow a whole neighbour
14898
+ * cell and turn a deliberate next-waypoint tap into an unwanted commit.
14899
+ * Snapping already makes a fingertip tap land exactly on the last waypoint,
14900
+ * so the tolerance is unnecessary there; it exists for the gridless and
14901
+ * `snapPoint`/identity paths, where a fingertip can never land on an exact
14902
+ * world point.
14903
+ */
14904
+ withinCommitRadius(point, last, ctx) {
14905
+ if (samePoint3(point, last)) return true;
14906
+ if (this.hasSnappingGrid()) return false;
14907
+ const zoom = ctx.camera.zoom;
14908
+ if (!(zoom > 0)) return false;
14909
+ const radius = this.commitTapRadiusPx / zoom;
14910
+ if (radius <= 0) return false;
14911
+ const dx = point.x - last.x;
14912
+ const dy = point.y - last.y;
14913
+ return dx * dx + dy * dy <= radius * radius;
14914
+ }
14915
+ /**
14916
+ * True when this path's captured grid state snaps every waypoint onto a
14917
+ * cell/hex centre: a `square` grid, or a `hex` grid with a captured
14918
+ * orientation, both with a usable `gridSize`. Mirrors the unconditional
14919
+ * branches of `snap` below — NOT the `snapToGrid`-gated fallback, which
14920
+ * leaves waypoints unsnapped when the user turns snapping off.
14921
+ */
14922
+ hasSnappingGrid() {
14923
+ if (!(this.gridSize > 0)) return false;
14924
+ if (this.gridType === "square") return true;
14925
+ return this.gridType === "hex" && this.hexOrientation !== void 0;
14926
+ }
14927
+ /**
14928
+ * Snapping to cell or hex centres is UNCONDITIONAL for a `square` grid, or a
14929
+ * `hex` grid WITH a captured orientation: a movement path measures in cells,
14930
+ * so an unsnapped waypoint would report a distance the grid does not agree
14931
+ * with. A `hex` grid WITHOUT an orientation falls through to the
14932
+ * `snapToGrid`-gated `snapPoint` (intersection) branch below, same as the
14933
+ * gridType-less case — unlike `smartSnap`, which is off entirely when the
14934
+ * user turns snapping off.
14935
+ */
14936
+ snap(point) {
14937
+ if (this.gridSize <= 0) return point;
14938
+ if (this.gridType === "hex" && this.hexOrientation) {
14939
+ return snapToHexCenter(point, this.gridSize, this.hexOrientation);
14940
+ }
14941
+ if (this.gridType === "square") {
14942
+ return snapToCellCenter(point, this.gridSize, this.footprint);
14943
+ }
14944
+ if (this.snapEnabled) {
14945
+ return snapPoint(point, this.gridSize);
14946
+ }
14947
+ return point;
14948
+ }
14949
+ /** The measured polyline: waypoints, plus the cursor when it adds a leg. */
14950
+ measure(cursor) {
14951
+ const points = this.waypoints.map((p) => ({ ...p }));
14952
+ const last = points[points.length - 1];
14953
+ if (cursor && (!last || !samePoint3(cursor, last))) points.push({ ...cursor });
14954
+ const { total, cumulative } = pathDistanceCells(points, {
14955
+ gridSize: this.gridSize,
14956
+ gridType: this.gridType,
14957
+ hexOrientation: this.hexOrientation,
14958
+ diagonalRule: this.diagonalRule
14959
+ });
14960
+ return { points, cumulative, total };
14961
+ }
14962
+ buildEmission(cursor) {
14963
+ const { cumulative, total } = this.measure(cursor);
14964
+ const segments = [];
14965
+ for (let i = 1; i < cumulative.length; i++) {
14966
+ const cells = (cumulative[i] ?? 0) - (cumulative[i - 1] ?? 0);
14967
+ segments.push({ cells, feet: cells * this.feetPerCell });
14968
+ }
14969
+ return {
14970
+ anchorKey: this.anchorKey,
14971
+ waypoints: this.waypoints.map((p) => ({ ...p })),
14972
+ cursor: cursor ? { ...cursor } : null,
14973
+ segments,
14974
+ totalCells: total,
14975
+ totalFeet: total * this.feetPerCell,
14976
+ color: this.color,
14977
+ rangeBands: this.rangeBands
14978
+ };
14979
+ }
14980
+ commit(ctx) {
14981
+ if (this.waypoints.length < 2) {
14982
+ this.cancel(ctx);
14983
+ return;
14984
+ }
14985
+ const emission = this.buildEmission(null);
14986
+ this.reset();
14987
+ this.emitCommit(emission);
14988
+ this.emitClear();
14989
+ ctx.requestRender();
14990
+ }
14991
+ cancel(ctx) {
14992
+ if (!this.isOpen) return;
14993
+ this.reset();
14994
+ this.emitClear();
14995
+ ctx.requestRender();
14996
+ }
14997
+ reset() {
14998
+ this.waypoints = [];
14999
+ this.cursor = null;
15000
+ this.anchorKey = void 0;
15001
+ this.footprint = this.footprintOption;
15002
+ this.pointerDown = false;
15003
+ this.commitOnUp = false;
15004
+ }
15005
+ notifyOptionsChange() {
15006
+ for (const listener of this.optionListeners) listener();
15007
+ }
15008
+ scheduleEmission() {
15009
+ if (this.pathListeners.size === 0) return;
15010
+ if (this.emissionRafId !== null) return;
15011
+ this.emissionRafId = requestAnimationFrame(() => {
15012
+ this.emissionRafId = null;
15013
+ const emission = this.getEmission();
15014
+ if (!emission) return;
15015
+ this.emitPath(emission);
15016
+ });
15017
+ }
15018
+ emitClear() {
15019
+ if (this.emissionRafId !== null) {
15020
+ cancelAnimationFrame(this.emissionRafId);
15021
+ this.emissionRafId = null;
15022
+ }
15023
+ if (this.pathListeners.size === 0) return;
15024
+ this.emitPath(null);
15025
+ }
15026
+ emitPath(emission) {
15027
+ for (const listener of this.pathListeners) {
15028
+ try {
15029
+ listener(emission);
15030
+ } catch {
15031
+ }
15032
+ }
15033
+ }
15034
+ emitCommit(emission) {
15035
+ for (const listener of this.commitListeners) {
15036
+ try {
15037
+ listener(emission);
15038
+ } catch {
15039
+ }
15040
+ }
15041
+ }
15042
+ };
15043
+
13543
15044
  // src/tools/template-tool.ts
13544
15045
  var MIN_RECT_WIDTH = 20;
13545
15046
  function defaultRectWidth(radius, scaleUnit) {
@@ -13833,7 +15334,7 @@ var TemplateTool = class {
13833
15334
  };
13834
15335
 
13835
15336
  // src/tools/laser-tool.ts
13836
- var DEFAULT_COLOR5 = "#ff3b30";
15337
+ var DEFAULT_COLOR6 = "#ff3b30";
13837
15338
  var DEFAULT_WIDTH3 = 4;
13838
15339
  var DEFAULT_FADE_MS3 = 1200;
13839
15340
  var LaserTool = class {
@@ -13849,7 +15350,7 @@ var LaserTool = class {
13849
15350
  pendingEmission = [];
13850
15351
  constructor(options = {}) {
13851
15352
  this.name = options.name ?? "laser";
13852
- this.color = options.color ?? DEFAULT_COLOR5;
15353
+ this.color = options.color ?? DEFAULT_COLOR6;
13853
15354
  this.width = options.width ?? DEFAULT_WIDTH3;
13854
15355
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
13855
15356
  }
@@ -13977,7 +15478,7 @@ var LaserTool = class {
13977
15478
  };
13978
15479
 
13979
15480
  // src/tools/ping-tool.ts
13980
- var DEFAULT_COLOR6 = "#ff3b30";
15481
+ var DEFAULT_COLOR7 = "#ff3b30";
13981
15482
  var DEFAULT_DURATION_MS4 = 1800;
13982
15483
  var DEFAULT_RADIUS4 = 48;
13983
15484
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -13994,7 +15495,7 @@ var PingTool = class {
13994
15495
  pingListeners = /* @__PURE__ */ new Set();
13995
15496
  constructor(options = {}) {
13996
15497
  this.name = options.name ?? "ping";
13997
- this.color = options.color ?? DEFAULT_COLOR6;
15498
+ this.color = options.color ?? DEFAULT_COLOR7;
13998
15499
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
13999
15500
  this.radius = options.radius ?? DEFAULT_RADIUS4;
14000
15501
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -14097,9 +15598,11 @@ var PingTool = class {
14097
15598
  };
14098
15599
 
14099
15600
  // src/index.ts
14100
- var VERSION = "0.63.0";
15601
+ var VERSION = "0.65.0";
14101
15602
  // Annotate the CommonJS export names for ESM import in node:
14102
15603
  0 && (module.exports = {
15604
+ AWARENESS_MAX_SELECTION,
15605
+ AWARENESS_PRESENCE_KIND,
14103
15606
  ArrowTool,
14104
15607
  AutoSave,
14105
15608
  Camera,
@@ -14118,20 +15621,29 @@ var VERSION = "0.63.0";
14118
15621
  LASER_TRAIL_PRESENCE_KIND,
14119
15622
  LaserTool,
14120
15623
  LayerManager,
15624
+ LocalAwareness,
14121
15625
  LocalStorageAdapter,
14122
15626
  MEASURE_PRESENCE_KIND,
14123
15627
  MeasureTool,
14124
15628
  MemoryAdapter,
14125
15629
  MinimapController,
14126
15630
  NoteTool,
15631
+ PATH_PRESENCE_KIND,
15632
+ PATH_PRESENCE_MAX_POINTS,
15633
+ PEER_COLORS,
14127
15634
  PING_PRESENCE_KIND,
15635
+ PathTool,
15636
+ PeerRoster,
14128
15637
  PencilTool,
14129
15638
  PingInput,
14130
15639
  PingTool,
15640
+ RemoteCursorOverlay,
14131
15641
  RemoteFocusReceiver,
14132
15642
  RemoteLaserOverlay,
14133
15643
  RemoteMeasureOverlay,
15644
+ RemotePathOverlay,
14134
15645
  RemotePingOverlay,
15646
+ RemoteSelectionOverlay,
14135
15647
  SelectTool,
14136
15648
  ShapeTool,
14137
15649
  TemplateTool,
@@ -14140,6 +15652,7 @@ var VERSION = "0.63.0";
14140
15652
  VERSION,
14141
15653
  Viewport,
14142
15654
  applyCameraView,
15655
+ attachAwareness,
14143
15656
  boundsIntersect,
14144
15657
  cameraOriginForView,
14145
15658
  captureCameraView,
@@ -14153,11 +15666,13 @@ var VERSION = "0.63.0";
14153
15666
  createStroke,
14154
15667
  createTemplate,
14155
15668
  createText,
15669
+ defaultPeerColor,
14156
15670
  drawHexPath,
14157
15671
  elementRectsEqual,
14158
15672
  exportImage,
14159
15673
  exportSvg,
14160
15674
  fitZoomForView,
15675
+ footprintFromSize,
14161
15676
  getActiveFormats,
14162
15677
  getArrowBounds,
14163
15678
  getArrowControlPoint,
@@ -14173,20 +15688,27 @@ var VERSION = "0.63.0";
14173
15688
  getHexCellsInRectangle,
14174
15689
  getHexCellsInSquare,
14175
15690
  getHexDistance,
15691
+ gridDistanceCells,
15692
+ isAwarenessPresence,
14176
15693
  isFocusPresence,
14177
15694
  isLaserTrailPresence,
14178
15695
  isMeasurePresence,
14179
15696
  isNearBezier,
15697
+ isPathPresence,
14180
15698
  isPingPresence,
15699
+ pathDistanceCells,
14181
15700
  resolveHtmlRouting,
14182
15701
  setFontSize,
14183
15702
  smartSnap,
15703
+ snapFootprintCenter,
14184
15704
  snapPoint,
15705
+ snapToCellCenter,
14185
15706
  snapToHexCenter,
14186
15707
  styleToPatch,
14187
15708
  toFocusPresence,
14188
15709
  toLaserTrailPresence,
14189
15710
  toMeasurePresence,
15711
+ toPathPresence,
14190
15712
  toPingPresence,
14191
15713
  toggleBold,
14192
15714
  toggleItalic,