@fieldnotes/core 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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;
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;
11224
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;
@@ -12597,7 +12932,8 @@ var SelectTool = class {
12597
12932
  } else if (!ctx.smartGuides && ctx.gridType && "size" in el) {
12598
12933
  const centerX = el.position.x + el.size.w / 2 + adjDx;
12599
12934
  const centerY = el.position.y + el.size.h / 2 + adjDy;
12600
- const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
12935
+ const footprint = footprintFromSize(el.size, ctx.gridSize ?? 0);
12936
+ const snappedCenter = snapFootprintCenter({ x: centerX, y: centerY }, footprint, ctx);
12601
12937
  ctx.store.update(id, {
12602
12938
  position: {
12603
12939
  x: snappedCenter.x - el.size.w / 2,
@@ -13175,15 +13511,15 @@ var ShapeTool = class {
13175
13511
  }
13176
13512
  onActivate(_ctx) {
13177
13513
  if (typeof window !== "undefined") {
13178
- window.addEventListener("keydown", this.onKeyDown);
13179
- window.addEventListener("keyup", this.onKeyUp);
13514
+ window.addEventListener("keydown", this.trackShiftKeyDown);
13515
+ window.addEventListener("keyup", this.trackShiftKeyUp);
13180
13516
  }
13181
13517
  }
13182
13518
  onDeactivate(_ctx) {
13183
13519
  this.shiftHeld = false;
13184
13520
  if (typeof window !== "undefined") {
13185
- window.removeEventListener("keydown", this.onKeyDown);
13186
- window.removeEventListener("keyup", this.onKeyUp);
13521
+ window.removeEventListener("keydown", this.trackShiftKeyDown);
13522
+ window.removeEventListener("keyup", this.trackShiftKeyUp);
13187
13523
  }
13188
13524
  }
13189
13525
  onPointerDown(state, ctx) {
@@ -13276,10 +13612,13 @@ var ShapeTool = class {
13276
13612
  snap(point, ctx) {
13277
13613
  return smartSnap(point, ctx);
13278
13614
  }
13279
- onKeyDown = (e) => {
13615
+ // Deliberately NOT migrated to `Tool.onKeyDown`: that hook is gated by
13616
+ // editable-target and keyboard-scope checks, and a Shift release must be
13617
+ // observed wherever it happens or the constraint sticks after the key is up.
13618
+ trackShiftKeyDown = (e) => {
13280
13619
  if (e.key === "Shift") this.shiftHeld = true;
13281
13620
  };
13282
- onKeyUp = (e) => {
13621
+ trackShiftKeyUp = (e) => {
13283
13622
  if (e.key === "Shift") this.shiftHeld = false;
13284
13623
  };
13285
13624
  };
@@ -13423,6 +13762,353 @@ var MeasureTool = class {
13423
13762
  }
13424
13763
  };
13425
13764
 
13765
+ // src/tools/path-tool.ts
13766
+ var EPS2 = 1e-6;
13767
+ var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
13768
+ function samePoint2(a, b) {
13769
+ return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
13770
+ }
13771
+ var PathTool = class {
13772
+ name = "path";
13773
+ feetPerCell;
13774
+ color;
13775
+ diagonalRule;
13776
+ footprintOption;
13777
+ rangeBands;
13778
+ commitTapRadiusPx;
13779
+ resolveStart;
13780
+ waypoints = [];
13781
+ cursor = null;
13782
+ anchorKey;
13783
+ footprint = 1;
13784
+ pointerDown = false;
13785
+ commitOnUp = false;
13786
+ // Grid state is captured at the opening pointer-down so a path measured on
13787
+ // one grid keeps that metric even if the host reconfigures mid-gesture.
13788
+ gridSize = 0;
13789
+ gridType;
13790
+ hexOrientation;
13791
+ snapEnabled = false;
13792
+ optionListeners = /* @__PURE__ */ new Set();
13793
+ pathListeners = /* @__PURE__ */ new Set();
13794
+ commitListeners = /* @__PURE__ */ new Set();
13795
+ emissionRafId = null;
13796
+ constructor(options = {}) {
13797
+ this.feetPerCell = options.feetPerCell ?? 5;
13798
+ this.color = options.color ?? "#FF5722";
13799
+ this.diagonalRule = options.diagonalRule ?? "euclidean";
13800
+ this.footprintOption = options.footprint ?? 1;
13801
+ this.rangeBands = options.rangeBands ?? [];
13802
+ this.commitTapRadiusPx = options.commitTapRadiusPx ?? DEFAULT_COMMIT_TAP_RADIUS_PX;
13803
+ this.resolveStart = options.resolveStart;
13804
+ }
13805
+ getOptions() {
13806
+ return {
13807
+ feetPerCell: this.feetPerCell,
13808
+ color: this.color,
13809
+ diagonalRule: this.diagonalRule,
13810
+ footprint: this.footprintOption,
13811
+ rangeBands: this.rangeBands,
13812
+ commitTapRadiusPx: this.commitTapRadiusPx,
13813
+ resolveStart: this.resolveStart
13814
+ };
13815
+ }
13816
+ setOptions(options) {
13817
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
13818
+ if (options.color !== void 0) this.color = options.color;
13819
+ if (options.diagonalRule !== void 0) this.diagonalRule = options.diagonalRule;
13820
+ if (options.footprint !== void 0) this.footprintOption = options.footprint;
13821
+ if (options.rangeBands !== void 0) this.rangeBands = options.rangeBands;
13822
+ if (options.commitTapRadiusPx !== void 0) {
13823
+ this.commitTapRadiusPx = options.commitTapRadiusPx;
13824
+ }
13825
+ if (options.resolveStart !== void 0) this.resolveStart = options.resolveStart;
13826
+ this.notifyOptionsChange();
13827
+ }
13828
+ onOptionsChange(listener) {
13829
+ this.optionListeners.add(listener);
13830
+ return () => this.optionListeners.delete(listener);
13831
+ }
13832
+ /**
13833
+ * Subscribes to raf-coalesced path snapshots. While a path is open,
13834
+ * listeners receive at most one snapshot per animation frame carrying the
13835
+ * latest state; `null` is delivered synchronously when the path closes
13836
+ * (commit, cancel, or deactivate).
13837
+ */
13838
+ onPath(listener) {
13839
+ this.pathListeners.add(listener);
13840
+ return () => this.pathListeners.delete(listener);
13841
+ }
13842
+ /**
13843
+ * Subscribes to finished paths. The emission carries `cursor: null` and the
13844
+ * final waypoints; applying the move (and recording history for it) is the
13845
+ * host's job — the tool has already forgotten the path by then.
13846
+ */
13847
+ onCommit(listener) {
13848
+ this.commitListeners.add(listener);
13849
+ return () => this.commitListeners.delete(listener);
13850
+ }
13851
+ get isOpen() {
13852
+ return this.waypoints.length > 0;
13853
+ }
13854
+ /** Synchronous snapshot of the open path; `null` when no path is open. */
13855
+ getEmission() {
13856
+ if (!this.isOpen) return null;
13857
+ return this.buildEmission(this.cursor);
13858
+ }
13859
+ onPointerDown(state, ctx) {
13860
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
13861
+ if (!this.isOpen) {
13862
+ const anchor = this.resolveStart ? this.resolveStart(world, ctx) : { origin: world };
13863
+ if (!anchor) return;
13864
+ this.gridSize = ctx.gridSize ?? 0;
13865
+ this.gridType = ctx.gridType;
13866
+ this.hexOrientation = ctx.hexOrientation;
13867
+ this.snapEnabled = ctx.snapToGrid === true;
13868
+ this.footprint = anchor.footprint ?? this.footprintOption;
13869
+ const origin = this.snap(anchor.origin);
13870
+ this.waypoints = [origin];
13871
+ this.cursor = { ...origin };
13872
+ this.anchorKey = anchor.anchorKey;
13873
+ this.pointerDown = true;
13874
+ this.scheduleEmission();
13875
+ ctx.requestRender();
13876
+ return;
13877
+ }
13878
+ const point = this.snap(world);
13879
+ const last = this.lastWaypoint();
13880
+ if (last && this.withinCommitRadius(point, last, ctx)) {
13881
+ this.commitOnUp = true;
13882
+ this.cursor = { ...last };
13883
+ } else {
13884
+ this.cursor = point;
13885
+ }
13886
+ this.pointerDown = true;
13887
+ this.scheduleEmission();
13888
+ ctx.requestRender();
13889
+ }
13890
+ onPointerMove(state, ctx) {
13891
+ if (!this.isOpen) return;
13892
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
13893
+ const point = this.snap(world);
13894
+ const last = this.lastWaypoint();
13895
+ if (this.commitOnUp && last && this.withinCommitRadius(point, last, ctx)) {
13896
+ this.cursor = { ...last };
13897
+ } else {
13898
+ this.commitOnUp = false;
13899
+ this.cursor = point;
13900
+ }
13901
+ this.scheduleEmission();
13902
+ ctx.requestRender();
13903
+ }
13904
+ onPointerUp(_state, ctx) {
13905
+ if (!this.isOpen) return;
13906
+ this.pointerDown = false;
13907
+ if (this.commitOnUp) {
13908
+ this.commit(ctx);
13909
+ return;
13910
+ }
13911
+ const last = this.lastWaypoint();
13912
+ if (this.cursor && last && !samePoint2(this.cursor, last)) {
13913
+ this.waypoints.push({ ...this.cursor });
13914
+ }
13915
+ this.scheduleEmission();
13916
+ ctx.requestRender();
13917
+ }
13918
+ onHover(state, ctx) {
13919
+ if (!this.isOpen || this.pointerDown) return;
13920
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
13921
+ this.cursor = this.snap(world);
13922
+ this.scheduleEmission();
13923
+ ctx.requestRender();
13924
+ }
13925
+ /** A takeover (second pointer, platform cancel) abandons the path outright. */
13926
+ onPointerCancel(_state, ctx) {
13927
+ this.cancel(ctx);
13928
+ }
13929
+ onDeactivate(ctx) {
13930
+ this.cancel(ctx);
13931
+ }
13932
+ onKeyDown(event, ctx) {
13933
+ if (!this.isOpen) return false;
13934
+ if (event.key === "Enter") {
13935
+ this.commit(ctx);
13936
+ return true;
13937
+ }
13938
+ if (event.key === "Escape") {
13939
+ this.cancel(ctx);
13940
+ return true;
13941
+ }
13942
+ return false;
13943
+ }
13944
+ renderOverlay(ctx) {
13945
+ if (!this.isOpen) return;
13946
+ const { points, cumulative, total } = this.measure(this.cursor);
13947
+ const cumulativeFeet = cumulative.map((cells) => cells * this.feetPerCell);
13948
+ drawPath(ctx, {
13949
+ points,
13950
+ segmentColors: resolveSegmentColors(cumulativeFeet, this.rangeBands, this.color),
13951
+ color: this.color,
13952
+ feet: total * this.feetPerCell
13953
+ });
13954
+ }
13955
+ lastWaypoint() {
13956
+ return this.waypoints[this.waypoints.length - 1];
13957
+ }
13958
+ /**
13959
+ * Is `point` close enough to `last` to mean "finish here"? EXACT match always
13960
+ * qualifies. The screen-space tolerance is added ONLY when this path has no
13961
+ * active snapping grid: on a snapping grid (`gridType === 'square'`, or
13962
+ * `'hex'` with a captured orientation, and `gridSize > 0`) the adjacent
13963
+ * cell/hex centre can be as little as one grid cell away, and a radius
13964
+ * computed from CSS pixels has no relationship to that distance — at a
13965
+ * sufficiently zoomed-out camera the radius would swallow a whole neighbour
13966
+ * cell and turn a deliberate next-waypoint tap into an unwanted commit.
13967
+ * Snapping already makes a fingertip tap land exactly on the last waypoint,
13968
+ * so the tolerance is unnecessary there; it exists for the gridless and
13969
+ * `snapPoint`/identity paths, where a fingertip can never land on an exact
13970
+ * world point.
13971
+ */
13972
+ withinCommitRadius(point, last, ctx) {
13973
+ if (samePoint2(point, last)) return true;
13974
+ if (this.hasSnappingGrid()) return false;
13975
+ const zoom = ctx.camera.zoom;
13976
+ if (!(zoom > 0)) return false;
13977
+ const radius = this.commitTapRadiusPx / zoom;
13978
+ if (radius <= 0) return false;
13979
+ const dx = point.x - last.x;
13980
+ const dy = point.y - last.y;
13981
+ return dx * dx + dy * dy <= radius * radius;
13982
+ }
13983
+ /**
13984
+ * True when this path's captured grid state snaps every waypoint onto a
13985
+ * cell/hex centre: a `square` grid, or a `hex` grid with a captured
13986
+ * orientation, both with a usable `gridSize`. Mirrors the unconditional
13987
+ * branches of `snap` below — NOT the `snapToGrid`-gated fallback, which
13988
+ * leaves waypoints unsnapped when the user turns snapping off.
13989
+ */
13990
+ hasSnappingGrid() {
13991
+ if (!(this.gridSize > 0)) return false;
13992
+ if (this.gridType === "square") return true;
13993
+ return this.gridType === "hex" && this.hexOrientation !== void 0;
13994
+ }
13995
+ /**
13996
+ * Snapping to cell or hex centres is UNCONDITIONAL for a `square` grid, or a
13997
+ * `hex` grid WITH a captured orientation: a movement path measures in cells,
13998
+ * so an unsnapped waypoint would report a distance the grid does not agree
13999
+ * with. A `hex` grid WITHOUT an orientation falls through to the
14000
+ * `snapToGrid`-gated `snapPoint` (intersection) branch below, same as the
14001
+ * gridType-less case — unlike `smartSnap`, which is off entirely when the
14002
+ * user turns snapping off.
14003
+ */
14004
+ snap(point) {
14005
+ if (this.gridSize <= 0) return point;
14006
+ if (this.gridType === "hex" && this.hexOrientation) {
14007
+ return snapToHexCenter(point, this.gridSize, this.hexOrientation);
14008
+ }
14009
+ if (this.gridType === "square") {
14010
+ return snapToCellCenter(point, this.gridSize, this.footprint);
14011
+ }
14012
+ if (this.snapEnabled) {
14013
+ return snapPoint(point, this.gridSize);
14014
+ }
14015
+ return point;
14016
+ }
14017
+ /** The measured polyline: waypoints, plus the cursor when it adds a leg. */
14018
+ measure(cursor) {
14019
+ const points = this.waypoints.map((p) => ({ ...p }));
14020
+ const last = points[points.length - 1];
14021
+ if (cursor && (!last || !samePoint2(cursor, last))) points.push({ ...cursor });
14022
+ const { total, cumulative } = pathDistanceCells(points, {
14023
+ gridSize: this.gridSize,
14024
+ gridType: this.gridType,
14025
+ hexOrientation: this.hexOrientation,
14026
+ diagonalRule: this.diagonalRule
14027
+ });
14028
+ return { points, cumulative, total };
14029
+ }
14030
+ buildEmission(cursor) {
14031
+ const { cumulative, total } = this.measure(cursor);
14032
+ const segments = [];
14033
+ for (let i = 1; i < cumulative.length; i++) {
14034
+ const cells = (cumulative[i] ?? 0) - (cumulative[i - 1] ?? 0);
14035
+ segments.push({ cells, feet: cells * this.feetPerCell });
14036
+ }
14037
+ return {
14038
+ anchorKey: this.anchorKey,
14039
+ waypoints: this.waypoints.map((p) => ({ ...p })),
14040
+ cursor: cursor ? { ...cursor } : null,
14041
+ segments,
14042
+ totalCells: total,
14043
+ totalFeet: total * this.feetPerCell,
14044
+ color: this.color,
14045
+ rangeBands: this.rangeBands
14046
+ };
14047
+ }
14048
+ commit(ctx) {
14049
+ if (this.waypoints.length < 2) {
14050
+ this.cancel(ctx);
14051
+ return;
14052
+ }
14053
+ const emission = this.buildEmission(null);
14054
+ this.reset();
14055
+ this.emitCommit(emission);
14056
+ this.emitClear();
14057
+ ctx.requestRender();
14058
+ }
14059
+ cancel(ctx) {
14060
+ if (!this.isOpen) return;
14061
+ this.reset();
14062
+ this.emitClear();
14063
+ ctx.requestRender();
14064
+ }
14065
+ reset() {
14066
+ this.waypoints = [];
14067
+ this.cursor = null;
14068
+ this.anchorKey = void 0;
14069
+ this.footprint = this.footprintOption;
14070
+ this.pointerDown = false;
14071
+ this.commitOnUp = false;
14072
+ }
14073
+ notifyOptionsChange() {
14074
+ for (const listener of this.optionListeners) listener();
14075
+ }
14076
+ scheduleEmission() {
14077
+ if (this.pathListeners.size === 0) return;
14078
+ if (this.emissionRafId !== null) return;
14079
+ this.emissionRafId = requestAnimationFrame(() => {
14080
+ this.emissionRafId = null;
14081
+ const emission = this.getEmission();
14082
+ if (!emission) return;
14083
+ this.emitPath(emission);
14084
+ });
14085
+ }
14086
+ emitClear() {
14087
+ if (this.emissionRafId !== null) {
14088
+ cancelAnimationFrame(this.emissionRafId);
14089
+ this.emissionRafId = null;
14090
+ }
14091
+ if (this.pathListeners.size === 0) return;
14092
+ this.emitPath(null);
14093
+ }
14094
+ emitPath(emission) {
14095
+ for (const listener of this.pathListeners) {
14096
+ try {
14097
+ listener(emission);
14098
+ } catch {
14099
+ }
14100
+ }
14101
+ }
14102
+ emitCommit(emission) {
14103
+ for (const listener of this.commitListeners) {
14104
+ try {
14105
+ listener(emission);
14106
+ } catch {
14107
+ }
14108
+ }
14109
+ }
14110
+ };
14111
+
13426
14112
  // src/tools/template-tool.ts
13427
14113
  var MIN_RECT_WIDTH = 20;
13428
14114
  function defaultRectWidth(radius, scaleUnit) {
@@ -13716,7 +14402,7 @@ var TemplateTool = class {
13716
14402
  };
13717
14403
 
13718
14404
  // src/tools/laser-tool.ts
13719
- var DEFAULT_COLOR5 = "#ff3b30";
14405
+ var DEFAULT_COLOR6 = "#ff3b30";
13720
14406
  var DEFAULT_WIDTH3 = 4;
13721
14407
  var DEFAULT_FADE_MS3 = 1200;
13722
14408
  var LaserTool = class {
@@ -13732,7 +14418,7 @@ var LaserTool = class {
13732
14418
  pendingEmission = [];
13733
14419
  constructor(options = {}) {
13734
14420
  this.name = options.name ?? "laser";
13735
- this.color = options.color ?? DEFAULT_COLOR5;
14421
+ this.color = options.color ?? DEFAULT_COLOR6;
13736
14422
  this.width = options.width ?? DEFAULT_WIDTH3;
13737
14423
  this.fadeMs = options.fadeMs ?? DEFAULT_FADE_MS3;
13738
14424
  }
@@ -13860,7 +14546,7 @@ var LaserTool = class {
13860
14546
  };
13861
14547
 
13862
14548
  // src/tools/ping-tool.ts
13863
- var DEFAULT_COLOR6 = "#ff3b30";
14549
+ var DEFAULT_COLOR7 = "#ff3b30";
13864
14550
  var DEFAULT_DURATION_MS4 = 1800;
13865
14551
  var DEFAULT_RADIUS4 = 48;
13866
14552
  var DEFAULT_MIN_INTERVAL_MS2 = 300;
@@ -13877,7 +14563,7 @@ var PingTool = class {
13877
14563
  pingListeners = /* @__PURE__ */ new Set();
13878
14564
  constructor(options = {}) {
13879
14565
  this.name = options.name ?? "ping";
13880
- this.color = options.color ?? DEFAULT_COLOR6;
14566
+ this.color = options.color ?? DEFAULT_COLOR7;
13881
14567
  this.durationMs = options.durationMs ?? DEFAULT_DURATION_MS4;
13882
14568
  this.radius = options.radius ?? DEFAULT_RADIUS4;
13883
14569
  this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS2;
@@ -13980,7 +14666,7 @@ var PingTool = class {
13980
14666
  };
13981
14667
 
13982
14668
  // src/index.ts
13983
- var VERSION = "0.63.0";
14669
+ var VERSION = "0.64.0";
13984
14670
  export {
13985
14671
  ArrowTool,
13986
14672
  AutoSave,
@@ -14006,13 +14692,17 @@ export {
14006
14692
  MemoryAdapter,
14007
14693
  MinimapController,
14008
14694
  NoteTool,
14695
+ PATH_PRESENCE_KIND,
14696
+ PATH_PRESENCE_MAX_POINTS,
14009
14697
  PING_PRESENCE_KIND,
14698
+ PathTool,
14010
14699
  PencilTool,
14011
14700
  PingInput,
14012
14701
  PingTool,
14013
14702
  RemoteFocusReceiver,
14014
14703
  RemoteLaserOverlay,
14015
14704
  RemoteMeasureOverlay,
14705
+ RemotePathOverlay,
14016
14706
  RemotePingOverlay,
14017
14707
  SelectTool,
14018
14708
  ShapeTool,
@@ -14040,6 +14730,7 @@ export {
14040
14730
  exportImage,
14041
14731
  exportSvg,
14042
14732
  fitZoomForView,
14733
+ footprintFromSize,
14043
14734
  getActiveFormats,
14044
14735
  getArrowBounds,
14045
14736
  getArrowControlPoint,
@@ -14055,20 +14746,26 @@ export {
14055
14746
  getHexCellsInRectangle,
14056
14747
  getHexCellsInSquare,
14057
14748
  getHexDistance,
14749
+ gridDistanceCells,
14058
14750
  isFocusPresence,
14059
14751
  isLaserTrailPresence,
14060
14752
  isMeasurePresence,
14061
14753
  isNearBezier,
14754
+ isPathPresence,
14062
14755
  isPingPresence,
14756
+ pathDistanceCells,
14063
14757
  resolveHtmlRouting,
14064
14758
  setFontSize,
14065
14759
  smartSnap,
14760
+ snapFootprintCenter,
14066
14761
  snapPoint,
14762
+ snapToCellCenter,
14067
14763
  snapToHexCenter,
14068
14764
  styleToPatch,
14069
14765
  toFocusPresence,
14070
14766
  toLaserTrailPresence,
14071
14767
  toMeasurePresence,
14768
+ toPathPresence,
14072
14769
  toPingPresence,
14073
14770
  toggleBold,
14074
14771
  toggleItalic,