@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.js CHANGED
@@ -29,6 +29,310 @@ function smartSnap(point, ctx) {
29
29
  }
30
30
  return snapPoint(point, ctx.gridSize);
31
31
  }
32
+ function footprintOf(footprint) {
33
+ return typeof footprint === "number" ? { w: footprint, h: footprint } : footprint;
34
+ }
35
+ function footprintFromSize(size, gridSize) {
36
+ if (!(gridSize > 0)) return 1;
37
+ return {
38
+ w: Math.max(1, Math.round(size.w / gridSize)),
39
+ h: Math.max(1, Math.round(size.h / gridSize))
40
+ };
41
+ }
42
+ function snapAxisToCell(value, gridSize, cells) {
43
+ const n2 = Math.max(1, Math.round(cells));
44
+ if (n2 % 2 === 0) return Math.round(value / gridSize) * gridSize || 0;
45
+ return (Math.round((value - gridSize / 2) / gridSize) + 0.5) * gridSize || 0;
46
+ }
47
+ function snapToCellCenter(point, gridSize, footprint = 1) {
48
+ const { w, h } = footprintOf(footprint);
49
+ return { x: snapAxisToCell(point.x, gridSize, w), y: snapAxisToCell(point.y, gridSize, h) };
50
+ }
51
+ function snapFootprintCenter(point, footprint, ctx) {
52
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
53
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
54
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
55
+ }
56
+ return snapToCellCenter(point, ctx.gridSize, footprint);
57
+ }
58
+
59
+ // src/elements/hex-fill.ts
60
+ function offsetToCube(col, row, orientation) {
61
+ if (orientation === "pointy") {
62
+ return { q: col - (row - (row & 1)) / 2, r: row };
63
+ }
64
+ return { q: col, r: row - (col - (col & 1)) / 2 };
65
+ }
66
+ function cubeToOffset(q, r, orientation) {
67
+ if (orientation === "pointy") {
68
+ return { col: q + (r - (r & 1)) / 2, row: r };
69
+ }
70
+ return { col: q, row: r + (q - (q & 1)) / 2 };
71
+ }
72
+ function offsetToPixel(col, row, cellSize, orientation) {
73
+ if (orientation === "pointy") {
74
+ const hexW = Math.sqrt(3) * cellSize;
75
+ const rowH = 1.5 * cellSize;
76
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
77
+ return { x: col * hexW + offsetX, y: row * rowH };
78
+ }
79
+ const hexH = Math.sqrt(3) * cellSize;
80
+ const colW = 1.5 * cellSize;
81
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
82
+ return { x: col * colW, y: row * hexH + offsetY };
83
+ }
84
+ function pixelToOffset(x, y, cellSize, orientation) {
85
+ if (orientation === "pointy") {
86
+ const hexW = Math.sqrt(3) * cellSize;
87
+ const rowH = 1.5 * cellSize;
88
+ const row = Math.round(y / rowH);
89
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
90
+ return { col: Math.round((x - offsetX) / hexW), row };
91
+ }
92
+ const hexH = Math.sqrt(3) * cellSize;
93
+ const colW = 1.5 * cellSize;
94
+ const col = Math.round(x / colW);
95
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
96
+ return { col, row: Math.round((y - offsetY) / hexH) };
97
+ }
98
+ function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
99
+ const cells = [];
100
+ for (let dq = -n2; dq <= n2; dq++) {
101
+ const rMin = Math.max(-n2, -dq - n2);
102
+ const rMax = Math.min(n2, -dq + n2);
103
+ for (let dr = rMin; dr <= rMax; dr++) {
104
+ const absQ = centerQ + dq;
105
+ const absR = centerR + dr;
106
+ const off = cubeToOffset(absQ, absR, orientation);
107
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
108
+ }
109
+ }
110
+ return cells;
111
+ }
112
+ function getHexDistance(a, b, cellSize, orientation) {
113
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
114
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
115
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
116
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
117
+ const dq = cubeA.q - cubeB.q;
118
+ const dr = cubeA.r - cubeB.r;
119
+ const ds = -dq - dr;
120
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
121
+ }
122
+ function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
123
+ const n2 = Math.round(radiusCells);
124
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
125
+ const cube = offsetToCube(off.col, off.row, orientation);
126
+ if (n2 <= 0) {
127
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
128
+ }
129
+ return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
130
+ }
131
+ function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
132
+ const n2 = Math.round(radiusCells);
133
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
134
+ const cube = offsetToCube(off.col, off.row, orientation);
135
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
136
+ if (n2 <= 0) return [centerPixel];
137
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
138
+ const step = Math.PI / 3;
139
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
140
+ const halfAngle = Math.PI / 6 + 1e-6;
141
+ const cells = [centerPixel];
142
+ for (let dq = -n2; dq <= n2; dq++) {
143
+ const rMin = Math.max(-n2, -dq - n2);
144
+ const rMax = Math.min(n2, -dq + n2);
145
+ for (let dr = rMin; dr <= rMax; dr++) {
146
+ if (dq === 0 && dr === 0) continue;
147
+ const absQ = cube.q + dq;
148
+ const absR = cube.r + dr;
149
+ const pixel = offsetToPixel(
150
+ cubeToOffset(absQ, absR, orientation).col,
151
+ cubeToOffset(absQ, absR, orientation).row,
152
+ cellSize,
153
+ orientation
154
+ );
155
+ const dx = pixel.x - centerPixel.x;
156
+ const dy = pixel.y - centerPixel.y;
157
+ let diff = Math.atan2(dy, dx) - snappedAngle;
158
+ if (diff > Math.PI) diff -= 2 * Math.PI;
159
+ if (diff < -Math.PI) diff += 2 * Math.PI;
160
+ if (Math.abs(diff) <= halfAngle) {
161
+ cells.push(pixel);
162
+ }
163
+ }
164
+ }
165
+ return cells;
166
+ }
167
+ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
168
+ const n2 = Math.round(radiusCells);
169
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
170
+ const cube = offsetToCube(off.col, off.row, orientation);
171
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
172
+ if (n2 <= 0) return [centerPixel];
173
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
174
+ const step = Math.PI / 3;
175
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
176
+ const cos = Math.cos(snappedAngle);
177
+ const sin = Math.sin(snappedAngle);
178
+ const snapUnit = Math.sqrt(3) * cellSize;
179
+ const lineLength = n2 * snapUnit;
180
+ const halfWidth = snapUnit * 0.5 + 1e-6;
181
+ const cells = [];
182
+ for (let dq = -n2; dq <= n2; dq++) {
183
+ const rMin = Math.max(-n2, -dq - n2);
184
+ const rMax = Math.min(n2, -dq + n2);
185
+ for (let dr = rMin; dr <= rMax; dr++) {
186
+ const absQ = cube.q + dq;
187
+ const absR = cube.r + dr;
188
+ const pixel = offsetToPixel(
189
+ cubeToOffset(absQ, absR, orientation).col,
190
+ cubeToOffset(absQ, absR, orientation).row,
191
+ cellSize,
192
+ orientation
193
+ );
194
+ const dx = pixel.x - centerPixel.x;
195
+ const dy = pixel.y - centerPixel.y;
196
+ const along = dx * cos + dy * sin;
197
+ const perp = Math.abs(-dx * sin + dy * cos);
198
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
199
+ cells.push(pixel);
200
+ }
201
+ }
202
+ }
203
+ return cells;
204
+ }
205
+ function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
206
+ const nLen = Math.round(lengthCells);
207
+ const wCells = Math.max(1, Math.round(widthCells));
208
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
209
+ const cube = offsetToCube(off.col, off.row, orientation);
210
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
211
+ if (nLen <= 0) return [centerPixel];
212
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
213
+ const step = Math.PI / 3;
214
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
215
+ const cos = Math.cos(snappedAngle);
216
+ const sin = Math.sin(snappedAngle);
217
+ const snapUnit = Math.sqrt(3) * cellSize;
218
+ const lineLength = nLen * snapUnit;
219
+ const halfWidth = wCells * snapUnit / 2 + 1e-6;
220
+ const iterM = nLen + Math.ceil(wCells / 2) + 1;
221
+ const cells = [];
222
+ for (let dq = -iterM; dq <= iterM; dq++) {
223
+ const rMin = Math.max(-iterM, -dq - iterM);
224
+ const rMax = Math.min(iterM, -dq + iterM);
225
+ for (let dr = rMin; dr <= rMax; dr++) {
226
+ const absQ = cube.q + dq;
227
+ const absR = cube.r + dr;
228
+ const pixel = offsetToPixel(
229
+ cubeToOffset(absQ, absR, orientation).col,
230
+ cubeToOffset(absQ, absR, orientation).row,
231
+ cellSize,
232
+ orientation
233
+ );
234
+ const dx = pixel.x - centerPixel.x;
235
+ const dy = pixel.y - centerPixel.y;
236
+ const along = dx * cos + dy * sin;
237
+ const perp = Math.abs(-dx * sin + dy * cos);
238
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
239
+ cells.push(pixel);
240
+ }
241
+ }
242
+ }
243
+ return cells;
244
+ }
245
+ function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
246
+ const n2 = Math.round(radiusCells);
247
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
248
+ const cube = offsetToCube(off.col, off.row, orientation);
249
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
250
+ if (n2 <= 0) return [centerPixel];
251
+ const snapUnit = Math.sqrt(3) * cellSize;
252
+ const halfSide = n2 * snapUnit / 2;
253
+ const cells = [];
254
+ for (let dq = -n2; dq <= n2; dq++) {
255
+ const rMin = Math.max(-n2, -dq - n2);
256
+ const rMax = Math.min(n2, -dq + n2);
257
+ for (let dr = rMin; dr <= rMax; dr++) {
258
+ const absQ = cube.q + dq;
259
+ const absR = cube.r + dr;
260
+ const pixel = offsetToPixel(
261
+ cubeToOffset(absQ, absR, orientation).col,
262
+ cubeToOffset(absQ, absR, orientation).row,
263
+ cellSize,
264
+ orientation
265
+ );
266
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
267
+ cells.push(pixel);
268
+ }
269
+ }
270
+ }
271
+ return cells;
272
+ }
273
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
274
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
275
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
276
+ for (let i = 1; i < 6; i++) {
277
+ const a = angleOffset + Math.PI / 3 * i;
278
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
279
+ }
280
+ ctx.closePath();
281
+ }
282
+
283
+ // src/core/grid-metric.ts
284
+ function squareCost(rule, straight, diag) {
285
+ switch (rule) {
286
+ case "alternate":
287
+ return straight + diag + Math.floor(diag / 2);
288
+ case "manhattan":
289
+ return straight + 2 * diag;
290
+ case "chebyshev":
291
+ case "euclidean":
292
+ default:
293
+ return straight + diag;
294
+ }
295
+ }
296
+ function pathDistanceCells(points, grid) {
297
+ const cumulative = [];
298
+ const first = points[0];
299
+ if (first === void 0) return { total: 0, cumulative };
300
+ const gridSize = grid.gridSize > 0 ? grid.gridSize : 1;
301
+ const rule = grid.diagonalRule ?? "euclidean";
302
+ const orientation = grid.hexOrientation;
303
+ const hex = grid.gridType === "hex" && orientation !== void 0;
304
+ const squareRule = grid.gridType === "square" && rule !== "euclidean";
305
+ let straight = 0;
306
+ let diag = 0;
307
+ let euclid = 0;
308
+ let hexCells = 0;
309
+ cumulative.push(0);
310
+ let prev = first;
311
+ for (let i = 1; i < points.length; i++) {
312
+ const next = points[i];
313
+ if (next === void 0) break;
314
+ if (hex && orientation !== void 0) {
315
+ hexCells += getHexDistance(prev, next, gridSize, orientation);
316
+ cumulative.push(hexCells);
317
+ } else if (squareRule) {
318
+ const dx = Math.abs(Math.round((next.x - prev.x) / gridSize));
319
+ const dy = Math.abs(Math.round((next.y - prev.y) / gridSize));
320
+ const d = Math.min(dx, dy);
321
+ diag += d;
322
+ straight += Math.max(dx, dy) - d;
323
+ cumulative.push(squareCost(rule, straight, diag));
324
+ } else {
325
+ euclid += Math.hypot(next.x - prev.x, next.y - prev.y) / gridSize;
326
+ cumulative.push(euclid);
327
+ }
328
+ prev = next;
329
+ }
330
+ const total = cumulative[cumulative.length - 1] ?? 0;
331
+ return { total, cumulative };
332
+ }
333
+ function gridDistanceCells(a, b, grid) {
334
+ return pathDistanceCells([a, b], grid).total;
335
+ }
32
336
 
33
337
  // src/elements/note-sanitizer.ts
34
338
  var BOLD_TAGS = /* @__PURE__ */ new Set(["b", "strong"]);
