@fieldnotes/core 0.63.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1943 -421
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +775 -17
- package/dist/index.d.ts +775 -17
- package/dist/index.js +1922 -421
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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
|
|
11179
|
+
var LingerOverlay = class {
|
|
11102
11180
|
host;
|
|
11103
|
-
|
|
11181
|
+
draw;
|
|
11182
|
+
clock;
|
|
11104
11183
|
holdMs;
|
|
11105
11184
|
fadeMs;
|
|
11106
11185
|
maxAgeMs;
|
|
11107
|
-
|
|
11186
|
+
entries = /* @__PURE__ */ new Map();
|
|
11108
11187
|
unregister;
|
|
11109
11188
|
rafId = null;
|
|
11110
|
-
|
|
11111
|
-
|
|
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.
|
|
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.
|
|
11202
|
+
this.unregister = host.registerOverlay((ctx) => this.render(ctx));
|
|
11118
11203
|
}
|
|
11119
11204
|
now() {
|
|
11120
|
-
return
|
|
11205
|
+
return this.clock();
|
|
11121
11206
|
}
|
|
11122
11207
|
/**
|
|
11123
|
-
*
|
|
11124
|
-
*
|
|
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
|
-
|
|
11128
|
-
if (this.
|
|
11129
|
-
|
|
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.
|
|
11136
|
-
|
|
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.
|
|
11218
|
+
expiryTimer: setTimeout(() => this.linger(sender), this.maxAgeMs)
|
|
11142
11219
|
});
|
|
11143
11220
|
this.host.requestRender();
|
|
11144
|
-
return true;
|
|
11145
11221
|
}
|
|
11146
|
-
/**
|
|
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.
|
|
11236
|
+
const entry = this.entries.get(sender);
|
|
11149
11237
|
if (!entry) return;
|
|
11150
11238
|
if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
|
|
11151
|
-
this.
|
|
11239
|
+
this.entries.delete(sender);
|
|
11152
11240
|
this.host.requestRender();
|
|
11153
11241
|
}
|
|
11154
|
-
/** Removes every
|
|
11242
|
+
/** Removes every entry immediately. */
|
|
11155
11243
|
clear() {
|
|
11156
|
-
if (this.
|
|
11157
|
-
for (const entry of this.
|
|
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.
|
|
11248
|
+
this.entries.clear();
|
|
11161
11249
|
this.host.requestRender();
|
|
11162
11250
|
}
|
|
11163
|
-
/** Number of senders with a visible (active or lingering)
|
|
11251
|
+
/** Number of senders with a visible (active or lingering) entry. */
|
|
11164
11252
|
get activeSenderCount() {
|
|
11165
|
-
return this.
|
|
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.
|
|
11170
|
-
this.
|
|
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.
|
|
11267
|
+
for (const entry of this.entries.values()) {
|
|
11176
11268
|
if (entry.expiryTimer != null) clearTimeout(entry.expiryTimer);
|
|
11177
11269
|
}
|
|
11178
|
-
this.
|
|
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.
|
|
11281
|
+
if (this.isDisposed) return;
|
|
11201
11282
|
const now = this.now();
|
|
11202
11283
|
let lingering = 0;
|
|
11203
|
-
for (const [sender, entry] of this.
|
|
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.
|
|
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
|
-
|
|
11215
|
-
if (this.
|
|
11295
|
+
render(ctx) {
|
|
11296
|
+
if (this.entries.size === 0) return;
|
|
11216
11297
|
const now = this.now();
|
|
11217
|
-
for (const entry of this.
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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.
|
|
13179
|
-
window.addEventListener("keyup", this.
|
|
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.
|
|
13186
|
-
window.removeEventListener("keyup", this.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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 ??
|
|
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.
|
|
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,
|