@fieldnotes/core 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12100,6 +12100,800 @@ var RemoteFocusReceiver = class {
12100
12100
  }
12101
12101
  };
12102
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;
12870
+ try {
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();
12884
+ } catch {
12885
+ }
12886
+ for (const unsub of unsubscribers) {
12887
+ try {
12888
+ unsub();
12889
+ } catch {
12890
+ }
12891
+ }
12892
+ unsubscribers.length = 0;
12893
+ }
12894
+ };
12895
+ }
12896
+
12103
12897
  // src/tools/hand-tool.ts
12104
12898
  var HandTool = class {
12105
12899
  name = "hand";
@@ -13765,7 +14559,7 @@ var MeasureTool = class {
13765
14559
  // src/tools/path-tool.ts
13766
14560
  var EPS2 = 1e-6;
13767
14561
  var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
13768
- function samePoint2(a, b) {
14562
+ function samePoint3(a, b) {
13769
14563
  return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
13770
14564
  }
13771
14565
  var PathTool = class {
@@ -13909,7 +14703,7 @@ var PathTool = class {
13909
14703
  return;
13910
14704
  }
13911
14705
  const last = this.lastWaypoint();
13912
- if (this.cursor && last && !samePoint2(this.cursor, last)) {
14706
+ if (this.cursor && last && !samePoint3(this.cursor, last)) {
13913
14707
  this.waypoints.push({ ...this.cursor });
13914
14708
  }
13915
14709
  this.scheduleEmission();
@@ -13970,7 +14764,7 @@ var PathTool = class {
13970
14764
  * world point.
13971
14765
  */
13972
14766
  withinCommitRadius(point, last, ctx) {
13973
- if (samePoint2(point, last)) return true;
14767
+ if (samePoint3(point, last)) return true;
13974
14768
  if (this.hasSnappingGrid()) return false;
13975
14769
  const zoom = ctx.camera.zoom;
13976
14770
  if (!(zoom > 0)) return false;
@@ -14018,7 +14812,7 @@ var PathTool = class {
14018
14812
  measure(cursor) {
14019
14813
  const points = this.waypoints.map((p) => ({ ...p }));
14020
14814
  const last = points[points.length - 1];
14021
- if (cursor && (!last || !samePoint2(cursor, last))) points.push({ ...cursor });
14815
+ if (cursor && (!last || !samePoint3(cursor, last))) points.push({ ...cursor });
14022
14816
  const { total, cumulative } = pathDistanceCells(points, {
14023
14817
  gridSize: this.gridSize,
14024
14818
  gridType: this.gridType,
@@ -14666,8 +15460,10 @@ var PingTool = class {
14666
15460
  };
14667
15461
 
14668
15462
  // src/index.ts
14669
- var VERSION = "0.64.0";
15463
+ var VERSION = "0.65.0";
14670
15464
  export {
15465
+ AWARENESS_MAX_SELECTION,
15466
+ AWARENESS_PRESENCE_KIND,
14671
15467
  ArrowTool,
14672
15468
  AutoSave,
14673
15469
  Camera,
@@ -14686,6 +15482,7 @@ export {
14686
15482
  LASER_TRAIL_PRESENCE_KIND,
14687
15483
  LaserTool,
14688
15484
  LayerManager,
15485
+ LocalAwareness,
14689
15486
  LocalStorageAdapter,
14690
15487
  MEASURE_PRESENCE_KIND,
14691
15488
  MeasureTool,
@@ -14694,16 +15491,20 @@ export {
14694
15491
  NoteTool,
14695
15492
  PATH_PRESENCE_KIND,
14696
15493
  PATH_PRESENCE_MAX_POINTS,
15494
+ PEER_COLORS,
14697
15495
  PING_PRESENCE_KIND,
14698
15496
  PathTool,
15497
+ PeerRoster,
14699
15498
  PencilTool,
14700
15499
  PingInput,
14701
15500
  PingTool,
15501
+ RemoteCursorOverlay,
14702
15502
  RemoteFocusReceiver,
14703
15503
  RemoteLaserOverlay,
14704
15504
  RemoteMeasureOverlay,
14705
15505
  RemotePathOverlay,
14706
15506
  RemotePingOverlay,
15507
+ RemoteSelectionOverlay,
14707
15508
  SelectTool,
14708
15509
  ShapeTool,
14709
15510
  TemplateTool,
@@ -14712,6 +15513,7 @@ export {
14712
15513
  VERSION,
14713
15514
  Viewport,
14714
15515
  applyCameraView,
15516
+ attachAwareness,
14715
15517
  boundsIntersect,
14716
15518
  cameraOriginForView,
14717
15519
  captureCameraView,
@@ -14725,6 +15527,7 @@ export {
14725
15527
  createStroke,
14726
15528
  createTemplate,
14727
15529
  createText,
15530
+ defaultPeerColor,
14728
15531
  drawHexPath,
14729
15532
  elementRectsEqual,
14730
15533
  exportImage,
@@ -14747,6 +15550,7 @@ export {
14747
15550
  getHexCellsInSquare,
14748
15551
  getHexDistance,
14749
15552
  gridDistanceCells,
15553
+ isAwarenessPresence,
14750
15554
  isFocusPresence,
14751
15555
  isLaserTrailPresence,
14752
15556
  isMeasurePresence,