@@ -1555,6 +1859,12 @@ var KeyboardHandler = class {
1555
1859
  if (e.key === " ") {
1556
1860
  this.deps.setSpaceHeld(true);
1557
1861
  }
1862
+ const tool = this.deps.getActiveTool();
1863
+ const ctx = this.deps.getToolContext();
1864
+ if (tool?.onKeyDown && ctx && tool.onKeyDown(e, ctx)) {
1865
+ e.preventDefault();
1866
+ return;
1867
+ }
1558
1868
  const action = this.shortcutMap.match(e);
1559
1869
  if (action !== null) {
1560
1870
  this.runAction(action, e);
@@ -1850,6 +2160,7 @@ var InputHandler = class {
1850
2160
  abortSignal: this.abortController.signal,
1851
2161
  getToolContext: () => this.toolContext,
1852
2162
  getIsToolActive: () => this.isToolActive,
2163
+ getActiveTool: () => this.toolManager?.activeTool ?? null,
1853
2164
  getLastPointerEvent: () => this.lastPointerEvent,
1854
2165
  setSpaceHeld: (v) => {
1855
2166
  this.spaceHeld = v;
@@ -1931,7 +2242,7 @@ var InputHandler = class {
1931
2242
  this.element.addEventListener("pointermove", this.onPointerMove, opts);
1932
2243
  this.element.addEventListener("pointerup", this.onPointerUp, opts);
1933
2244
  this.element.addEventListener("pointerleave", this.onPointerLeave, opts);
1934
- this.element.addEventListener("pointercancel", this.onPointerUp, opts);
2245
+ this.element.addEventListener("pointercancel", this.onPointerCancel, opts);
1935
2246
  this.element.addEventListener("contextmenu", this.onContextMenu, opts);
1936
2247
  window.addEventListener("blur", this.onCoastInterrupt, opts);
1937
2248
  window.addEventListener("visibilitychange", this.onCoastInterrupt, opts);
@@ -2010,6 +2321,12 @@ var InputHandler = class {
2010
2321
  }
2011
2322
  };
2012
2323
  onPointerUp = (e) => {
2324
+ this.finishPointer(e, false);
2325
+ };
2326
+ onPointerCancel = (e) => {
2327
+ this.finishPointer(e, true);
2328
+ };
2329
+ finishPointer(e, cancelled) {
2013
2330
  this.cancelLongPress();
2014
2331
  try {
2015
2332
  this.element.releasePointerCapture(e.pointerId);
@@ -2032,16 +2349,18 @@ var InputHandler = class {
2032
2349
  if (this.activePointers.size === 0) this.coastStoppedByPointer = false;
2033
2350
  const upResult = this.inputFilter.filterUp(e);
2034
2351
  if (this.isToolActive) {
2035
- this.dispatchToolUp(e);
2352
+ if (cancelled) this.dispatchToolCancel(e);
2353
+ else this.dispatchToolUp(e);
2036
2354
  this.isToolActive = false;
2037
2355
  } else if (this.deferredDown && upResult.pendingTap) {
2038
2356
  this.dispatchToolDown(this.deferredDown);
2039
- this.dispatchToolUp(e);
2357
+ if (cancelled) this.dispatchToolCancel(e);
2358
+ else this.dispatchToolUp(e);
2040
2359
  this.deferredDown = null;
2041
2360
  } else {
2042
2361
  this.deferredDown = null;
2043
2362
  }
2044
- };
2363
+ }
2045
2364
  runAction(action, e) {
2046
2365
  this.keyboard.runAction(action, e);
2047
2366
  }
@@ -2148,6 +2467,11 @@ var InputHandler = class {
2148
2467
  this.toolManager.handlePointerUp(this.toPointerState(e), this.toolContext);
2149
2468
  this.historyRecorder?.commit();
2150
2469
  }
2470
+ dispatchToolCancel(e) {
2471
+ if (!this.toolManager || !this.toolContext) return;
2472
+ this.toolManager.handlePointerCancel(this.toPointerState(e), this.toolContext);
2473
+ this.historyRecorder?.commit();
2474
+ }
2151
2475
  isInScope() {
2152
2476
  if (this.scope === "window") return true;
2153
2477
  const active = document.activeElement;
@@ -2160,7 +2484,7 @@ var InputHandler = class {
2160
2484
  cancelToolIfActive(e) {
2161
2485
  this.cancelLongPress();
2162
2486
  if (this.isToolActive) {
2163
- this.dispatchToolUp(e);
2487
+ this.dispatchToolCancel(e);
2164
2488
  this.isToolActive = false;
2165
2489
  }
2166
2490
  this.deferredDown = null;
@@ -3258,230 +3582,6 @@ function getImage(src, imageCache, onImageLoad, onImageError) {
3258
3582
  return null;
3259
3583
  }
3260
3584
 
3261
- // src/elements/hex-fill.ts
3262
- function offsetToCube(col, row, orientation) {
3263
- if (orientation === "pointy") {
3264
- return { q: col - (row - (row & 1)) / 2, r: row };
3265
- }
3266
- return { q: col, r: row - (col - (col & 1)) / 2 };
3267
- }
3268
- function cubeToOffset(q, r, orientation) {
3269
- if (orientation === "pointy") {
3270
- return { col: q + (r - (r & 1)) / 2, row: r };
3271
- }
3272
- return { col: q, row: r + (q - (q & 1)) / 2 };
3273
- }
3274
- function offsetToPixel(col, row, cellSize, orientation) {
3275
- if (orientation === "pointy") {
3276
- const hexW = Math.sqrt(3) * cellSize;
3277
- const rowH = 1.5 * cellSize;
3278
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
3279
- return { x: col * hexW + offsetX, y: row * rowH };
3280
- }
3281
- const hexH = Math.sqrt(3) * cellSize;
3282
- const colW = 1.5 * cellSize;
3283
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
3284
- return { x: col * colW, y: row * hexH + offsetY };
3285
- }
3286
- function pixelToOffset(x, y, cellSize, orientation) {
3287
- if (orientation === "pointy") {
3288
- const hexW = Math.sqrt(3) * cellSize;
3289
- const rowH = 1.5 * cellSize;
3290
- const row = Math.round(y / rowH);
3291
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
3292
- return { col: Math.round((x - offsetX) / hexW), row };
3293
- }
3294
- const hexH = Math.sqrt(3) * cellSize;
3295
- const colW = 1.5 * cellSize;
3296
- const col = Math.round(x / colW);
3297
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
3298
- return { col, row: Math.round((y - offsetY) / hexH) };
3299
- }
3300
- function enumerateHexRing(centerQ, centerR, n2, orientation, cellSize) {
3301
- const cells = [];
3302
- for (let dq = -n2; dq <= n2; dq++) {
3303
- const rMin = Math.max(-n2, -dq - n2);
3304
- const rMax = Math.min(n2, -dq + n2);
3305
- for (let dr = rMin; dr <= rMax; dr++) {
3306
- const absQ = centerQ + dq;
3307
- const absR = centerR + dr;
3308
- const off = cubeToOffset(absQ, absR, orientation);
3309
- cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
3310
- }
3311
- }
3312
- return cells;
3313
- }
3314
- function getHexDistance(a, b, cellSize, orientation) {
3315
- const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
3316
- const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
3317
- const cubeA = offsetToCube(offA.col, offA.row, orientation);
3318
- const cubeB = offsetToCube(offB.col, offB.row, orientation);
3319
- const dq = cubeA.q - cubeB.q;
3320
- const dr = cubeA.r - cubeB.r;
3321
- const ds = -dq - dr;
3322
- return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
3323
- }
3324
- function getHexCellsInRadius(center2, radiusCells, cellSize, orientation) {
3325
- const n2 = Math.round(radiusCells);
3326
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3327
- const cube = offsetToCube(off.col, off.row, orientation);
3328
- if (n2 <= 0) {
3329
- return [offsetToPixel(off.col, off.row, cellSize, orientation)];
3330
- }
3331
- return enumerateHexRing(cube.q, cube.r, n2, orientation, cellSize);
3332
- }
3333
- function getHexCellsInCone(center2, angle, radiusCells, cellSize, orientation) {
3334
- const n2 = Math.round(radiusCells);
3335
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3336
- const cube = offsetToCube(off.col, off.row, orientation);
3337
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3338
- if (n2 <= 0) return [centerPixel];
3339
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3340
- const step = Math.PI / 3;
3341
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3342
- const halfAngle = Math.PI / 6 + 1e-6;
3343
- const cells = [centerPixel];
3344
- for (let dq = -n2; dq <= n2; dq++) {
3345
- const rMin = Math.max(-n2, -dq - n2);
3346
- const rMax = Math.min(n2, -dq + n2);
3347
- for (let dr = rMin; dr <= rMax; dr++) {
3348
- if (dq === 0 && dr === 0) continue;
3349
- const absQ = cube.q + dq;
3350
- const absR = cube.r + dr;
3351
- const pixel = offsetToPixel(
3352
- cubeToOffset(absQ, absR, orientation).col,
3353
- cubeToOffset(absQ, absR, orientation).row,
3354
- cellSize,
3355
- orientation
3356
- );
3357
- const dx = pixel.x - centerPixel.x;
3358
- const dy = pixel.y - centerPixel.y;
3359
- let diff = Math.atan2(dy, dx) - snappedAngle;
3360
- if (diff > Math.PI) diff -= 2 * Math.PI;
3361
- if (diff < -Math.PI) diff += 2 * Math.PI;
3362
- if (Math.abs(diff) <= halfAngle) {
3363
- cells.push(pixel);
3364
- }
3365
- }
3366
- }
3367
- return cells;
3368
- }
3369
- function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
3370
- const n2 = Math.round(radiusCells);
3371
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3372
- const cube = offsetToCube(off.col, off.row, orientation);
3373
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3374
- if (n2 <= 0) return [centerPixel];
3375
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3376
- const step = Math.PI / 3;
3377
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3378
- const cos = Math.cos(snappedAngle);
3379
- const sin = Math.sin(snappedAngle);
3380
- const snapUnit = Math.sqrt(3) * cellSize;
3381
- const lineLength = n2 * snapUnit;
3382
- const halfWidth = snapUnit * 0.5 + 1e-6;
3383
- const cells = [];
3384
- for (let dq = -n2; dq <= n2; dq++) {
3385
- const rMin = Math.max(-n2, -dq - n2);
3386
- const rMax = Math.min(n2, -dq + n2);
3387
- for (let dr = rMin; dr <= rMax; dr++) {
3388
- const absQ = cube.q + dq;
3389
- const absR = cube.r + dr;
3390
- const pixel = offsetToPixel(
3391
- cubeToOffset(absQ, absR, orientation).col,
3392
- cubeToOffset(absQ, absR, orientation).row,
3393
- cellSize,
3394
- orientation
3395
- );
3396
- const dx = pixel.x - centerPixel.x;
3397
- const dy = pixel.y - centerPixel.y;
3398
- const along = dx * cos + dy * sin;
3399
- const perp = Math.abs(-dx * sin + dy * cos);
3400
- if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
3401
- cells.push(pixel);
3402
- }
3403
- }
3404
- }
3405
- return cells;
3406
- }
3407
- function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
3408
- const nLen = Math.round(lengthCells);
3409
- const wCells = Math.max(1, Math.round(widthCells));
3410
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3411
- const cube = offsetToCube(off.col, off.row, orientation);
3412
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3413
- if (nLen <= 0) return [centerPixel];
3414
- const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3415
- const step = Math.PI / 3;
3416
- const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3417
- const cos = Math.cos(snappedAngle);
3418
- const sin = Math.sin(snappedAngle);
3419
- const snapUnit = Math.sqrt(3) * cellSize;
3420
- const lineLength = nLen * snapUnit;
3421
- const halfWidth = wCells * snapUnit / 2 + 1e-6;
3422
- const iterM = nLen + Math.ceil(wCells / 2) + 1;
3423
- const cells = [];
3424
- for (let dq = -iterM; dq <= iterM; dq++) {
3425
- const rMin = Math.max(-iterM, -dq - iterM);
3426
- const rMax = Math.min(iterM, -dq + iterM);
3427
- for (let dr = rMin; dr <= rMax; dr++) {
3428
- const absQ = cube.q + dq;
3429
- const absR = cube.r + dr;
3430
- const pixel = offsetToPixel(
3431
- cubeToOffset(absQ, absR, orientation).col,
3432
- cubeToOffset(absQ, absR, orientation).row,
3433
- cellSize,
3434
- orientation
3435
- );
3436
- const dx = pixel.x - centerPixel.x;
3437
- const dy = pixel.y - centerPixel.y;
3438
- const along = dx * cos + dy * sin;
3439
- const perp = Math.abs(-dx * sin + dy * cos);
3440
- if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
3441
- cells.push(pixel);
3442
- }
3443
- }
3444
- }
3445
- return cells;
3446
- }
3447
- function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
3448
- const n2 = Math.round(radiusCells);
3449
- const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3450
- const cube = offsetToCube(off.col, off.row, orientation);
3451
- const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3452
- if (n2 <= 0) return [centerPixel];
3453
- const snapUnit = Math.sqrt(3) * cellSize;
3454
- const halfSide = n2 * snapUnit / 2;
3455
- const cells = [];
3456
- for (let dq = -n2; dq <= n2; dq++) {
3457
- const rMin = Math.max(-n2, -dq - n2);
3458
- const rMax = Math.min(n2, -dq + n2);
3459
- for (let dr = rMin; dr <= rMax; dr++) {
3460
- const absQ = cube.q + dq;
3461
- const absR = cube.r + dr;
3462
- const pixel = offsetToPixel(
3463
- cubeToOffset(absQ, absR, orientation).col,
3464
- cubeToOffset(absQ, absR, orientation).row,
3465
- cellSize,
3466
- orientation
3467
- );
3468
- if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
3469
- cells.push(pixel);
3470
- }
3471
- }
3472
- }
3473
- return cells;
3474
- }
3475
- function drawHexPath(ctx, cx, cy, cellSize, orientation) {
3476
- const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3477
- ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
3478
- for (let i = 1; i < 6; i++) {
3479
- const a = angleOffset + Math.PI / 3 * i;
3480
- ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
3481
- }
3482
- ctx.closePath();
3483
- }
3484
-
3485
3585
  // src/elements/renderers/template-measure.ts
3486
3586
  function renderTemplateFeetLabel(ctx, p) {
3487
3587
  if (p.feet <= 0) return;
@@ -5456,6 +5556,13 @@ var ToolManager = class {
5456
5556
  handlePointerUp(state, ctx) {
5457
5557
  this.current?.onPointerUp(state, ctx);
5458
5558
  }
5559
+ /** Cancels the active gesture; falls back to `onPointerUp` for tools without `onPointerCancel`. */
5560
+ handlePointerCancel(state, ctx) {
5561
+ const tool = this.current;
5562
+ if (!tool) return;
5563
+ if (tool.onPointerCancel) tool.onPointerCancel(state, ctx);
5564
+ else tool.onPointerUp(state, ctx);
5565
+ }
5459
5566
  onChange(listener) {
5460
5567
  this.changeListeners.add(listener);
5461
5568
  return () => this.changeListeners.delete(listener);
@@ -11065,145 +11172,119 @@ function drawMeasurement(ctx, m, opts = {}) {
11065
11172
  ctx.restore();
11066
11173
  }
11067
11174
 
11068
- // src/canvas/remote-measure-overlay.ts
11069
- var MEASURE_PRESENCE_KIND = "measure";
11070
- function isFinitePoint2(value) {
11071
- if (typeof value !== "object" || value === null) return false;
11072
- const point = value;
11073
- return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11074
- }
11075
- function isMeasurePresence(data) {
11076
- if (typeof data !== "object" || data === null) return false;
11077
- const payload = data;
11078
- if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
11079
- if ("cleared" in payload) return payload.cleared === true;
11080
- if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
11081
- if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
11082
- if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11083
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11084
- return true;
11085
- }
11086
- function toMeasurePresence(emission) {
11087
- if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
11088
- return {
11089
- kind: MEASURE_PRESENCE_KIND,
11090
- start: emission.start,
11091
- end: emission.end,
11092
- cells: emission.cells,
11093
- feet: emission.feet,
11094
- color: emission.color
11095
- };
11096
- }
11097
- var DEFAULT_COLOR3 = "#FF5722";
11175
+ // src/canvas/linger-overlay.ts
11098
11176
  var DEFAULT_HOLD_MS = 1500;
11099
11177
  var DEFAULT_FADE_MS2 = 400;
11100
11178
  var DEFAULT_MAX_AGE_MS = 3e4;
11101
- var RemoteMeasureOverlay = class {
11179
+ var LingerOverlay = class {
11102
11180
  host;
11103
- color;
11181
+ draw;
11182
+ clock;
11104
11183
  holdMs;
11105
11184
  fadeMs;
11106
11185
  maxAgeMs;
11107
- measurements = /* @__PURE__ */ new Map();
11186
+ entries = /* @__PURE__ */ new Map();
11108
11187
  unregister;
11109
11188
  rafId = null;
11110
- disposed = false;
11111
- constructor(host, options = {}) {
11189
+ isDisposed = false;
11190
+ /**
11191
+ * `clock` exists only as a test seam; production callers leave it at
11192
+ * `performance.now`. Owners that expose their own `now()` seam pass it
11193
+ * through so a spy on the owner still drives this overlay's timing.
11194
+ */
11195
+ constructor(host, options, draw, clock = () => performance.now()) {
11112
11196
  this.host = host;
11113
- this.color = options.color ?? DEFAULT_COLOR3;
11197
+ this.draw = draw;
11198
+ this.clock = clock;
11114
11199
  this.holdMs = options.holdMs ?? DEFAULT_HOLD_MS;
11115
11200
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS2;
11116
11201
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
11117
- this.unregister = host.registerOverlay((ctx) => this.renderMeasurements(ctx));
11202
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
11118
11203
  }
11119
11204
  now() {
11120
- return performance.now();
11205
+ return this.clock();
11121
11206
  }
11122
11207
  /**
11123
- * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11124
- * the envelope `from`). Non-measure or malformed payloads are ignored and
11125
- * reported as `false`, so hosts can feed every presence frame through.
11208
+ * Stores `sender`'s current entry as active, replacing any previous one and
11209
+ * cancelling an in-flight linger, and restarts the `maxAgeMs` expiry timer.
11126
11210
  */
11127
- apply(sender, data) {
11128
- if (this.disposed || !isMeasurePresence(data)) return false;
11129
- if ("cleared" in data) {
11130
- this.beginLinger(sender);
11131
- return true;
11132
- }
11133
- const existing = this.measurements.get(sender);
11211
+ set(sender, entry) {
11212
+ if (this.isDisposed) return;
11213
+ const existing = this.entries.get(sender);
11134
11214
  if (existing?.expiryTimer != null) clearTimeout(existing.expiryTimer);
11135
- this.measurements.set(sender, {
11136
- start: data.start,
11137
- end: data.end,
11138
- feet: data.feet,
11139
- color: data.color ?? this.color,
11215
+ this.entries.set(sender, {
11216
+ entry,
11140
11217
  clearedAt: null,
11141
- expiryTimer: setTimeout(() => this.beginLinger(sender), this.maxAgeMs)
11218
+ expiryTimer: setTimeout(() => this.linger(sender), this.maxAgeMs)
11142
11219
  });
11143
11220
  this.host.requestRender();
11144
- return true;
11145
11221
  }
11146
- /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11222
+ /** Starts the hold-then-fade lifetime for `sender`'s entry. */
11223
+ linger(sender) {
11224
+ const entry = this.entries.get(sender);
11225
+ if (!entry || entry.clearedAt !== null) return;
11226
+ if (entry.expiryTimer != null) {
11227
+ clearTimeout(entry.expiryTimer);
11228
+ entry.expiryTimer = null;
11229
+ }
11230
+ entry.clearedAt = this.now();
11231
+ this.ensureAnimating();
11232
+ this.host.requestRender();
11233
+ }
11234
+ /** Removes a sender's entry immediately (presence-leave/disconnect). */
11147
11235
  remove(sender) {
11148
- const entry = this.measurements.get(sender);
11236
+ const entry = this.entries.get(sender);
11149
11237
  if (!entry) return;
11150
11238
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11151
- this.measurements.delete(sender);
11239
+ this.entries.delete(sender);
11152
11240
  this.host.requestRender();
11153
11241
  }
11154
- /** Removes every ruler immediately. */
11242
+ /** Removes every entry immediately. */
11155
11243
  clear() {
11156
- if (this.measurements.size === 0) return;
11157
- for (const entry of this.measurements.values()) {
11244
+ if (this.entries.size === 0) return;
11245
+ for (const entry of this.entries.values()) {
11158
11246
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11159
11247
  }
11160
- this.measurements.clear();
11248
+ this.entries.clear();
11161
11249
  this.host.requestRender();
11162
11250
  }
11163
- /** Number of senders with a visible (active or lingering) ruler. */
11251
+ /** Number of senders with a visible (active or lingering) entry. */
11164
11252
  get activeSenderCount() {
11165
- return this.measurements.size;
11253
+ return this.entries.size;
11254
+ }
11255
+ /** True once `dispose` has run; the overlay accepts no further entries. */
11256
+ get disposed() {
11257
+ return this.isDisposed;
11166
11258
  }
11167
11259
  /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11168
11260
  dispose() {
11169
- if (this.disposed) return;
11170
- this.disposed = true;
11261
+ if (this.isDisposed) return;
11262
+ this.isDisposed = true;
11171
11263
  if (this.rafId !== null) {
11172
11264
  cancelAnimationFrame(this.rafId);
11173
11265
  this.rafId = null;
11174
11266
  }
11175
- for (const entry of this.measurements.values()) {
11267
+ for (const entry of this.entries.values()) {
11176
11268
  if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
11177
11269
  }
11178
- this.measurements.clear();
11270
+ this.entries.clear();
11179
11271
  this.unregister?.();
11180
11272
  this.unregister = null;
11181
11273
  this.host.requestRender();
11182
11274
  }
11183
- beginLinger(sender) {
11184
- const entry = this.measurements.get(sender);
11185
- if (!entry || entry.clearedAt !== null) return;
11186
- if (entry.expiryTimer != null) {
11187
- clearTimeout(entry.expiryTimer);
11188
- entry.expiryTimer = null;
11189
- }
11190
- entry.clearedAt = this.now();
11191
- this.ensureAnimating();
11192
- this.host.requestRender();
11193
- }
11194
11275
  ensureAnimating() {
11195
11276
  if (this.rafId === null) {
11196
11277
  this.rafId = requestAnimationFrame(() => this.tick());
11197
11278
  }
11198
11279
  }
11199
11280
  tick() {
11200
- if (this.disposed) return;
11281
+ if (this.isDisposed) return;
11201
11282
  const now = this.now();
11202
11283
  let lingering = 0;
11203
- for (const [sender, entry] of this.measurements) {
11284
+ for (const [sender, entry] of this.entries) {
11204
11285
  if (entry.clearedAt === null) continue;
11205
11286
  if (now - entry.clearedAt >= this.holdMs + this.fadeMs) {
11206
- this.measurements.delete(sender);
11287
+ this.entries.delete(sender);
11207
11288
  } else {
11208
11289
  lingering += 1;
11209
11290
  }
@@ -11211,24 +11292,278 @@ var RemoteMeasureOverlay = class {
11211
11292
  this.host.requestRender();
11212
11293
  this.rafId = lingering > 0 ? requestAnimationFrame(() => this.tick()) : null;
11213
11294
  }
11214
- renderMeasurements(ctx) {
11215
- if (this.measurements.size === 0) return;
11295
+ render(ctx) {
11296
+ if (this.entries.size === 0) return;
11216
11297
  const now = this.now();
11217
- for (const entry of this.measurements.values()) {
11298
+ for (const entry of this.entries.values()) {
11218
11299
  let alpha = 1;
11219
11300
  if (entry.clearedAt !== null) {
11220
11301
  const fadeAge = now - entry.clearedAt - this.holdMs;
11221
11302
  if (fadeAge > 0) alpha = Math.max(0, 1 - fadeAge / this.fadeMs);
11222
11303
  }
11223
- drawMeasurement(ctx, entry, { alpha });
11304
+ this.draw(ctx, entry.entry, alpha);
11305
+ }
11306
+ }
11307
+ };
11308
+
11309
+ // src/canvas/remote-measure-overlay.ts
11310
+ var MEASURE_PRESENCE_KIND = "measure";
11311
+ function isFinitePoint2(value) {
11312
+ if (typeof value !== "object" || value === null) return false;
11313
+ const point = value;
11314
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11315
+ }
11316
+ function isMeasurePresence(data) {
11317
+ if (typeof data !== "object" || data === null) return false;
11318
+ const payload = data;
11319
+ if (payload.kind !== MEASURE_PRESENCE_KIND) return false;
11320
+ if ("cleared" in payload) return payload.cleared === true;
11321
+ if (!isFinitePoint2(payload.start) || !isFinitePoint2(payload.end)) return false;
11322
+ if (typeof payload.cells !== "number" || !Number.isFinite(payload.cells)) return false;
11323
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11324
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11325
+ return true;
11326
+ }
11327
+ function toMeasurePresence(emission) {
11328
+ if (emission === null) return { kind: MEASURE_PRESENCE_KIND, cleared: true };
11329
+ return {
11330
+ kind: MEASURE_PRESENCE_KIND,
11331
+ start: emission.start,
11332
+ end: emission.end,
11333
+ cells: emission.cells,
11334
+ feet: emission.feet,
11335
+ color: emission.color
11336
+ };
11337
+ }
11338
+ var DEFAULT_COLOR3 = "#FF5722";
11339
+ var RemoteMeasureOverlay = class {
11340
+ color;
11341
+ overlay;
11342
+ constructor(host, options = {}) {
11343
+ this.color = options.color ?? DEFAULT_COLOR3;
11344
+ this.overlay = new LingerOverlay(
11345
+ host,
11346
+ options,
11347
+ (ctx, entry, alpha) => drawMeasurement(ctx, entry, { alpha }),
11348
+ () => this.now()
11349
+ );
11350
+ }
11351
+ now() {
11352
+ return performance.now();
11353
+ }
11354
+ /**
11355
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11356
+ * the envelope `from`). Non-measure or malformed payloads are ignored and
11357
+ * reported as `false`, so hosts can feed every presence frame through.
11358
+ */
11359
+ apply(sender, data) {
11360
+ if (this.overlay.disposed || !isMeasurePresence(data)) return false;
11361
+ if ("cleared" in data) {
11362
+ this.overlay.linger(sender);
11363
+ return true;
11224
11364
  }
11365
+ this.overlay.set(sender, {
11366
+ start: data.start,
11367
+ end: data.end,
11368
+ feet: data.feet,
11369
+ color: data.color ?? this.color
11370
+ });
11371
+ return true;
11372
+ }
11373
+ /** Removes a sender's ruler immediately (presence-leave/disconnect). */
11374
+ remove(sender) {
11375
+ this.overlay.remove(sender);
11376
+ }
11377
+ /** Removes every ruler immediately. */
11378
+ clear() {
11379
+ this.overlay.clear();
11380
+ }
11381
+ /** Number of senders with a visible (active or lingering) ruler. */
11382
+ get activeSenderCount() {
11383
+ return this.overlay.activeSenderCount;
11384
+ }
11385
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11386
+ dispose() {
11387
+ this.overlay.dispose();
11388
+ }
11389
+ };
11390
+
11391
+ // src/canvas/path-render.ts
11392
+ function resolveSegmentColors(cumulativeFeet, bands, color) {
11393
+ const sorted = [...bands].sort((a, b) => a.feet - b.feet);
11394
+ const out = [];
11395
+ for (let i = 1; i < cumulativeFeet.length; i++) {
11396
+ const end = cumulativeFeet[i] ?? 0;
11397
+ const band = sorted.find((b) => b.feet >= end);
11398
+ out.push(band ? band.color : color);
11399
+ }
11400
+ return out;
11401
+ }
11402
+ var LABEL_OFFSET_Y = 18;
11403
+ function drawPath(ctx, m, opts = {}) {
11404
+ const first = m.points[0];
11405
+ if (first === void 0) return;
11406
+ ctx.save();
11407
+ if (opts.alpha !== void 0) ctx.globalAlpha = opts.alpha;
11408
+ ctx.lineWidth = 2;
11409
+ let prev = first;
11410
+ for (let i = 1; i < m.points.length; i++) {
11411
+ const next = m.points[i];
11412
+ if (next === void 0) break;
11413
+ ctx.strokeStyle = m.segmentColors[i - 1] ?? m.color;
11414
+ ctx.setLineDash([8, 4]);
11415
+ ctx.beginPath();
11416
+ ctx.moveTo(prev.x, prev.y);
11417
+ ctx.lineTo(next.x, next.y);
11418
+ ctx.stroke();
11419
+ prev = next;
11420
+ }
11421
+ ctx.setLineDash([]);
11422
+ ctx.fillStyle = m.color;
11423
+ for (const p of m.points) {
11424
+ ctx.beginPath();
11425
+ ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
11426
+ ctx.fill();
11427
+ }
11428
+ if (m.points.length >= 2) {
11429
+ const last = prev;
11430
+ const label = formatMeasureLabel(m.feet);
11431
+ ctx.font = "14px sans-serif";
11432
+ const metrics = ctx.measureText(label);
11433
+ const padX = 6;
11434
+ const padY = 4;
11435
+ const textH = 14;
11436
+ const cx = last.x;
11437
+ const cy = last.y - LABEL_OFFSET_Y;
11438
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
11439
+ ctx.beginPath();
11440
+ ctx.roundRect(
11441
+ cx - metrics.width / 2 - padX,
11442
+ cy - textH / 2 - padY,
11443
+ metrics.width + padX * 2,
11444
+ textH + padY * 2,
11445
+ 4
11446
+ );
11447
+ ctx.fill();
11448
+ ctx.fillStyle = "#FFFFFF";
11449
+ ctx.textAlign = "center";
11450
+ ctx.textBaseline = "middle";
11451
+ ctx.fillText(label, cx, cy);
11452
+ }
11453
+ ctx.restore();
11454
+ }
11455
+
11456
+ // src/canvas/remote-path-overlay.ts
11457
+ var PATH_PRESENCE_KIND = "path";
11458
+ var PATH_PRESENCE_MAX_POINTS = 256;
11459
+ var MAX_COLOR_LENGTH = 64;
11460
+ var EPS = 1e-6;
11461
+ function isFinitePoint3(value) {
11462
+ if (typeof value !== "object" || value === null) return false;
11463
+ const point = value;
11464
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
11465
+ }
11466
+ function isPathPresence(data) {
11467
+ if (typeof data !== "object" || data === null) return false;
11468
+ const payload = data;
11469
+ if (payload.kind !== PATH_PRESENCE_KIND) return false;
11470
+ if ("cleared" in payload) return payload.cleared === true;
11471
+ if (!Array.isArray(payload.points)) return false;
11472
+ if (payload.points.length === 0 || payload.points.length > PATH_PRESENCE_MAX_POINTS) return false;
11473
+ for (const point of payload.points) {
11474
+ if (!isFinitePoint3(point)) return false;
11475
+ }
11476
+ if (!Array.isArray(payload.segmentColors)) return false;
11477
+ if (payload.segmentColors.length !== payload.points.length - 1) return false;
11478
+ for (const color of payload.segmentColors) {
11479
+ if (typeof color !== "string" || color.length > MAX_COLOR_LENGTH) return false;
11480
+ }
11481
+ if (typeof payload.feet !== "number" || !Number.isFinite(payload.feet)) return false;
11482
+ if (payload.color !== void 0) {
11483
+ if (typeof payload.color !== "string" || payload.color.length > MAX_COLOR_LENGTH) return false;
11484
+ }
11485
+ return true;
11486
+ }
11487
+ function samePoint(a, b) {
11488
+ return Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS;
11489
+ }
11490
+ function toPathPresence(emission) {
11491
+ if (emission === null) return { kind: PATH_PRESENCE_KIND, cleared: true };
11492
+ const points = emission.waypoints.map((point) => ({ x: point.x, y: point.y }));
11493
+ const last = points[points.length - 1];
11494
+ const cursor = emission.cursor;
11495
+ if (cursor && (!last || !samePoint(cursor, last))) points.push({ x: cursor.x, y: cursor.y });
11496
+ const cumulativeFeet = [0];
11497
+ let total = 0;
11498
+ for (let i = 1; i < points.length; i++) {
11499
+ total += emission.segments[i - 1]?.feet ?? 0;
11500
+ cumulativeFeet.push(total);
11501
+ }
11502
+ return {
11503
+ kind: PATH_PRESENCE_KIND,
11504
+ points,
11505
+ segmentColors: resolveSegmentColors(cumulativeFeet, emission.rangeBands, emission.color),
11506
+ feet: emission.totalFeet,
11507
+ color: emission.color
11508
+ };
11509
+ }
11510
+ var DEFAULT_COLOR4 = "#FF5722";
11511
+ var RemotePathOverlay = class {
11512
+ color;
11513
+ overlay;
11514
+ constructor(host, options = {}) {
11515
+ this.color = options.color ?? DEFAULT_COLOR4;
11516
+ this.overlay = new LingerOverlay(
11517
+ host,
11518
+ options,
11519
+ (ctx, entry, alpha) => drawPath(ctx, entry, { alpha }),
11520
+ () => this.now()
11521
+ );
11522
+ }
11523
+ now() {
11524
+ return performance.now();
11525
+ }
11526
+ /**
11527
+ * Applies a presence payload from `sender` (any opaque per-sender key, e.g.
11528
+ * the envelope `from`). Non-path or malformed payloads are ignored and
11529
+ * reported as `false`, so hosts can feed every presence frame through.
11530
+ */
11531
+ apply(sender, data) {
11532
+ if (this.overlay.disposed || !isPathPresence(data)) return false;
11533
+ if ("cleared" in data) {
11534
+ this.overlay.linger(sender);
11535
+ return true;
11536
+ }
11537
+ this.overlay.set(sender, {
11538
+ points: data.points,
11539
+ segmentColors: data.segmentColors,
11540
+ feet: data.feet,
11541
+ color: data.color ?? this.color
11542
+ });
11543
+ return true;
11544
+ }
11545
+ /** Removes a sender's path immediately (presence-leave/disconnect). */
11546
+ remove(sender) {
11547
+ this.overlay.remove(sender);
11548
+ }
11549
+ /** Removes every path immediately. */
11550
+ clear() {
11551
+ this.overlay.clear();
11552
+ }
11553
+ /** Number of senders with a visible (active or lingering) path. */
11554
+ get activeSenderCount() {
11555
+ return this.overlay.activeSenderCount;
11556
+ }
11557
+ /** Unregisters the overlay, cancels timers, stops animating. Idempotent. */
11558
+ dispose() {
11559
+ this.overlay.dispose();
11225
11560
  }
11226
11561
  };
11227
11562
 
11228
11563
  // src/canvas/ping-input.ts
11229
11564
  var DEFAULT_LONG_PRESS_MS = 600;
11230
11565
  var DEFAULT_SLOP_PX = 8;
11231
- var DEFAULT_COLOR4 = "#ff3b30";
11566
+ var DEFAULT_COLOR5 = "#ff3b30";
11232
11567
  var DEFAULT_DURATION_MS2 = 1800;
11233
11568
  var DEFAULT_RADIUS2 = 48;
11234
11569
  var DEFAULT_MIN_INTERVAL_MS = 300;
@@ -11261,7 +11596,7 @@ var PingInput = class {
11261
11596
  this.longPressEnabled = options.longPressEnabled ?? false;
11262
11597
  this.longPressMs = options.longPressMs ?? DEFAULT_LONG_PRESS_MS;
11263
11598
  this.slopPx = options.slopPx ?? DEFAULT_SLOP_PX;
11264
- this.color = options.color ?? DEFAULT_COLOR4;
11599
+ this.color = options.color ?? DEFAULT_COLOR5;
11265
11600
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS2;
11266
11601
  this.radius = options.radius ?? DEFAULT_RADIUS2;
11267
11602
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
@@ -11666,105 +12001,899 @@ var CameraAnimator = class {
11666
12001
  this.rafId = null;
11667
12002
  }
11668
12003
  }
11669
- emit(reason) {
11670
- for (const listener of [...this.endListeners]) {
12004
+ emit(reason) {
12005
+ for (const listener of [...this.endListeners]) {
12006
+ try {
12007
+ listener(reason);
12008
+ } catch {
12009
+ }
12010
+ }
12011
+ }
12012
+ };
12013
+
12014
+ // src/canvas/focus-presence.ts
12015
+ var FOCUS_PRESENCE_KIND = "focus";
12016
+ var AUDIENCES = ["all", "players", "display"];
12017
+ function isPositiveFinite(value) {
12018
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
12019
+ }
12020
+ function isFiniteNumber2(value) {
12021
+ return typeof value === "number" && Number.isFinite(value);
12022
+ }
12023
+ function isFocusPresence(data) {
12024
+ if (typeof data !== "object" || data === null) return false;
12025
+ const payload = data;
12026
+ if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
12027
+ if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
12028
+ if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
12029
+ if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
12030
+ return false;
12031
+ }
12032
+ if (payload.color !== void 0 && typeof payload.color !== "string") return false;
12033
+ return true;
12034
+ }
12035
+ function toFocusPresence(view, audience, color) {
12036
+ return {
12037
+ kind: FOCUS_PRESENCE_KIND,
12038
+ x: view.x,
12039
+ y: view.y,
12040
+ w: view.w,
12041
+ h: view.h,
12042
+ audience,
12043
+ ...color === void 0 ? {} : { color }
12044
+ };
12045
+ }
12046
+
12047
+ // src/canvas/remote-focus-receiver.ts
12048
+ function audienceIncludes(audience, role) {
12049
+ if (role === "dm") return false;
12050
+ if (audience === "all") return true;
12051
+ if (audience === "players") return role === "player";
12052
+ return role === "display";
12053
+ }
12054
+ var RemoteFocusReceiver = class {
12055
+ role;
12056
+ animator;
12057
+ animate;
12058
+ pulseColor;
12059
+ overlay;
12060
+ disposed = false;
12061
+ constructor(host, options) {
12062
+ this.role = options.role;
12063
+ this.animator = options.animator;
12064
+ this.animate = options.animate ?? true;
12065
+ this.pulseColor = options.pulseColor;
12066
+ this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
12067
+ ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
12068
+ ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
12069
+ ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
12070
+ maxPingsPerSender: 1
12071
+ }) : null;
12072
+ }
12073
+ /**
12074
+ * Applies a presence payload from `sender`. Returns `false` for payloads
12075
+ * that are not focus frames, or are addressed to a different role, so hosts
12076
+ * can feed every presence frame through without disturbing other handlers.
12077
+ */
12078
+ apply(from, data) {
12079
+ if (this.disposed || !isFocusPresence(data)) return false;
12080
+ if (!audienceIncludes(data.audience, this.role)) return false;
12081
+ const view = { x: data.x, y: data.y, w: data.w, h: data.h };
12082
+ if (this.animate) {
12083
+ this.animator.animateTo(view);
12084
+ } else {
12085
+ this.animator.jumpTo(view);
12086
+ }
12087
+ this.overlay?.apply(from, {
12088
+ kind: "ping",
12089
+ x: view.x + view.w / 2,
12090
+ y: view.y + view.h / 2,
12091
+ color: data.color ?? this.pulseColor
12092
+ });
12093
+ return true;
12094
+ }
12095
+ /** Idempotent. Does NOT dispose the animator — the host owns that. */
12096
+ dispose() {
12097
+ if (this.disposed) return;
12098
+ this.disposed = true;
12099
+ this.overlay?.dispose();
12100
+ }
12101
+ };
12102
+
12103
+ // src/canvas/awareness-presence.ts
12104
+ var AWARENESS_PRESENCE_KIND = "awareness";
12105
+ var AWARENESS_MAX_SELECTION = 256;
12106
+ var MAX_ID_LENGTH = 128;
12107
+ var MAX_NAME_LENGTH = 64;
12108
+ var MAX_COLOR_LENGTH2 = 64;
12109
+ var MAX_ROLE_LENGTH = 32;
12110
+ var MAX_TOOL_LENGTH = 64;
12111
+ function isBoundedString(value, max) {
12112
+ return typeof value === "string" && value.length <= max;
12113
+ }
12114
+ function isOptionalBoundedString(value, max) {
12115
+ return value === void 0 || isBoundedString(value, max);
12116
+ }
12117
+ function isFinitePoint4(value) {
12118
+ if (typeof value !== "object" || value === null) return false;
12119
+ const point = value;
12120
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
12121
+ }
12122
+ function isAwarenessPresence(data) {
12123
+ if (typeof data !== "object" || data === null) return false;
12124
+ const payload = data;
12125
+ if (payload.kind !== AWARENESS_PRESENCE_KIND) return false;
12126
+ if (!isBoundedString(payload.id, MAX_ID_LENGTH) || payload.id.length === 0) return false;
12127
+ if ("cleared" in payload) return payload.cleared === true;
12128
+ if (!isOptionalBoundedString(payload.name, MAX_NAME_LENGTH)) return false;
12129
+ if (!isOptionalBoundedString(payload.color, MAX_COLOR_LENGTH2)) return false;
12130
+ if (!isOptionalBoundedString(payload.role, MAX_ROLE_LENGTH)) return false;
12131
+ if (!isOptionalBoundedString(payload.tool, MAX_TOOL_LENGTH)) return false;
12132
+ if (payload.cursor !== void 0 && !isFinitePoint4(payload.cursor)) return false;
12133
+ if (payload.selection !== void 0) {
12134
+ if (!Array.isArray(payload.selection)) return false;
12135
+ if (payload.selection.length > AWARENESS_MAX_SELECTION) return false;
12136
+ for (const id of payload.selection) {
12137
+ if (!isBoundedString(id, MAX_ID_LENGTH) || id.length === 0) return false;
12138
+ }
12139
+ }
12140
+ return true;
12141
+ }
12142
+
12143
+ // src/canvas/awareness-roster.ts
12144
+ var DEFAULT_STALE_MS = 45e3;
12145
+ var EMPTY_PEERS = Object.freeze([]);
12146
+ var EMPTY_SELECTION = Object.freeze([]);
12147
+ function sameSelection(a, b) {
12148
+ if (a.length !== b.length) return false;
12149
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
12150
+ return true;
12151
+ }
12152
+ function samePoint2(a, b) {
12153
+ if (a === null || b === null) return a === b;
12154
+ return a.x === b.x && a.y === b.y;
12155
+ }
12156
+ function toPeer(from, data, prev) {
12157
+ const cursor = data.cursor ? { x: data.cursor.x, y: data.cursor.y } : null;
12158
+ const incoming = data.selection ?? EMPTY_SELECTION;
12159
+ const selection = prev && sameSelection(prev.selection, incoming) ? prev.selection : incoming.length === 0 ? EMPTY_SELECTION : Object.freeze([...incoming]);
12160
+ const tool = data.tool ?? null;
12161
+ 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)) {
12162
+ return prev;
12163
+ }
12164
+ const peer = {
12165
+ from,
12166
+ id: data.id,
12167
+ ...data.name === void 0 ? {} : { name: data.name },
12168
+ ...data.color === void 0 ? {} : { color: data.color },
12169
+ ...data.role === void 0 ? {} : { role: data.role },
12170
+ cursor,
12171
+ selection,
12172
+ tool
12173
+ };
12174
+ return peer;
12175
+ }
12176
+ var PeerRoster = class {
12177
+ staleMs;
12178
+ now;
12179
+ rows = /* @__PURE__ */ new Map();
12180
+ discovered = /* @__PURE__ */ new Map();
12181
+ changeListeners = /* @__PURE__ */ new Set();
12182
+ discoverListeners = /* @__PURE__ */ new Set();
12183
+ leaveListeners = /* @__PURE__ */ new Set();
12184
+ snapshot = EMPTY_PEERS;
12185
+ snapshotDirty = false;
12186
+ staleTimer = null;
12187
+ isDisposed = false;
12188
+ constructor(options = {}) {
12189
+ this.staleMs = options.staleMs ?? DEFAULT_STALE_MS;
12190
+ this.now = options.now ?? (() => Date.now());
12191
+ }
12192
+ get disposed() {
12193
+ return this.isDisposed;
12194
+ }
12195
+ /**
12196
+ * Applies a presence payload from `from`. Non-awareness or malformed payloads
12197
+ * return `false` untouched, so hosts can feed every presence frame through.
12198
+ */
12199
+ apply(from, data) {
12200
+ if (this.isDisposed || !isAwarenessPresence(data)) return false;
12201
+ const isNew = !this.discovered.has(from);
12202
+ this.discovered.set(from, this.now());
12203
+ if ("cleared" in data) {
12204
+ this.dropRow(from, "cleared");
12205
+ } else {
12206
+ const prev = this.rows.get(from);
12207
+ const next = toPeer(from, data, prev);
12208
+ if (next !== prev) {
12209
+ this.rows.set(from, next);
12210
+ this.changed();
12211
+ }
12212
+ }
12213
+ this.armStaleTimer();
12214
+ if (isNew) this.emit(this.discoverListeners, (l) => l(from));
12215
+ return true;
12216
+ }
12217
+ /** Server-authored presence-leave: drops the row AND the discovery entry. */
12218
+ remove(from) {
12219
+ if (this.isDisposed) return;
12220
+ const hadEntry = this.discovered.delete(from);
12221
+ this.dropRow(from, "left");
12222
+ if (hadEntry) this.armStaleTimer();
12223
+ }
12224
+ getPeers() {
12225
+ if (this.snapshotDirty) {
12226
+ this.snapshot = this.rows.size === 0 ? EMPTY_PEERS : Object.freeze([...this.rows.values()]);
12227
+ this.snapshotDirty = false;
12228
+ }
12229
+ return this.snapshot;
12230
+ }
12231
+ getPeer(from) {
12232
+ return this.rows.get(from);
12233
+ }
12234
+ /** Fires only when `getPeers()` would return a new reference. */
12235
+ onChange(listener) {
12236
+ this.changeListeners.add(listener);
12237
+ return () => this.changeListeners.delete(listener);
12238
+ }
12239
+ /** First valid frame from a sender since its discovery entry was last dropped. */
12240
+ onDiscover(listener) {
12241
+ this.discoverListeners.add(listener);
12242
+ return () => this.discoverListeners.delete(listener);
12243
+ }
12244
+ onLeave(listener) {
12245
+ this.leaveListeners.add(listener);
12246
+ return () => this.leaveListeners.delete(listener);
12247
+ }
12248
+ dispose() {
12249
+ if (this.isDisposed) return;
12250
+ this.isDisposed = true;
12251
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12252
+ this.staleTimer = null;
12253
+ this.rows.clear();
12254
+ this.discovered.clear();
12255
+ this.snapshot = EMPTY_PEERS;
12256
+ this.snapshotDirty = false;
12257
+ this.changeListeners.clear();
12258
+ this.discoverListeners.clear();
12259
+ this.leaveListeners.clear();
12260
+ }
12261
+ dropRow(from, reason) {
12262
+ const row = this.rows.get(from);
12263
+ if (!row) return;
12264
+ this.rows.delete(from);
12265
+ this.changed();
12266
+ this.emit(this.leaveListeners, (l) => l(row, reason));
12267
+ }
12268
+ changed() {
12269
+ this.snapshotDirty = true;
12270
+ this.emit(this.changeListeners, (l) => l());
12271
+ }
12272
+ emit(listeners, call) {
12273
+ for (const listener of [...listeners]) {
12274
+ try {
12275
+ call(listener);
12276
+ } catch {
12277
+ }
12278
+ }
12279
+ }
12280
+ armStaleTimer() {
12281
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12282
+ this.staleTimer = null;
12283
+ if (!Number.isFinite(this.staleMs) || this.staleMs <= 0 || this.isDisposed || this.discovered.size === 0) {
12284
+ return;
12285
+ }
12286
+ let earliest = Infinity;
12287
+ for (const seen of this.discovered.values()) if (seen < earliest) earliest = seen;
12288
+ const delay = Math.min(Math.max(0, earliest + this.staleMs - this.now()), 2 ** 31 - 1);
12289
+ this.staleTimer = setTimeout(() => {
12290
+ this.staleTimer = null;
12291
+ this.expireStale();
12292
+ }, delay);
12293
+ }
12294
+ expireStale() {
12295
+ const t = this.now();
12296
+ for (const from of [...this.discovered.keys()]) {
12297
+ const seen = this.discovered.get(from);
12298
+ if (seen === void 0 || t - seen < this.staleMs) continue;
12299
+ this.discovered.delete(from);
12300
+ this.dropRow(from, "stale");
12301
+ }
12302
+ this.armStaleTimer();
12303
+ }
12304
+ };
12305
+
12306
+ // src/canvas/awareness-publisher.ts
12307
+ var DEFAULT_FIELDS = Object.freeze({
12308
+ cursor: true,
12309
+ selection: false,
12310
+ tool: true
12311
+ });
12312
+ var DEFAULT_INTERVAL_MS = 50;
12313
+ var DEFAULT_HEARTBEAT_MS = 15e3;
12314
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
12315
+ var MAX_IDENTITY_ID_LENGTH = 128;
12316
+ var MAX_IDENTITY_NAME_LENGTH = 64;
12317
+ var MAX_IDENTITY_COLOR_LENGTH = 64;
12318
+ var MAX_IDENTITY_ROLE_LENGTH = 32;
12319
+ var MAX_TOOL_LENGTH2 = 64;
12320
+ var MAX_SELECTION_ID_LENGTH = 128;
12321
+ function normalizeIntervalMs(value) {
12322
+ return Number.isFinite(value) && value >= 0 ? value : 0;
12323
+ }
12324
+ function normalizeHeartbeatMs(value) {
12325
+ return Number.isFinite(value) && value > 0 ? value : 0;
12326
+ }
12327
+ var LocalAwareness = class {
12328
+ host;
12329
+ element;
12330
+ send;
12331
+ selectionFilter;
12332
+ onError;
12333
+ intervalMs;
12334
+ heartbeatMs;
12335
+ identity;
12336
+ fields;
12337
+ lastPointer = null;
12338
+ selection = [];
12339
+ selectionFailed = false;
12340
+ tool;
12341
+ dirty = false;
12342
+ lastSentAt = null;
12343
+ throttleTimer = null;
12344
+ heartbeatTimer = null;
12345
+ unsubscribers = [];
12346
+ isDisposed = false;
12347
+ handlePointerMove = (e) => this.onPointerMove(e);
12348
+ handlePointerEnd = (e) => this.onPointerEnd(e);
12349
+ constructor(host, options) {
12350
+ const element = options.element ?? host.domLayer.parentElement;
12351
+ if (!element) throw new Error("LocalAwareness: the viewport wrapper is not mounted");
12352
+ this.host = host;
12353
+ this.element = element;
12354
+ this.send = options.send;
12355
+ this.selectionFilter = options.selectionFilter;
12356
+ this.onError = options.onError;
12357
+ this.intervalMs = normalizeIntervalMs(options.intervalMs ?? DEFAULT_INTERVAL_MS);
12358
+ this.heartbeatMs = normalizeHeartbeatMs(options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
12359
+ this.identity = this.normalizeIdentity(options.identity);
12360
+ this.fields = mergeFields(DEFAULT_FIELDS, options.fields ?? {});
12361
+ this.tool = host.toolManager.activeTool?.name ?? null;
12362
+ if (this.fields.selection) this.refreshSelection();
12363
+ const opts = { passive: true };
12364
+ element.addEventListener("pointermove", this.handlePointerMove, opts);
12365
+ element.addEventListener("pointerleave", this.handlePointerEnd, opts);
12366
+ element.addEventListener("pointercancel", this.handlePointerEnd, opts);
12367
+ this.unsubscribers.push(
12368
+ host.onSelectionChange(() => {
12369
+ if (this.fields.selection) this.schedule();
12370
+ }),
12371
+ host.toolManager.onChange((name) => {
12372
+ this.tool = name;
12373
+ if (this.fields.tool) this.schedule();
12374
+ })
12375
+ );
12376
+ this.armHeartbeat();
12377
+ }
12378
+ get disposed() {
12379
+ return this.isDisposed;
12380
+ }
12381
+ getFields() {
12382
+ return this.fields;
12383
+ }
12384
+ setIdentity(identity) {
12385
+ this.identity = this.normalizeIdentity(identity);
12386
+ this.schedule();
12387
+ }
12388
+ /** Merges the given flags into the current policy; `undefined` keys are ignored. */
12389
+ setFields(fields) {
12390
+ this.fields = mergeFields(this.fields, fields);
12391
+ this.schedule();
12392
+ }
12393
+ /**
12394
+ * Requests a full frame: immediate when idle, otherwise folded into the
12395
+ * pending trailing frame (so N simultaneous requests cost one frame). Hosts
12396
+ * call it when the connection becomes live or reconnects.
12397
+ */
12398
+ announce() {
12399
+ this.schedule();
12400
+ }
12401
+ /**
12402
+ * The complete state a frame carries right now. Side-effecting when
12403
+ * selection publishing is on: re-reads `getSelectedIds()`, re-runs
12404
+ * `selectionFilter`, updates the fail-closed selection state, and may call
12405
+ * `onError`. A no-op with respect to selection while publishing is off.
12406
+ */
12407
+ getState() {
12408
+ const frame = { kind: AWARENESS_PRESENCE_KIND, id: this.identity.id };
12409
+ if (this.identity.name !== void 0) frame.name = this.identity.name;
12410
+ if (this.identity.color !== void 0) frame.color = this.identity.color;
12411
+ if (this.identity.role !== void 0) frame.role = this.identity.role;
12412
+ if (this.fields.cursor && this.lastPointer !== null) {
12413
+ frame.cursor = { x: this.lastPointer.x, y: this.lastPointer.y };
12414
+ }
12415
+ if (this.fields.selection) {
12416
+ this.refreshSelection();
12417
+ if (!this.selectionFailed && this.selection.length > 0) {
12418
+ frame.selection = [...this.selection];
12419
+ }
12420
+ }
12421
+ if (this.fields.tool && this.tool !== null && this.tool.length <= MAX_TOOL_LENGTH2) {
12422
+ frame.tool = this.tool;
12423
+ }
12424
+ return frame;
12425
+ }
12426
+ dispose() {
12427
+ if (this.isDisposed) return;
12428
+ this.isDisposed = true;
12429
+ if (this.throttleTimer !== null) clearTimeout(this.throttleTimer);
12430
+ this.throttleTimer = null;
12431
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12432
+ this.heartbeatTimer = null;
12433
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
12434
+ this.element.removeEventListener("pointerleave", this.handlePointerEnd);
12435
+ this.element.removeEventListener("pointercancel", this.handlePointerEnd);
12436
+ for (const unsub of this.unsubscribers) unsub();
12437
+ this.unsubscribers.length = 0;
12438
+ this.safeSend({ kind: AWARENESS_PRESENCE_KIND, id: this.identity.id, cleared: true });
12439
+ }
12440
+ now() {
12441
+ return Date.now();
12442
+ }
12443
+ onPointerMove(e) {
12444
+ if (!e.isPrimary) return;
12445
+ const rect = this.element.getBoundingClientRect();
12446
+ const world = this.host.camera.screenToWorld({
12447
+ x: e.clientX - rect.left,
12448
+ y: e.clientY - rect.top
12449
+ });
12450
+ this.lastPointer = Number.isFinite(world.x) && Number.isFinite(world.y) ? { x: world.x, y: world.y } : null;
12451
+ if (this.fields.cursor) this.schedule();
12452
+ }
12453
+ onPointerEnd(e) {
12454
+ if (!e.isPrimary || this.lastPointer === null) return;
12455
+ this.lastPointer = null;
12456
+ if (this.fields.cursor) this.schedule();
12457
+ }
12458
+ refreshSelection() {
12459
+ try {
12460
+ const raw = this.host.getSelectedIds();
12461
+ const ids = this.selectionFilter ? this.selectionFilter(raw) : raw;
12462
+ if (!Array.isArray(ids)) throw new TypeError("selectionFilter must return an array");
12463
+ for (const id of ids) {
12464
+ if (typeof id !== "string") throw new TypeError("selectionFilter must return strings");
12465
+ if (id.length === 0 || id.length > MAX_SELECTION_ID_LENGTH) {
12466
+ throw new TypeError("selectionFilter must return ids of 1..128 characters");
12467
+ }
12468
+ }
12469
+ this.selection = ids.slice(0, AWARENESS_MAX_SELECTION);
12470
+ this.selectionFailed = false;
12471
+ } catch (error) {
12472
+ this.selection = [];
12473
+ this.selectionFailed = true;
12474
+ this.report(error);
12475
+ }
12476
+ }
12477
+ schedule() {
12478
+ if (this.isDisposed) return;
12479
+ this.dirty = true;
12480
+ if (this.throttleTimer !== null) return;
12481
+ const elapsed = this.lastSentAt === null ? Infinity : this.now() - this.lastSentAt;
12482
+ if (elapsed >= this.intervalMs) {
12483
+ this.flush();
12484
+ return;
12485
+ }
12486
+ this.throttleTimer = setTimeout(
12487
+ () => {
12488
+ this.throttleTimer = null;
12489
+ if (this.dirty) this.flush();
12490
+ },
12491
+ Math.min(this.intervalMs - elapsed, MAX_TIMER_DELAY_MS)
12492
+ );
12493
+ }
12494
+ flush() {
12495
+ this.dirty = false;
12496
+ this.lastSentAt = this.now();
12497
+ this.safeSend(this.getState());
12498
+ this.armHeartbeat();
12499
+ }
12500
+ armHeartbeat() {
12501
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12502
+ this.heartbeatTimer = null;
12503
+ if (this.heartbeatMs <= 0 || this.isDisposed) return;
12504
+ this.heartbeatTimer = setTimeout(
12505
+ () => {
12506
+ this.heartbeatTimer = null;
12507
+ this.flush();
12508
+ },
12509
+ Math.min(this.heartbeatMs, MAX_TIMER_DELAY_MS)
12510
+ );
12511
+ }
12512
+ safeSend(frame) {
12513
+ try {
12514
+ this.send(frame);
12515
+ } catch (error) {
12516
+ this.report(error);
12517
+ }
12518
+ }
12519
+ report(error) {
12520
+ try {
12521
+ this.onError?.(error);
12522
+ } catch {
12523
+ }
12524
+ }
12525
+ /**
12526
+ * Truncates identity strings to the wire caps and rejects an invalid id, so a
12527
+ * sender can never publish a frame that the wire guard would drop outright.
12528
+ * A truncated field is reported through `onError` (a `RangeError`) rather
12529
+ * than silently shortened, so a caller passing an over-long name finds out.
12530
+ */
12531
+ normalizeIdentity(identity) {
12532
+ if (identity.id.length === 0 || identity.id.length > MAX_IDENTITY_ID_LENGTH) {
12533
+ throw new RangeError("LocalAwareness: identity.id must be 1..128 characters");
12534
+ }
12535
+ const normalized = {
12536
+ id: identity.id
12537
+ };
12538
+ if (identity.name !== void 0) {
12539
+ normalized.name = identity.name.slice(0, MAX_IDENTITY_NAME_LENGTH);
12540
+ if (identity.name.length > MAX_IDENTITY_NAME_LENGTH) {
12541
+ this.report(
12542
+ new RangeError(
12543
+ `LocalAwareness: identity.name truncated to ${MAX_IDENTITY_NAME_LENGTH} characters`
12544
+ )
12545
+ );
12546
+ }
12547
+ }
12548
+ if (identity.color !== void 0) {
12549
+ normalized.color = identity.color.slice(0, MAX_IDENTITY_COLOR_LENGTH);
12550
+ if (identity.color.length > MAX_IDENTITY_COLOR_LENGTH) {
12551
+ this.report(
12552
+ new RangeError(
12553
+ `LocalAwareness: identity.color truncated to ${MAX_IDENTITY_COLOR_LENGTH} characters`
12554
+ )
12555
+ );
12556
+ }
12557
+ }
12558
+ if (identity.role !== void 0) {
12559
+ normalized.role = identity.role.slice(0, MAX_IDENTITY_ROLE_LENGTH);
12560
+ if (identity.role.length > MAX_IDENTITY_ROLE_LENGTH) {
12561
+ this.report(
12562
+ new RangeError(
12563
+ `LocalAwareness: identity.role truncated to ${MAX_IDENTITY_ROLE_LENGTH} characters`
12564
+ )
12565
+ );
12566
+ }
12567
+ }
12568
+ return normalized;
12569
+ }
12570
+ };
12571
+ function mergeFields(current, patch) {
12572
+ return Object.freeze({
12573
+ cursor: patch.cursor ?? current.cursor,
12574
+ selection: patch.selection ?? current.selection,
12575
+ tool: patch.tool ?? current.tool
12576
+ });
12577
+ }
12578
+
12579
+ // src/canvas/remote-cursor-overlay.ts
12580
+ var PEER_COLORS = Object.freeze([
12581
+ "#e11d48",
12582
+ "#ea580c",
12583
+ "#ca8a04",
12584
+ "#16a34a",
12585
+ "#0d9488",
12586
+ "#0284c7",
12587
+ "#2563eb",
12588
+ "#7c3aed",
12589
+ "#c026d3",
12590
+ "#db2777",
12591
+ "#4d7c0f",
12592
+ "#b45309"
12593
+ ]);
12594
+ function defaultPeerColor(seed) {
12595
+ if (seed.length === 0) return PEER_COLORS[0] ?? "#2563eb";
12596
+ let hash = 2166136261;
12597
+ for (let i = 0; i < seed.length; i++) {
12598
+ hash ^= seed.charCodeAt(i);
12599
+ hash = Math.imul(hash, 16777619) >>> 0;
12600
+ }
12601
+ return PEER_COLORS[hash % PEER_COLORS.length] ?? "#2563eb";
12602
+ }
12603
+ var DEFAULT_LABEL_FONT = "12px sans-serif";
12604
+ var LABEL_PAD_X = 6;
12605
+ var LABEL_PAD_Y = 3;
12606
+ var LABEL_HEIGHT = 16;
12607
+ var LABEL_OFFSET = 14;
12608
+ var MAX_LABEL_WIDTH_CACHE = 64;
12609
+ var RemoteCursorOverlay = class {
12610
+ host;
12611
+ roster;
12612
+ colorFor;
12613
+ showLabels;
12614
+ labelFont;
12615
+ labelWidths = /* @__PURE__ */ new Map();
12616
+ unregister;
12617
+ unsubscribe;
12618
+ isDisposed = false;
12619
+ constructor(host, roster, options = {}) {
12620
+ this.host = host;
12621
+ this.roster = roster;
12622
+ this.colorFor = options.colorFor;
12623
+ this.showLabels = options.showLabels ?? true;
12624
+ this.labelFont = options.labelFont ?? DEFAULT_LABEL_FONT;
12625
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12626
+ this.unsubscribe = roster.onChange(() => {
12627
+ if (this.labelWidths.size > MAX_LABEL_WIDTH_CACHE) this.labelWidths.clear();
12628
+ host.requestRender();
12629
+ });
12630
+ }
12631
+ get disposed() {
12632
+ return this.isDisposed;
12633
+ }
12634
+ resolveColor(peer) {
12635
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12636
+ }
12637
+ dispose() {
12638
+ if (this.isDisposed) return;
12639
+ this.isDisposed = true;
12640
+ this.unsubscribe?.();
12641
+ this.unsubscribe = null;
12642
+ this.unregister?.();
12643
+ this.unregister = null;
12644
+ this.labelWidths.clear();
12645
+ this.host.requestRender();
12646
+ }
12647
+ render(ctx) {
12648
+ if (this.isDisposed) return;
12649
+ const zoom = this.host.camera.zoom;
12650
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12651
+ for (const peer of this.roster.getPeers()) {
12652
+ if (peer.cursor === null) continue;
12653
+ const color = this.resolveColor(peer);
12654
+ ctx.save();
12655
+ ctx.translate(peer.cursor.x, peer.cursor.y);
12656
+ ctx.scale(inv, inv);
12657
+ ctx.beginPath();
12658
+ ctx.moveTo(0, 0);
12659
+ ctx.lineTo(0, 16);
12660
+ ctx.lineTo(4.5, 12.5);
12661
+ ctx.lineTo(11, 12.5);
12662
+ ctx.closePath();
12663
+ ctx.fillStyle = color;
12664
+ ctx.fill();
12665
+ ctx.strokeStyle = "#ffffff";
12666
+ ctx.lineWidth = 1;
12667
+ ctx.stroke();
12668
+ if (this.showLabels && peer.name !== void 0 && peer.name.length > 0) {
12669
+ this.drawLabel(ctx, peer.name, color);
12670
+ }
12671
+ ctx.restore();
12672
+ }
12673
+ }
12674
+ drawLabel(ctx, name, color) {
12675
+ ctx.font = this.labelFont;
12676
+ const key = `${this.labelFont} ${name}`;
12677
+ let width = this.labelWidths.get(key);
12678
+ if (width === void 0) {
12679
+ width = ctx.measureText(name).width;
12680
+ this.labelWidths.set(key, width);
12681
+ }
12682
+ const w = width + LABEL_PAD_X * 2;
12683
+ ctx.fillStyle = color;
12684
+ ctx.beginPath();
12685
+ ctx.roundRect(LABEL_OFFSET, LABEL_OFFSET, w, LABEL_HEIGHT + LABEL_PAD_Y, 4);
12686
+ ctx.fill();
12687
+ ctx.fillStyle = "#ffffff";
12688
+ ctx.textAlign = "left";
12689
+ ctx.textBaseline = "middle";
12690
+ ctx.fillText(name, LABEL_OFFSET + LABEL_PAD_X, LABEL_OFFSET + (LABEL_HEIGHT + LABEL_PAD_Y) / 2);
12691
+ }
12692
+ };
12693
+
12694
+ // src/canvas/remote-selection-overlay.ts
12695
+ var DEFAULT_ALPHA = 0.6;
12696
+ var DEFAULT_LINE_WIDTH_PX = 2;
12697
+ var RemoteSelectionOverlay = class {
12698
+ host;
12699
+ roster;
12700
+ colorFor;
12701
+ alpha;
12702
+ lineWidthPx;
12703
+ signatures = [];
12704
+ outlines = [];
12705
+ storeDirty = true;
12706
+ unregister;
12707
+ unsubscribers = [];
12708
+ isDisposed = false;
12709
+ constructor(host, roster, options = {}) {
12710
+ this.host = host;
12711
+ this.roster = roster;
12712
+ this.colorFor = options.colorFor;
12713
+ this.alpha = options.alpha ?? DEFAULT_ALPHA;
12714
+ this.lineWidthPx = options.lineWidthPx ?? DEFAULT_LINE_WIDTH_PX;
12715
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12716
+ const invalidate = () => {
12717
+ this.storeDirty = true;
12718
+ host.requestRender();
12719
+ };
12720
+ this.unsubscribers.push(
12721
+ roster.onChange(() => host.requestRender()),
12722
+ host.store.onChange(invalidate),
12723
+ host.layerManager.on("change", invalidate)
12724
+ );
12725
+ }
12726
+ get disposed() {
12727
+ return this.isDisposed;
12728
+ }
12729
+ dispose() {
12730
+ if (this.isDisposed) return;
12731
+ this.isDisposed = true;
12732
+ for (const unsub of this.unsubscribers) unsub();
12733
+ this.unsubscribers.length = 0;
12734
+ this.unregister?.();
12735
+ this.unregister = null;
12736
+ this.signatures = [];
12737
+ this.outlines = [];
12738
+ this.host.requestRender();
12739
+ }
12740
+ resolveColor(peer) {
12741
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12742
+ }
12743
+ /** Recomputes outlines only when the selection signature or the store/layers changed. */
12744
+ rebuild() {
12745
+ const peers = this.roster.getPeers();
12746
+ const next = [];
12747
+ for (const peer of peers) {
12748
+ if (peer.selection.length === 0) continue;
12749
+ next.push({ from: peer.from, selection: peer.selection, color: this.resolveColor(peer) });
12750
+ }
12751
+ let changed = this.storeDirty || next.length !== this.signatures.length;
12752
+ if (!changed) {
12753
+ for (let i = 0; i < next.length; i++) {
12754
+ const a = next[i];
12755
+ const b = this.signatures[i];
12756
+ if (!a || !b || a.from !== b.from || a.selection !== b.selection || a.color !== b.color) {
12757
+ changed = true;
12758
+ break;
12759
+ }
12760
+ }
12761
+ }
12762
+ if (!changed) return;
12763
+ this.storeDirty = false;
12764
+ this.signatures = next;
12765
+ if (next.length === 0) {
12766
+ this.outlines = [];
12767
+ return;
12768
+ }
12769
+ const colorById = /* @__PURE__ */ new Map();
12770
+ for (const sig of next) {
12771
+ for (const id of sig.selection) if (!colorById.has(id)) colorById.set(id, sig.color);
12772
+ }
12773
+ const layers = this.host.layerManager;
12774
+ const rects = computeElementRects(
12775
+ this.host.store,
12776
+ (element) => colorById.has(element.id) && layers.isLayerVisible(element.layerId) ? element.id : null
12777
+ );
12778
+ this.outlines = rects.map((rect) => ({ rect, color: colorById.get(rect.id) ?? "#2563eb" }));
12779
+ }
12780
+ render(ctx) {
12781
+ if (this.isDisposed) return;
12782
+ this.rebuild();
12783
+ if (this.outlines.length === 0) return;
12784
+ const zoom = this.host.camera.zoom;
12785
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12786
+ ctx.save();
12787
+ ctx.globalAlpha = this.alpha;
12788
+ ctx.lineWidth = this.lineWidthPx * inv;
12789
+ for (const { rect, color } of this.outlines) {
12790
+ ctx.save();
12791
+ ctx.strokeStyle = color;
12792
+ ctx.translate(rect.x + rect.w / 2, rect.y + rect.h / 2);
12793
+ if (rect.rotation !== 0) ctx.rotate(rect.rotation);
12794
+ ctx.strokeRect(-rect.w / 2, -rect.h / 2, rect.w, rect.h);
12795
+ ctx.restore();
12796
+ }
12797
+ ctx.restore();
12798
+ }
12799
+ };
12800
+
12801
+ // src/canvas/attach-awareness.ts
12802
+ function attachAwareness(viewport, channel, options) {
12803
+ const {
12804
+ roster: rosterOptions,
12805
+ cursors: cursorOptions,
12806
+ selections: selectionOptions,
12807
+ publish,
12808
+ ...localOptions
12809
+ } = options;
12810
+ const roster = new PeerRoster(rosterOptions);
12811
+ let local = null;
12812
+ let cursors = null;
12813
+ let selections = null;
12814
+ const unsubscribers = [];
12815
+ try {
12816
+ local = publish === false ? null : new LocalAwareness(viewport, {
12817
+ ...localOptions,
12818
+ send: (data) => channel.sendPresence(data)
12819
+ });
12820
+ cursors = cursorOptions === false ? null : new RemoteCursorOverlay(viewport, roster, cursorOptions ?? {});
12821
+ selections = selectionOptions === void 0 || selectionOptions === false ? null : new RemoteSelectionOverlay(
12822
+ viewport,
12823
+ roster,
12824
+ selectionOptions === true ? {} : selectionOptions
12825
+ );
12826
+ if (publish !== false) unsubscribers.push(roster.onDiscover(() => local?.announce()));
12827
+ unsubscribers.push(
12828
+ channel.onPresence((from, data) => {
12829
+ roster.apply(from, data);
12830
+ })
12831
+ );
12832
+ unsubscribers.push(channel.onPresenceLeave((from) => roster.remove(from)));
12833
+ } catch (error) {
12834
+ for (let i = unsubscribers.length - 1; i >= 0; i--) {
12835
+ try {
12836
+ unsubscribers[i]?.();
12837
+ } catch {
12838
+ }
12839
+ }
12840
+ unsubscribers.length = 0;
12841
+ try {
12842
+ selections?.dispose();
12843
+ } catch {
12844
+ }
12845
+ try {
12846
+ cursors?.dispose();
12847
+ } catch {
12848
+ }
12849
+ try {
12850
+ local?.dispose();
12851
+ } catch {
12852
+ }
12853
+ try {
12854
+ roster.dispose();
12855
+ } catch {
12856
+ }
12857
+ throw error;
12858
+ }
12859
+ let disposed = false;
12860
+ return {
12861
+ roster,
12862
+ local,
12863
+ cursors,
12864
+ selections,
12865
+ announce: () => local?.announce(),
12866
+ setFields: (fields) => local?.setFields(fields),
12867
+ dispose: () => {
12868
+ if (disposed) return;
12869
+ disposed = true;
11671
12870
  try {
11672
- listener(reason);
12871
+ local?.dispose();
12872
+ } catch {
12873
+ }
12874
+ try {
12875
+ cursors?.dispose();
12876
+ } catch {
12877
+ }
12878
+ try {
12879
+ selections?.dispose();
12880
+ } catch {
12881
+ }
12882
+ try {
12883
+ roster.dispose();
11673
12884
  } catch {
11674
12885
  }
12886
+ for (const unsub of unsubscribers) {
12887
+ try {
12888
+ unsub();
12889
+ } catch {
12890
+ }
12891
+ }
12892
+ unsubscribers.length = 0;
11675
12893
  }
11676
- }
11677
- };
11678
-
11679
- // src/canvas/focus-presence.ts
11680
- var FOCUS_PRESENCE_KIND = "focus";
11681
- var AUDIENCES = ["all", "players", "display"];
11682
- function isPositiveFinite(value) {
11683
- return typeof value === "number" && Number.isFinite(value) && value > 0;
11684
- }
11685
- function isFiniteNumber2(value) {
11686
- return typeof value === "number" && Number.isFinite(value);
11687
- }
11688
- function isFocusPresence(data) {
11689
- if (typeof data !== "object" || data === null) return false;
11690
- const payload = data;
11691
- if (payload.kind !== FOCUS_PRESENCE_KIND) return false;
11692
- if (!isFiniteNumber2(payload.x) || !isFiniteNumber2(payload.y)) return false;
11693
- if (!isPositiveFinite(payload.w) || !isPositiveFinite(payload.h)) return false;
11694
- if (typeof payload.audience !== "string" || !AUDIENCES.some((a) => a === payload.audience)) {
11695
- return false;
11696
- }
11697
- if (payload.color !== void 0 && typeof payload.color !== "string") return false;
11698
- return true;
11699
- }
11700
- function toFocusPresence(view, audience, color) {
11701
- return {
11702
- kind: FOCUS_PRESENCE_KIND,
11703
- x: view.x,
11704
- y: view.y,
11705
- w: view.w,
11706
- h: view.h,
11707
- audience,
11708
- ...color === void 0 ? {} : { color }
11709
12894
  };
11710
12895
  }
11711
12896
 
11712
- // src/canvas/remote-focus-receiver.ts
11713
- function audienceIncludes(audience, role) {
11714
- if (role === "dm") return false;
11715
- if (audience === "all") return true;
11716
- if (audience === "players") return role === "player";
11717
- return role === "display";
11718
- }
11719
- var RemoteFocusReceiver = class {
11720
- role;
11721
- animator;
11722
- animate;
11723
- pulseColor;
11724
- overlay;
11725
- disposed = false;
11726
- constructor(host, options) {
11727
- this.role = options.role;
11728
- this.animator = options.animator;
11729
- this.animate = options.animate ?? true;
11730
- this.pulseColor = options.pulseColor;
11731
- this.overlay = options.pulse ?? true ? new RemotePingOverlay(host, {
11732
- ...options.pulseColor === void 0 ? {} : { color: options.pulseColor },
11733
- ...options.pulseDurationMs === void 0 ? {} : { durationMs: options.pulseDurationMs },
11734
- ...options.pulseRadius === void 0 ? {} : { radius: options.pulseRadius },
11735
- maxPingsPerSender: 1
11736
- }) : null;
11737
- }
11738
- /**
11739
- * Applies a presence payload from `sender`. Returns `false` for payloads
11740
- * that are not focus frames, or are addressed to a different role, so hosts
11741
- * can feed every presence frame through without disturbing other handlers.
11742
- */
11743
- apply(from, data) {
11744
- if (this.disposed || !isFocusPresence(data)) return false;
11745
- if (!audienceIncludes(data.audience, this.role)) return false;
11746
- const view = { x: data.x, y: data.y, w: data.w, h: data.h };
11747
- if (this.animate) {
11748
- this.animator.animateTo(view);
11749
- } else {
11750
- this.animator.jumpTo(view);
11751
- }
11752
- this.overlay?.apply(from, {
11753
- kind: "ping",
11754
- x: view.x + view.w / 2,
11755
- y: view.y + view.h / 2,
11756
- color: data.color ?? this.pulseColor
11757
- });
11758
- return true;
11759
- }
11760
- /** Idempotent. Does NOT dispose the animator — the host owns that. */
11761
- dispose() {
11762
- if (this.disposed) return;
11763
- this.disposed = true;
11764
- this.overlay?.dispose();
11765
- }
11766
- };
11767
-
11768
12897
  // src/tools/hand-tool.ts
11769
12898
  var HandTool = class {
11770
12899
  name = "hand";
@@ -12597,7 +13726,8 @@ var SelectTool = class {
12597
13726
  } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
12598
13727
  const centerX = el.position.x + el.size.w / 2 + adjDx;
12599
13728
  const centerY = el.position.y + el.size.h / 2 + adjDy;
12600
- const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
13729
+ const footprint = footprintFromSize(el.size, ctx.gridSize ?? 0);
13730
+ const snappedCenter = snapFootprintCenter({ x: centerX, y: centerY }, footprint, ctx);
12601
13731
  ctx.store.update(id, {
12602
13732
  position: {
12603
13733
  x: snappedCenter.x - el.size.w / 2,
@@ -13175,15 +14305,15 @@ var ShapeTool = class {
13175
14305
  }
13176
14306
  onActivate(_ctx) {
13177
14307
  if (typeof window !== "undefined") {
13178
- window.addEventListener("keydown", this.onKeyDown);
13179
- window.addEventListener("keyup", this.onKeyUp);
14308
+ window.addEventListener("keydown", this.trackShiftKeyDown);
14309
+ window.addEventListener("keyup", this.trackShiftKeyUp);
13180
14310
  }
13181
14311
  }
13182
14312
  onDeactivate(_ctx) {
13183
14313
  this.shiftHeld = false;
13184
14314
  if (typeof window !== "undefined") {
13185
- window.removeEventListener("keydown", this.onKeyDown);
13186
- window.removeEventListener("keyup", this.onKeyUp);
14315
+ window.removeEventListener("keydown", this.trackShiftKeyDown);
14316
+ window.removeEventListener("keyup", this.trackShiftKeyUp);
13187
14317
  }
13188
14318
  }
13189
14319
  onPointerDown(state, ctx) {
@@ -13276,10 +14406,13 @@ var ShapeTool = class {
13276
14406
  snap(point, ctx) {
13277
14407
  return smartSnap(point, ctx);
13278
14408
  }
13279
- onKeyDown = (e) => {
14409
+ // Deliberately NOT migrated to `Tool.onKeyDown`: that hook is gated by
14410
+ // editable-target and keyboard-scope checks, and a Shift release must be
14411
+ // observed wherever it happens or the constraint sticks after the key is up.
14412
+ trackShiftKeyDown = (e) => {
13280
14413
  if (e.key === "Shift") this.shiftHeld = true;
13281
14414
  };
13282
- onKeyUp = (e) => {
14415
+ trackShiftKeyUp = (e) => {
13283
14416
  if (e.key === "Shift") this.shiftHeld = false;
13284
14417
  };
13285
14418
  };
@@ -13423,6 +14556,353 @@ var MeasureTool = class {
13423
14556
  }
13424
14557
  };
13425
14558
 
14559
+ // src/tools/path-tool.ts
14560
+ var EPS2 = 1e-6;
14561
+ var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
14562
+ function samePoint3(a, b) {
14563
+ return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
14564
+ }
14565
+ var PathTool = class {
14566
+ name = "path";
14567
+ feetPerCell;
14568
+ color;
14569
+ diagonalRule;
14570
+ footprintOption;
14571
+ rangeBands;
14572
+ commitTapRadiusPx;
14573
+ resolveStart;
14574
+ waypoints = [];
14575
+ cursor = null;
14576
+ anchorKey;
14577
+ footprint = 1;
14578
+ pointerDown = false;
14579
+ commitOnUp = false;
14580
+ // Grid state is captured at the opening pointer-down so a path measured on
14581
+ // one grid keeps that metric even if the host reconfigures mid-gesture.
14582
+ gridSize = 0;
14583
+ gridType;
14584
+ hexOrientation;
14585
+ snapEnabled = false;
14586
+ optionListeners = /* @__PURE__ */ new Set();
14587
+ pathListeners = /* @__PURE__ */ new Set();
14588
+ commitListeners = /* @__PURE__ */ new Set();
14589
+ emissionRafId = null;
14590
+ constructor(options = {}) {
14591
+ this.feetPerCell = options.feetPerCell ?? 5;
14592
+ this.color = options.color ?? "#FF5722";
14593
+ this.diagonalRule = options.diagonalRule ?? "euclidean";
14594
+ this.footprintOption = options.footprint ?? 1;
14595
+ this.rangeBands = options.rangeBands ?? [];
14596
+ this.commitTapRadiusPx = options.commitTapRadiusPx ?? DEFAULT_COMMIT_TAP_RADIUS_PX;
14597
+ this.resolveStart = options.resolveStart;
14598
+ }
14599
+ getOptions() {
14600
+ return {
14601
+ feetPerCell: this.feetPerCell,
14602
+ color: this.color,
14603
+ diagonalRule: this.diagonalRule,
14604
+ footprint: this.footprintOption,
14605
+ rangeBands: this.rangeBands,
14606
+ commitTapRadiusPx: this.commitTapRadiusPx,
14607
+ resolveStart: this.resolveStart
14608
+ };
14609
+ }
14610
+ setOptions(options) {
14611
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
14612
+ if (options.color !== void 0) this.color = options.color;
14613
+ if (options.diagonalRule !== void 0) this.diagonalRule = options.diagonalRule;
14614
+ if (options.footprint !== void 0) this.footprintOption = options.footprint;
14615
+ if (options.rangeBands !== void 0) this.rangeBands = options.rangeBands;
14616
+ if (options.commitTapRadiusPx !== void 0) {
14617
+ this.commitTapRadiusPx = options.commitTapRadiusPx;
14618
+ }
14619
+ if (options.resolveStart !== void 0) this.resolveStart = options.resolveStart;
14620
+ this.notifyOptionsChange();
14621
+ }
14622
+ onOptionsChange(listener) {
14623
+ this.optionListeners.add(listener);
14624
+ return () => this.optionListeners.delete(listener);
14625
+ }
14626
+ /**
14627
+ * Subscribes to raf-coalesced path snapshots. While a path is open,
14628
+ * listeners receive at most one snapshot per animation frame carrying the
14629
+ * latest state; `null` is delivered synchronously when the path closes
14630
+ * (commit, cancel, or deactivate).
14631
+ */
14632
+ onPath(listener) {
14633
+ this.pathListeners.add(listener);
14634
+ return () => this.pathListeners.delete(listener);
14635
+ }
14636
+ /**
14637
+ * Subscribes to finished paths. The emission carries `cursor: null` and the
14638
+ * final waypoints; applying the move (and recording history for it) is the
14639
+ * host's job — the tool has already forgotten the path by then.
14640
+ */
14641
+ onCommit(listener) {
14642
+ this.commitListeners.add(listener);
14643
+ return () => this.commitListeners.delete(listener);
14644
+ }
14645
+ get isOpen() {
14646
+ return this.waypoints.length > 0;
14647
+ }
14648
+ /** Synchronous snapshot of the open path; `null` when no path is open. */
14649
+ getEmission() {
14650
+ if (!this.isOpen) return null;
14651
+ return this.buildEmission(this.cursor);
14652
+ }
14653
+ onPointerDown(state, ctx) {
14654
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14655
+ if (!this.isOpen) {
14656
+ const anchor = this.resolveStart ? this.resolveStart(world, ctx) : { origin: world };
14657
+ if (!anchor) return;
14658
+ this.gridSize = ctx.gridSize ?? 0;
14659
+ this.gridType = ctx.gridType;
14660
+ this.hexOrientation = ctx.hexOrientation;
14661
+ this.snapEnabled = ctx.snapToGrid === true;
14662
+ this.footprint = anchor.footprint ?? this.footprintOption;
14663
+ const origin = this.snap(anchor.origin);
14664
+ this.waypoints = [origin];
14665
+ this.cursor = { ...origin };
14666
+ this.anchorKey = anchor.anchorKey;
14667
+ this.pointerDown = true;
14668
+ this.scheduleEmission();
14669
+ ctx.requestRender();
14670
+ return;
14671
+ }
14672
+ const point = this.snap(world);
14673
+ const last = this.lastWaypoint();
14674
+ if (last && this.withinCommitRadius(point, last, ctx)) {
14675
+ this.commitOnUp = true;
14676
+ this.cursor = { ...last };
14677
+ } else {
14678
+ this.cursor = point;
14679
+ }
14680
+ this.pointerDown = true;
14681
+ this.scheduleEmission();
14682
+ ctx.requestRender();
14683
+ }
14684
+ onPointerMove(state, ctx) {
14685
+ if (!this.isOpen) return;
14686
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14687
+ const point = this.snap(world);
14688
+ const last = this.lastWaypoint();
14689
+ if (this.commitOnUp && last && this.withinCommitRadius(point, last, ctx)) {
14690
+ this.cursor = { ...last };
14691
+ } else {
14692
+ this.commitOnUp = false;
14693
+ this.cursor = point;
14694
+ }
14695
+ this.scheduleEmission();
14696
+ ctx.requestRender();
14697
+ }
14698
+ onPointerUp(_state, ctx) {
14699
+ if (!this.isOpen) return;
14700
+ this.pointerDown = false;
14701
+ if (this.commitOnUp) {
14702
+ this.commit(ctx);
14703
+ return;
14704
+ }
14705
+ const last = this.lastWaypoint();
14706
+ if (this.cursor && last && !samePoint3(this.cursor, last)) {
14707
+ this.waypoints.push({ ...this.cursor });
14708
+ }
14709
+ this.scheduleEmission();
14710
+ ctx.requestRender();
14711
+ }
14712
+ onHover(state, ctx) {
14713
+ if (!this.isOpen || this.pointerDown) return;
14714
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
14715
+ this.cursor = this.snap(world);
14716
+ this.scheduleEmission();
14717
+ ctx.requestRender();
14718
+ }
14719
+ /** A takeover (second pointer, platform cancel) abandons the path outright. */
14720
+ onPointerCancel(_state, ctx) {
14721
+ this.cancel(ctx);
14722
+ }
14723
+ onDeactivate(ctx) {
14724
+ this.cancel(ctx);
14725
+ }
14726
+ onKeyDown(event, ctx) {
14727
+ if (!this.isOpen) return false;
14728
+ if (event.key === "Enter") {
14729
+ this.commit(ctx);
14730
+ return true;
14731
+ }
14732
+ if (event.key === "Escape") {
14733
+ this.cancel(ctx);
14734
+ return true;
14735
+ }
14736
+ return false;
14737
+ }
14738
+ renderOverlay(ctx) {
14739
+ if (!this.isOpen) return;
14740
+ const { points, cumulative, total } = this.measure(this.cursor);
14741
+ const cumulativeFeet = cumulative.map((cells) => cells * this.feetPerCell);
14742
+ drawPath(ctx, {
14743
+ points,
14744
+ segmentColors: resolveSegmentColors(cumulativeFeet, this.rangeBands, this.color),
14745
+ color: this.color,
14746
+ feet: total * this.feetPerCell
14747
+ });
14748
+ }
14749
+ lastWaypoint() {
14750
+ return this.waypoints[this.waypoints.length - 1];
14751
+ }
14752
+ /**
14753
+ * Is `point` close enough to `last` to mean "finish here"? EXACT match always
14754
+ * qualifies. The screen-space tolerance is added ONLY when this path has no
14755
+ * active snapping grid: on a snapping grid (`gridType === 'square'`, or
14756
+ * `'hex'` with a captured orientation, and `gridSize > 0`) the adjacent
14757
+ * cell/hex centre can be as little as one grid cell away, and a radius
14758
+ * computed from CSS pixels has no relationship to that distance — at a
14759
+ * sufficiently zoomed-out camera the radius would swallow a whole neighbour
14760
+ * cell and turn a deliberate next-waypoint tap into an unwanted commit.
14761
+ * Snapping already makes a fingertip tap land exactly on the last waypoint,
14762
+ * so the tolerance is unnecessary there; it exists for the gridless and
14763
+ * `snapPoint`/identity paths, where a fingertip can never land on an exact
14764
+ * world point.
14765
+ */
14766
+ withinCommitRadius(point, last, ctx) {
14767
+ if (samePoint3(point, last)) return true;
14768
+ if (this.hasSnappingGrid()) return false;
14769
+ const zoom = ctx.camera.zoom;
14770
+ if (!(zoom > 0)) return false;
14771
+ const radius = this.commitTapRadiusPx / zoom;
14772
+ if (radius <= 0) return false;
14773
+ const dx = point.x - last.x;
14774
+ const dy = point.y - last.y;
14775
+ return dx * dx + dy * dy <= radius * radius;
14776
+ }
14777
+ /**
14778
+ * True when this path's captured grid state snaps every waypoint onto a
14779
+ * cell/hex centre: a `square` grid, or a `hex` grid with a captured
14780
+ * orientation, both with a usable `gridSize`. Mirrors the unconditional
14781
+ * branches of `snap` below — NOT the `snapToGrid`-gated fallback, which
14782
+ * leaves waypoints unsnapped when the user turns snapping off.
14783
+ */
14784
+ hasSnappingGrid() {
14785
+ if (!(this.gridSize > 0)) return false;
14786
+ if (this.gridType === "square") return true;
14787
+ return this.gridType === "hex" && this.hexOrientation !== void 0;
14788
+ }
14789
+ /**
14790
+ * Snapping to cell or hex centres is UNCONDITIONAL for a `square` grid, or a
14791
+ * `hex` grid WITH a captured orientation: a movement path measures in cells,
14792
+ * so an unsnapped waypoint would report a distance the grid does not agree
14793
+ * with. A `hex` grid WITHOUT an orientation falls through to the
14794
+ * `snapToGrid`-gated `snapPoint` (intersection) branch below, same as the
14795
+ * gridType-less case — unlike `smartSnap`, which is off entirely when the
14796
+ * user turns snapping off.
14797
+ */
14798
+ snap(point) {
14799
+ if (this.gridSize <= 0) return point;
14800
+ if (this.gridType === "hex" && this.hexOrientation) {
14801
+ return snapToHexCenter(point, this.gridSize, this.hexOrientation);
14802
+ }
14803
+ if (this.gridType === "square") {
14804
+ return snapToCellCenter(point, this.gridSize, this.footprint);
14805
+ }
14806
+ if (this.snapEnabled) {
14807
+ return snapPoint(point, this.gridSize);
14808
+ }
14809
+ return point;
14810
+ }
14811
+ /** The measured polyline: waypoints, plus the cursor when it adds a leg. */
14812
+ measure(cursor) {
14813
+ const points = this.waypoints.map((p) => ({ ...p }));
14814
+ const last = points[points.length - 1];
14815
+ if (cursor && (!last || !samePoint3(cursor, last))) points.push({ ...cursor });
14816
+ const { total, cumulative } = pathDistanceCells(points, {
14817
+ gridSize: this.gridSize,
14818
+ gridType: this.gridType,
14819
+ hexOrientation: this.hexOrientation,
14820
+ diagonalRule: this.diagonalRule
14821
+ });
14822
+ return { points, cumulative, total };
14823
+ }
14824
+ buildEmission(cursor) {
14825
+ const { cumulative, total } = this.measure(cursor);
14826
+ const segments = [];
14827
+ for (let i = 1; i < cumulative.length; i++) {
14828
+ const cells = (cumulative[i] ?? 0) - (cumulative[i - 1] ?? 0);
14829
+ segments.push({ cells, feet: cells * this.feetPerCell });
14830
+ }
14831
+ return {
14832
+ anchorKey: this.anchorKey,
14833
+ waypoints: this.waypoints.map((p) => ({ ...p })),
14834
+ cursor: cursor ? { ...cursor } : null,
14835
+ segments,
14836
+ totalCells: total,
14837
+ totalFeet: total * this.feetPerCell,
14838
+ color: this.color,
14839
+ rangeBands: this.rangeBands
14840
+ };
14841
+ }
14842
+ commit(ctx) {
14843
+ if (this.waypoints.length < 2) {
14844
+ this.cancel(ctx);
14845
+ return;
14846
+ }
14847
+ const emission = this.buildEmission(null);
14848
+ this.reset();
14849
+ this.emitCommit(emission);
14850
+ this.emitClear();
14851
+ ctx.requestRender();
14852
+ }
14853
+ cancel(ctx) {
14854
+ if (!this.isOpen) return;
14855
+ this.reset();
14856
+ this.emitClear();
14857
+ ctx.requestRender();
14858
+ }
14859
+ reset() {
14860
+ this.waypoints = [];
14861
+ this.cursor = null;
14862
+ this.anchorKey = void 0;
14863
+ this.footprint = this.footprintOption;
14864
+ this.pointerDown = false;
14865
+ this.commitOnUp = false;
14866
+ }
14867
+ notifyOptionsChange() {
14868
+ for (const listener of this.optionListeners) listener();
14869
+ }
14870
+ scheduleEmission() {
14871
+ if (this.pathListeners.size === 0) return;
14872
+ if (this.emissionRafId !== null) return;
14873
+ this.emissionRafId = requestAnimationFrame(() => {
14874
+ this.emissionRafId = null;
14875
+ const emission = this.getEmission();
14876
+ if (!emission) return;
14877
+ this.emitPath(emission);
14878
+ });
14879
+ }
14880
+ emitClear() {
14881
+ if (this.emissionRafId !== null) {
14882
+ cancelAnimationFrame(this.emissionRafId);
14883
+ this.emissionRafId = null;
14884
+ }
14885
+ if (this.pathListeners.size === 0) return;
14886
+ this.emitPath(null);
14887
+ }
14888
+ emitPath(emission) {
14889
+ for (const listener of this.pathListeners) {
14890
+ try {
14891
+ listener(emission);
14892
+ } catch {
14893
+ }
14894
+ }
14895
+ }
14896
+ emitCommit(emission) {
14897
+ for (const listener of this.commitListeners) {
14898
+ try {
14899
+ listener(emission);
14900
+ } catch {
14901
+ }
14902
+ }
14903
+ }
14904
+ };
14905
+
13426
14906
  // src/tools/template-tool.ts
13427
14907
  var MIN_RECT_WIDTH = 20;
13428
14908
  function defaultRectWidth(radius, scaleUnit) {
@@ -13716,7 +15196,7 @@ var TemplateTool = class {
13716
15196
  };
13717
15197
 
13718
15198
  // src/tools/laser-tool.ts
13719
- var DEFAULT_COLOR5 = "#ff3b30";
15199
+ var DEFAULT_COLOR6 = "#ff3b30";
13720
15200
  var DEFAULT_WIDTH3 = 4;
13721
15201
  var DEFAULT_FADE_MS3 = 1200;
13722
15202
  var LaserTool = class {
@@ -13732,7 +15212,7 @@ var LaserTool = class {
13732
15212
  pendingEmission = [];
13733
15213
  constructor(options = {}) {
13734
15214
  this.name = options.name ?? "laser";
13735
- this.color = options.color ?? DEFAULT_COLOR5;
15215
+ this.color = options.color ?? DEFAULT_COLOR6;
13736
15216
  this.width = options.width ?? DEFAULT_WIDTH3;
13737
15217
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
13738
15218
  }
@@ -13860,7 +15340,7 @@ var LaserTool = class {
13860
15340
  };
13861
15341
 
13862
15342
  // src/tools/ping-tool.ts
13863
- var DEFAULT_COLOR6 = "#ff3b30";
15343
+ var DEFAULT_COLOR7 = "#ff3b30";
13864
15344
  var DEFAULT_DURATION_MS4 = 1800;
13865
15345
  var DEFAULT_RADIUS4 = 48;
13866
15346
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -13877,7 +15357,7 @@ var PingTool = class {
13877
15357
  pingListeners = /* @__PURE__ */ new Set();
13878
15358
  constructor(options = {}) {
13879
15359
  this.name = options.name ?? "ping";
13880
- this.color = options.color ?? DEFAULT_COLOR6;
15360
+ this.color = options.color ?? DEFAULT_COLOR7;
13881
15361
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
13882
15362
  this.radius = options.radius ?? DEFAULT_RADIUS4;
13883
15363
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -13980,8 +15460,10 @@ var PingTool = class {
13980
15460
  };
13981
15461
 
13982
15462
  // src/index.ts
13983
- var VERSION = "0.63.0";
15463
+ var VERSION = "0.65.0";
13984
15464
  export {
15465
+ AWARENESS_MAX_SELECTION,
15466
+ AWARENESS_PRESENCE_KIND,
13985
15467
  ArrowTool,
13986
15468
  AutoSave,
13987
15469
  Camera,
@@ -14000,20 +15482,29 @@ export {
14000
15482
  LASER_TRAIL_PRESENCE_KIND,
14001
15483
  LaserTool,
14002
15484
  LayerManager,
15485
+ LocalAwareness,
14003
15486
  LocalStorageAdapter,
14004
15487
  MEASURE_PRESENCE_KIND,
14005
15488
  MeasureTool,
14006
15489
  MemoryAdapter,
14007
15490
  MinimapController,
14008
15491
  NoteTool,
15492
+ PATH_PRESENCE_KIND,
15493
+ PATH_PRESENCE_MAX_POINTS,
15494
+ PEER_COLORS,
14009
15495
  PING_PRESENCE_KIND,
15496
+ PathTool,
15497
+ PeerRoster,
14010
15498
  PencilTool,
14011
15499
  PingInput,
14012
15500
  PingTool,
15501
+ RemoteCursorOverlay,
14013
15502
  RemoteFocusReceiver,
14014
15503
  RemoteLaserOverlay,
14015
15504
  RemoteMeasureOverlay,
15505
+ RemotePathOverlay,
14016
15506
  RemotePingOverlay,
15507
+ RemoteSelectionOverlay,
14017
15508
  SelectTool,
14018
15509
  ShapeTool,
14019
15510
  TemplateTool,
@@ -14022,6 +15513,7 @@ export {
14022
15513
  VERSION,
14023
15514
  Viewport,
14024
15515
  applyCameraView,
15516
+ attachAwareness,
14025
15517
  boundsIntersect,
14026
15518
  cameraOriginForView,
14027
15519
  captureCameraView,
@@ -14035,11 +15527,13 @@ export {
14035
15527
  createStroke,
14036
15528
  createTemplate,
14037
15529
  createText,
15530
+ defaultPeerColor,
14038
15531
  drawHexPath,
14039
15532
  elementRectsEqual,
14040
15533
  exportImage,
14041
15534
  exportSvg,
14042
15535
  fitZoomForView,
15536
+ footprintFromSize,
14043
15537
  getActiveFormats,
14044
15538
  getArrowBounds,
14045
15539
  getArrowControlPoint,
@@ -14055,20 +15549,27 @@ export {
14055
15549
  getHexCellsInRectangle,
14056
15550
  getHexCellsInSquare,
14057
15551
  getHexDistance,
15552
+ gridDistanceCells,
15553
+ isAwarenessPresence,
14058
15554
  isFocusPresence,
14059
15555
  isLaserTrailPresence,
14060
15556
  isMeasurePresence,
14061
15557
  isNearBezier,
15558
+ isPathPresence,
14062
15559
  isPingPresence,
15560
+ pathDistanceCells,
14063
15561
  resolveHtmlRouting,
14064
15562
  setFontSize,
14065
15563
  smartSnap,
15564
+ snapFootprintCenter,
14066
15565
  snapPoint,
15566
+ snapToCellCenter,
14067
15567
  snapToHexCenter,
14068
15568
  styleToPatch,
14069
15569
  toFocusPresence,
14070
15570
  toLaserTrailPresence,
14071
15571
  toMeasurePresence,
15572
+ toPathPresence,
14072
15573
  toPingPresence,
14073
15574
  toggleBold,
14074
15575
  toggleItalic,