@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.cjs CHANGED
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ AWARENESS_MAX_SELECTION: () => AWARENESS_MAX_SELECTION,
24
+ AWARENESS_PRESENCE_KIND: () => AWARENESS_PRESENCE_KIND,
23
25
  ArrowTool: () => ArrowTool,
24
26
  AutoSave: () => AutoSave,
25
27
  Camera: () => Camera,
@@ -38,6 +40,7 @@ __export(index_exports, {
38
40
  LASER_TRAIL_PRESENCE_KIND: () => LASER_TRAIL_PRESENCE_KIND,
39
41
  LaserTool: () => LaserTool,
40
42
  LayerManager: () => LayerManager,
43
+ LocalAwareness: () => LocalAwareness,
41
44
  LocalStorageAdapter: () => LocalStorageAdapter,
42
45
  MEASURE_PRESENCE_KIND: () => MEASURE_PRESENCE_KIND,
43
46
  MeasureTool: () => MeasureTool,
@@ -46,16 +49,20 @@ __export(index_exports, {
46
49
  NoteTool: () => NoteTool,
47
50
  PATH_PRESENCE_KIND: () => PATH_PRESENCE_KIND,
48
51
  PATH_PRESENCE_MAX_POINTS: () => PATH_PRESENCE_MAX_POINTS,
52
+ PEER_COLORS: () => PEER_COLORS,
49
53
  PING_PRESENCE_KIND: () => PING_PRESENCE_KIND,
50
54
  PathTool: () => PathTool,
55
+ PeerRoster: () => PeerRoster,
51
56
  PencilTool: () => PencilTool,
52
57
  PingInput: () => PingInput,
53
58
  PingTool: () => PingTool,
59
+ RemoteCursorOverlay: () => RemoteCursorOverlay,
54
60
  RemoteFocusReceiver: () => RemoteFocusReceiver,
55
61
  RemoteLaserOverlay: () => RemoteLaserOverlay,
56
62
  RemoteMeasureOverlay: () => RemoteMeasureOverlay,
57
63
  RemotePathOverlay: () => RemotePathOverlay,
58
64
  RemotePingOverlay: () => RemotePingOverlay,
65
+ RemoteSelectionOverlay: () => RemoteSelectionOverlay,
59
66
  SelectTool: () => SelectTool,
60
67
  ShapeTool: () => ShapeTool,
61
68
  TemplateTool: () => TemplateTool,
@@ -64,6 +71,7 @@ __export(index_exports, {
64
71
  VERSION: () => VERSION,
65
72
  Viewport: () => Viewport,
66
73
  applyCameraView: () => applyCameraView,
74
+ attachAwareness: () => attachAwareness,
67
75
  boundsIntersect: () => boundsIntersect,
68
76
  cameraOriginForView: () => cameraOriginForView,
69
77
  captureCameraView: () => captureCameraView,
@@ -77,6 +85,7 @@ __export(index_exports, {
77
85
  createStroke: () => createStroke,
78
86
  createTemplate: () => createTemplate,
79
87
  createText: () => createText,
88
+ defaultPeerColor: () => defaultPeerColor,
80
89
  drawHexPath: () => drawHexPath,
81
90
  elementRectsEqual: () => elementRectsEqual,
82
91
  exportImage: () => exportImage,
@@ -99,6 +108,7 @@ __export(index_exports, {
99
108
  getHexCellsInSquare: () => getHexCellsInSquare,
100
109
  getHexDistance: () => getHexDistance,
101
110
  gridDistanceCells: () => gridDistanceCells,
111
+ isAwarenessPresence: () => isAwarenessPresence,
102
112
  isFocusPresence: () => isFocusPresence,
103
113
  isLaserTrailPresence: () => isLaserTrailPresence,
104
114
  isMeasurePresence: () => isMeasurePresence,
@@ -12228,6 +12238,800 @@ var RemoteFocusReceiver = class {
12228
12238
  }
12229
12239
  };
12230
12240
 
12241
+ // src/canvas/awareness-presence.ts
12242
+ var AWARENESS_PRESENCE_KIND = "awareness";
12243
+ var AWARENESS_MAX_SELECTION = 256;
12244
+ var MAX_ID_LENGTH = 128;
12245
+ var MAX_NAME_LENGTH = 64;
12246
+ var MAX_COLOR_LENGTH2 = 64;
12247
+ var MAX_ROLE_LENGTH = 32;
12248
+ var MAX_TOOL_LENGTH = 64;
12249
+ function isBoundedString(value, max) {
12250
+ return typeof value === "string" && value.length <= max;
12251
+ }
12252
+ function isOptionalBoundedString(value, max) {
12253
+ return value === void 0 || isBoundedString(value, max);
12254
+ }
12255
+ function isFinitePoint4(value) {
12256
+ if (typeof value !== "object" || value === null) return false;
12257
+ const point = value;
12258
+ return typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y);
12259
+ }
12260
+ function isAwarenessPresence(data) {
12261
+ if (typeof data !== "object" || data === null) return false;
12262
+ const payload = data;
12263
+ if (payload.kind !== AWARENESS_PRESENCE_KIND) return false;
12264
+ if (!isBoundedString(payload.id, MAX_ID_LENGTH) || payload.id.length === 0) return false;
12265
+ if ("cleared" in payload) return payload.cleared === true;
12266
+ if (!isOptionalBoundedString(payload.name, MAX_NAME_LENGTH)) return false;
12267
+ if (!isOptionalBoundedString(payload.color, MAX_COLOR_LENGTH2)) return false;
12268
+ if (!isOptionalBoundedString(payload.role, MAX_ROLE_LENGTH)) return false;
12269
+ if (!isOptionalBoundedString(payload.tool, MAX_TOOL_LENGTH)) return false;
12270
+ if (payload.cursor !== void 0 && !isFinitePoint4(payload.cursor)) return false;
12271
+ if (payload.selection !== void 0) {
12272
+ if (!Array.isArray(payload.selection)) return false;
12273
+ if (payload.selection.length > AWARENESS_MAX_SELECTION) return false;
12274
+ for (const id of payload.selection) {
12275
+ if (!isBoundedString(id, MAX_ID_LENGTH) || id.length === 0) return false;
12276
+ }
12277
+ }
12278
+ return true;
12279
+ }
12280
+
12281
+ // src/canvas/awareness-roster.ts
12282
+ var DEFAULT_STALE_MS = 45e3;
12283
+ var EMPTY_PEERS = Object.freeze([]);
12284
+ var EMPTY_SELECTION = Object.freeze([]);
12285
+ function sameSelection(a, b) {
12286
+ if (a.length !== b.length) return false;
12287
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
12288
+ return true;
12289
+ }
12290
+ function samePoint2(a, b) {
12291
+ if (a === null || b === null) return a === b;
12292
+ return a.x === b.x && a.y === b.y;
12293
+ }
12294
+ function toPeer(from, data, prev) {
12295
+ const cursor = data.cursor ? { x: data.cursor.x, y: data.cursor.y } : null;
12296
+ const incoming = data.selection ?? EMPTY_SELECTION;
12297
+ const selection = prev && sameSelection(prev.selection, incoming) ? prev.selection : incoming.length === 0 ? EMPTY_SELECTION : Object.freeze([...incoming]);
12298
+ const tool = data.tool ?? null;
12299
+ if (prev && prev.id === data.id && prev.name === data.name && prev.color === data.color && prev.role === data.role && prev.tool === tool && prev.selection === selection && samePoint2(prev.cursor, cursor)) {
12300
+ return prev;
12301
+ }
12302
+ const peer = {
12303
+ from,
12304
+ id: data.id,
12305
+ ...data.name === void 0 ? {} : { name: data.name },
12306
+ ...data.color === void 0 ? {} : { color: data.color },
12307
+ ...data.role === void 0 ? {} : { role: data.role },
12308
+ cursor,
12309
+ selection,
12310
+ tool
12311
+ };
12312
+ return peer;
12313
+ }
12314
+ var PeerRoster = class {
12315
+ staleMs;
12316
+ now;
12317
+ rows = /* @__PURE__ */ new Map();
12318
+ discovered = /* @__PURE__ */ new Map();
12319
+ changeListeners = /* @__PURE__ */ new Set();
12320
+ discoverListeners = /* @__PURE__ */ new Set();
12321
+ leaveListeners = /* @__PURE__ */ new Set();
12322
+ snapshot = EMPTY_PEERS;
12323
+ snapshotDirty = false;
12324
+ staleTimer = null;
12325
+ isDisposed = false;
12326
+ constructor(options = {}) {
12327
+ this.staleMs = options.staleMs ?? DEFAULT_STALE_MS;
12328
+ this.now = options.now ?? (() => Date.now());
12329
+ }
12330
+ get disposed() {
12331
+ return this.isDisposed;
12332
+ }
12333
+ /**
12334
+ * Applies a presence payload from `from`. Non-awareness or malformed payloads
12335
+ * return `false` untouched, so hosts can feed every presence frame through.
12336
+ */
12337
+ apply(from, data) {
12338
+ if (this.isDisposed || !isAwarenessPresence(data)) return false;
12339
+ const isNew = !this.discovered.has(from);
12340
+ this.discovered.set(from, this.now());
12341
+ if ("cleared" in data) {
12342
+ this.dropRow(from, "cleared");
12343
+ } else {
12344
+ const prev = this.rows.get(from);
12345
+ const next = toPeer(from, data, prev);
12346
+ if (next !== prev) {
12347
+ this.rows.set(from, next);
12348
+ this.changed();
12349
+ }
12350
+ }
12351
+ this.armStaleTimer();
12352
+ if (isNew) this.emit(this.discoverListeners, (l) => l(from));
12353
+ return true;
12354
+ }
12355
+ /** Server-authored presence-leave: drops the row AND the discovery entry. */
12356
+ remove(from) {
12357
+ if (this.isDisposed) return;
12358
+ const hadEntry = this.discovered.delete(from);
12359
+ this.dropRow(from, "left");
12360
+ if (hadEntry) this.armStaleTimer();
12361
+ }
12362
+ getPeers() {
12363
+ if (this.snapshotDirty) {
12364
+ this.snapshot = this.rows.size === 0 ? EMPTY_PEERS : Object.freeze([...this.rows.values()]);
12365
+ this.snapshotDirty = false;
12366
+ }
12367
+ return this.snapshot;
12368
+ }
12369
+ getPeer(from) {
12370
+ return this.rows.get(from);
12371
+ }
12372
+ /** Fires only when `getPeers()` would return a new reference. */
12373
+ onChange(listener) {
12374
+ this.changeListeners.add(listener);
12375
+ return () => this.changeListeners.delete(listener);
12376
+ }
12377
+ /** First valid frame from a sender since its discovery entry was last dropped. */
12378
+ onDiscover(listener) {
12379
+ this.discoverListeners.add(listener);
12380
+ return () => this.discoverListeners.delete(listener);
12381
+ }
12382
+ onLeave(listener) {
12383
+ this.leaveListeners.add(listener);
12384
+ return () => this.leaveListeners.delete(listener);
12385
+ }
12386
+ dispose() {
12387
+ if (this.isDisposed) return;
12388
+ this.isDisposed = true;
12389
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12390
+ this.staleTimer = null;
12391
+ this.rows.clear();
12392
+ this.discovered.clear();
12393
+ this.snapshot = EMPTY_PEERS;
12394
+ this.snapshotDirty = false;
12395
+ this.changeListeners.clear();
12396
+ this.discoverListeners.clear();
12397
+ this.leaveListeners.clear();
12398
+ }
12399
+ dropRow(from, reason) {
12400
+ const row = this.rows.get(from);
12401
+ if (!row) return;
12402
+ this.rows.delete(from);
12403
+ this.changed();
12404
+ this.emit(this.leaveListeners, (l) => l(row, reason));
12405
+ }
12406
+ changed() {
12407
+ this.snapshotDirty = true;
12408
+ this.emit(this.changeListeners, (l) => l());
12409
+ }
12410
+ emit(listeners, call) {
12411
+ for (const listener of [...listeners]) {
12412
+ try {
12413
+ call(listener);
12414
+ } catch {
12415
+ }
12416
+ }
12417
+ }
12418
+ armStaleTimer() {
12419
+ if (this.staleTimer !== null) clearTimeout(this.staleTimer);
12420
+ this.staleTimer = null;
12421
+ if (!Number.isFinite(this.staleMs) || this.staleMs <= 0 || this.isDisposed || this.discovered.size === 0) {
12422
+ return;
12423
+ }
12424
+ let earliest = Infinity;
12425
+ for (const seen of this.discovered.values()) if (seen < earliest) earliest = seen;
12426
+ const delay = Math.min(Math.max(0, earliest + this.staleMs - this.now()), 2 ** 31 - 1);
12427
+ this.staleTimer = setTimeout(() => {
12428
+ this.staleTimer = null;
12429
+ this.expireStale();
12430
+ }, delay);
12431
+ }
12432
+ expireStale() {
12433
+ const t = this.now();
12434
+ for (const from of [...this.discovered.keys()]) {
12435
+ const seen = this.discovered.get(from);
12436
+ if (seen === void 0 || t - seen < this.staleMs) continue;
12437
+ this.discovered.delete(from);
12438
+ this.dropRow(from, "stale");
12439
+ }
12440
+ this.armStaleTimer();
12441
+ }
12442
+ };
12443
+
12444
+ // src/canvas/awareness-publisher.ts
12445
+ var DEFAULT_FIELDS = Object.freeze({
12446
+ cursor: true,
12447
+ selection: false,
12448
+ tool: true
12449
+ });
12450
+ var DEFAULT_INTERVAL_MS = 50;
12451
+ var DEFAULT_HEARTBEAT_MS = 15e3;
12452
+ var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
12453
+ var MAX_IDENTITY_ID_LENGTH = 128;
12454
+ var MAX_IDENTITY_NAME_LENGTH = 64;
12455
+ var MAX_IDENTITY_COLOR_LENGTH = 64;
12456
+ var MAX_IDENTITY_ROLE_LENGTH = 32;
12457
+ var MAX_TOOL_LENGTH2 = 64;
12458
+ var MAX_SELECTION_ID_LENGTH = 128;
12459
+ function normalizeIntervalMs(value) {
12460
+ return Number.isFinite(value) && value >= 0 ? value : 0;
12461
+ }
12462
+ function normalizeHeartbeatMs(value) {
12463
+ return Number.isFinite(value) && value > 0 ? value : 0;
12464
+ }
12465
+ var LocalAwareness = class {
12466
+ host;
12467
+ element;
12468
+ send;
12469
+ selectionFilter;
12470
+ onError;
12471
+ intervalMs;
12472
+ heartbeatMs;
12473
+ identity;
12474
+ fields;
12475
+ lastPointer = null;
12476
+ selection = [];
12477
+ selectionFailed = false;
12478
+ tool;
12479
+ dirty = false;
12480
+ lastSentAt = null;
12481
+ throttleTimer = null;
12482
+ heartbeatTimer = null;
12483
+ unsubscribers = [];
12484
+ isDisposed = false;
12485
+ handlePointerMove = (e) => this.onPointerMove(e);
12486
+ handlePointerEnd = (e) => this.onPointerEnd(e);
12487
+ constructor(host, options) {
12488
+ const element = options.element ?? host.domLayer.parentElement;
12489
+ if (!element) throw new Error("LocalAwareness: the viewport wrapper is not mounted");
12490
+ this.host = host;
12491
+ this.element = element;
12492
+ this.send = options.send;
12493
+ this.selectionFilter = options.selectionFilter;
12494
+ this.onError = options.onError;
12495
+ this.intervalMs = normalizeIntervalMs(options.intervalMs ?? DEFAULT_INTERVAL_MS);
12496
+ this.heartbeatMs = normalizeHeartbeatMs(options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
12497
+ this.identity = this.normalizeIdentity(options.identity);
12498
+ this.fields = mergeFields(DEFAULT_FIELDS, options.fields ?? {});
12499
+ this.tool = host.toolManager.activeTool?.name ?? null;
12500
+ if (this.fields.selection) this.refreshSelection();
12501
+ const opts = { passive: true };
12502
+ element.addEventListener("pointermove", this.handlePointerMove, opts);
12503
+ element.addEventListener("pointerleave", this.handlePointerEnd, opts);
12504
+ element.addEventListener("pointercancel", this.handlePointerEnd, opts);
12505
+ this.unsubscribers.push(
12506
+ host.onSelectionChange(() => {
12507
+ if (this.fields.selection) this.schedule();
12508
+ }),
12509
+ host.toolManager.onChange((name) => {
12510
+ this.tool = name;
12511
+ if (this.fields.tool) this.schedule();
12512
+ })
12513
+ );
12514
+ this.armHeartbeat();
12515
+ }
12516
+ get disposed() {
12517
+ return this.isDisposed;
12518
+ }
12519
+ getFields() {
12520
+ return this.fields;
12521
+ }
12522
+ setIdentity(identity) {
12523
+ this.identity = this.normalizeIdentity(identity);
12524
+ this.schedule();
12525
+ }
12526
+ /** Merges the given flags into the current policy; `undefined` keys are ignored. */
12527
+ setFields(fields) {
12528
+ this.fields = mergeFields(this.fields, fields);
12529
+ this.schedule();
12530
+ }
12531
+ /**
12532
+ * Requests a full frame: immediate when idle, otherwise folded into the
12533
+ * pending trailing frame (so N simultaneous requests cost one frame). Hosts
12534
+ * call it when the connection becomes live or reconnects.
12535
+ */
12536
+ announce() {
12537
+ this.schedule();
12538
+ }
12539
+ /**
12540
+ * The complete state a frame carries right now. Side-effecting when
12541
+ * selection publishing is on: re-reads `getSelectedIds()`, re-runs
12542
+ * `selectionFilter`, updates the fail-closed selection state, and may call
12543
+ * `onError`. A no-op with respect to selection while publishing is off.
12544
+ */
12545
+ getState() {
12546
+ const frame = { kind: AWARENESS_PRESENCE_KIND, id: this.identity.id };
12547
+ if (this.identity.name !== void 0) frame.name = this.identity.name;
12548
+ if (this.identity.color !== void 0) frame.color = this.identity.color;
12549
+ if (this.identity.role !== void 0) frame.role = this.identity.role;
12550
+ if (this.fields.cursor && this.lastPointer !== null) {
12551
+ frame.cursor = { x: this.lastPointer.x, y: this.lastPointer.y };
12552
+ }
12553
+ if (this.fields.selection) {
12554
+ this.refreshSelection();
12555
+ if (!this.selectionFailed && this.selection.length > 0) {
12556
+ frame.selection = [...this.selection];
12557
+ }
12558
+ }
12559
+ if (this.fields.tool && this.tool !== null && this.tool.length <= MAX_TOOL_LENGTH2) {
12560
+ frame.tool = this.tool;
12561
+ }
12562
+ return frame;
12563
+ }
12564
+ dispose() {
12565
+ if (this.isDisposed) return;
12566
+ this.isDisposed = true;
12567
+ if (this.throttleTimer !== null) clearTimeout(this.throttleTimer);
12568
+ this.throttleTimer = null;
12569
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12570
+ this.heartbeatTimer = null;
12571
+ this.element.removeEventListener("pointermove", this.handlePointerMove);
12572
+ this.element.removeEventListener("pointerleave", this.handlePointerEnd);
12573
+ this.element.removeEventListener("pointercancel", this.handlePointerEnd);
12574
+ for (const unsub of this.unsubscribers) unsub();
12575
+ this.unsubscribers.length = 0;
12576
+ this.safeSend({ kind: AWARENESS_PRESENCE_KIND, id: this.identity.id, cleared: true });
12577
+ }
12578
+ now() {
12579
+ return Date.now();
12580
+ }
12581
+ onPointerMove(e) {
12582
+ if (!e.isPrimary) return;
12583
+ const rect = this.element.getBoundingClientRect();
12584
+ const world = this.host.camera.screenToWorld({
12585
+ x: e.clientX - rect.left,
12586
+ y: e.clientY - rect.top
12587
+ });
12588
+ this.lastPointer = Number.isFinite(world.x) && Number.isFinite(world.y) ? { x: world.x, y: world.y } : null;
12589
+ if (this.fields.cursor) this.schedule();
12590
+ }
12591
+ onPointerEnd(e) {
12592
+ if (!e.isPrimary || this.lastPointer === null) return;
12593
+ this.lastPointer = null;
12594
+ if (this.fields.cursor) this.schedule();
12595
+ }
12596
+ refreshSelection() {
12597
+ try {
12598
+ const raw = this.host.getSelectedIds();
12599
+ const ids = this.selectionFilter ? this.selectionFilter(raw) : raw;
12600
+ if (!Array.isArray(ids)) throw new TypeError("selectionFilter must return an array");
12601
+ for (const id of ids) {
12602
+ if (typeof id !== "string") throw new TypeError("selectionFilter must return strings");
12603
+ if (id.length === 0 || id.length > MAX_SELECTION_ID_LENGTH) {
12604
+ throw new TypeError("selectionFilter must return ids of 1..128 characters");
12605
+ }
12606
+ }
12607
+ this.selection = ids.slice(0, AWARENESS_MAX_SELECTION);
12608
+ this.selectionFailed = false;
12609
+ } catch (error) {
12610
+ this.selection = [];
12611
+ this.selectionFailed = true;
12612
+ this.report(error);
12613
+ }
12614
+ }
12615
+ schedule() {
12616
+ if (this.isDisposed) return;
12617
+ this.dirty = true;
12618
+ if (this.throttleTimer !== null) return;
12619
+ const elapsed = this.lastSentAt === null ? Infinity : this.now() - this.lastSentAt;
12620
+ if (elapsed >= this.intervalMs) {
12621
+ this.flush();
12622
+ return;
12623
+ }
12624
+ this.throttleTimer = setTimeout(
12625
+ () => {
12626
+ this.throttleTimer = null;
12627
+ if (this.dirty) this.flush();
12628
+ },
12629
+ Math.min(this.intervalMs - elapsed, MAX_TIMER_DELAY_MS)
12630
+ );
12631
+ }
12632
+ flush() {
12633
+ this.dirty = false;
12634
+ this.lastSentAt = this.now();
12635
+ this.safeSend(this.getState());
12636
+ this.armHeartbeat();
12637
+ }
12638
+ armHeartbeat() {
12639
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer);
12640
+ this.heartbeatTimer = null;
12641
+ if (this.heartbeatMs <= 0 || this.isDisposed) return;
12642
+ this.heartbeatTimer = setTimeout(
12643
+ () => {
12644
+ this.heartbeatTimer = null;
12645
+ this.flush();
12646
+ },
12647
+ Math.min(this.heartbeatMs, MAX_TIMER_DELAY_MS)
12648
+ );
12649
+ }
12650
+ safeSend(frame) {
12651
+ try {
12652
+ this.send(frame);
12653
+ } catch (error) {
12654
+ this.report(error);
12655
+ }
12656
+ }
12657
+ report(error) {
12658
+ try {
12659
+ this.onError?.(error);
12660
+ } catch {
12661
+ }
12662
+ }
12663
+ /**
12664
+ * Truncates identity strings to the wire caps and rejects an invalid id, so a
12665
+ * sender can never publish a frame that the wire guard would drop outright.
12666
+ * A truncated field is reported through `onError` (a `RangeError`) rather
12667
+ * than silently shortened, so a caller passing an over-long name finds out.
12668
+ */
12669
+ normalizeIdentity(identity) {
12670
+ if (identity.id.length === 0 || identity.id.length > MAX_IDENTITY_ID_LENGTH) {
12671
+ throw new RangeError("LocalAwareness: identity.id must be 1..128 characters");
12672
+ }
12673
+ const normalized = {
12674
+ id: identity.id
12675
+ };
12676
+ if (identity.name !== void 0) {
12677
+ normalized.name = identity.name.slice(0, MAX_IDENTITY_NAME_LENGTH);
12678
+ if (identity.name.length > MAX_IDENTITY_NAME_LENGTH) {
12679
+ this.report(
12680
+ new RangeError(
12681
+ `LocalAwareness: identity.name truncated to ${MAX_IDENTITY_NAME_LENGTH} characters`
12682
+ )
12683
+ );
12684
+ }
12685
+ }
12686
+ if (identity.color !== void 0) {
12687
+ normalized.color = identity.color.slice(0, MAX_IDENTITY_COLOR_LENGTH);
12688
+ if (identity.color.length > MAX_IDENTITY_COLOR_LENGTH) {
12689
+ this.report(
12690
+ new RangeError(
12691
+ `LocalAwareness: identity.color truncated to ${MAX_IDENTITY_COLOR_LENGTH} characters`
12692
+ )
12693
+ );
12694
+ }
12695
+ }
12696
+ if (identity.role !== void 0) {
12697
+ normalized.role = identity.role.slice(0, MAX_IDENTITY_ROLE_LENGTH);
12698
+ if (identity.role.length > MAX_IDENTITY_ROLE_LENGTH) {
12699
+ this.report(
12700
+ new RangeError(
12701
+ `LocalAwareness: identity.role truncated to ${MAX_IDENTITY_ROLE_LENGTH} characters`
12702
+ )
12703
+ );
12704
+ }
12705
+ }
12706
+ return normalized;
12707
+ }
12708
+ };
12709
+ function mergeFields(current, patch) {
12710
+ return Object.freeze({
12711
+ cursor: patch.cursor ?? current.cursor,
12712
+ selection: patch.selection ?? current.selection,
12713
+ tool: patch.tool ?? current.tool
12714
+ });
12715
+ }
12716
+
12717
+ // src/canvas/remote-cursor-overlay.ts
12718
+ var PEER_COLORS = Object.freeze([
12719
+ "#e11d48",
12720
+ "#ea580c",
12721
+ "#ca8a04",
12722
+ "#16a34a",
12723
+ "#0d9488",
12724
+ "#0284c7",
12725
+ "#2563eb",
12726
+ "#7c3aed",
12727
+ "#c026d3",
12728
+ "#db2777",
12729
+ "#4d7c0f",
12730
+ "#b45309"
12731
+ ]);
12732
+ function defaultPeerColor(seed) {
12733
+ if (seed.length === 0) return PEER_COLORS[0] ?? "#2563eb";
12734
+ let hash = 2166136261;
12735
+ for (let i = 0; i < seed.length; i++) {
12736
+ hash ^= seed.charCodeAt(i);
12737
+ hash = Math.imul(hash, 16777619) >>> 0;
12738
+ }
12739
+ return PEER_COLORS[hash % PEER_COLORS.length] ?? "#2563eb";
12740
+ }
12741
+ var DEFAULT_LABEL_FONT = "12px sans-serif";
12742
+ var LABEL_PAD_X = 6;
12743
+ var LABEL_PAD_Y = 3;
12744
+ var LABEL_HEIGHT = 16;
12745
+ var LABEL_OFFSET = 14;
12746
+ var MAX_LABEL_WIDTH_CACHE = 64;
12747
+ var RemoteCursorOverlay = class {
12748
+ host;
12749
+ roster;
12750
+ colorFor;
12751
+ showLabels;
12752
+ labelFont;
12753
+ labelWidths = /* @__PURE__ */ new Map();
12754
+ unregister;
12755
+ unsubscribe;
12756
+ isDisposed = false;
12757
+ constructor(host, roster, options = {}) {
12758
+ this.host = host;
12759
+ this.roster = roster;
12760
+ this.colorFor = options.colorFor;
12761
+ this.showLabels = options.showLabels ?? true;
12762
+ this.labelFont = options.labelFont ?? DEFAULT_LABEL_FONT;
12763
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12764
+ this.unsubscribe = roster.onChange(() => {
12765
+ if (this.labelWidths.size > MAX_LABEL_WIDTH_CACHE) this.labelWidths.clear();
12766
+ host.requestRender();
12767
+ });
12768
+ }
12769
+ get disposed() {
12770
+ return this.isDisposed;
12771
+ }
12772
+ resolveColor(peer) {
12773
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12774
+ }
12775
+ dispose() {
12776
+ if (this.isDisposed) return;
12777
+ this.isDisposed = true;
12778
+ this.unsubscribe?.();
12779
+ this.unsubscribe = null;
12780
+ this.unregister?.();
12781
+ this.unregister = null;
12782
+ this.labelWidths.clear();
12783
+ this.host.requestRender();
12784
+ }
12785
+ render(ctx) {
12786
+ if (this.isDisposed) return;
12787
+ const zoom = this.host.camera.zoom;
12788
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12789
+ for (const peer of this.roster.getPeers()) {
12790
+ if (peer.cursor === null) continue;
12791
+ const color = this.resolveColor(peer);
12792
+ ctx.save();
12793
+ ctx.translate(peer.cursor.x, peer.cursor.y);
12794
+ ctx.scale(inv, inv);
12795
+ ctx.beginPath();
12796
+ ctx.moveTo(0, 0);
12797
+ ctx.lineTo(0, 16);
12798
+ ctx.lineTo(4.5, 12.5);
12799
+ ctx.lineTo(11, 12.5);
12800
+ ctx.closePath();
12801
+ ctx.fillStyle = color;
12802
+ ctx.fill();
12803
+ ctx.strokeStyle = "#ffffff";
12804
+ ctx.lineWidth = 1;
12805
+ ctx.stroke();
12806
+ if (this.showLabels && peer.name !== void 0 && peer.name.length > 0) {
12807
+ this.drawLabel(ctx, peer.name, color);
12808
+ }
12809
+ ctx.restore();
12810
+ }
12811
+ }
12812
+ drawLabel(ctx, name, color) {
12813
+ ctx.font = this.labelFont;
12814
+ const key = `${this.labelFont} ${name}`;
12815
+ let width = this.labelWidths.get(key);
12816
+ if (width === void 0) {
12817
+ width = ctx.measureText(name).width;
12818
+ this.labelWidths.set(key, width);
12819
+ }
12820
+ const w = width + LABEL_PAD_X * 2;
12821
+ ctx.fillStyle = color;
12822
+ ctx.beginPath();
12823
+ ctx.roundRect(LABEL_OFFSET, LABEL_OFFSET, w, LABEL_HEIGHT + LABEL_PAD_Y, 4);
12824
+ ctx.fill();
12825
+ ctx.fillStyle = "#ffffff";
12826
+ ctx.textAlign = "left";
12827
+ ctx.textBaseline = "middle";
12828
+ ctx.fillText(name, LABEL_OFFSET + LABEL_PAD_X, LABEL_OFFSET + (LABEL_HEIGHT + LABEL_PAD_Y) / 2);
12829
+ }
12830
+ };
12831
+
12832
+ // src/canvas/remote-selection-overlay.ts
12833
+ var DEFAULT_ALPHA = 0.6;
12834
+ var DEFAULT_LINE_WIDTH_PX = 2;
12835
+ var RemoteSelectionOverlay = class {
12836
+ host;
12837
+ roster;
12838
+ colorFor;
12839
+ alpha;
12840
+ lineWidthPx;
12841
+ signatures = [];
12842
+ outlines = [];
12843
+ storeDirty = true;
12844
+ unregister;
12845
+ unsubscribers = [];
12846
+ isDisposed = false;
12847
+ constructor(host, roster, options = {}) {
12848
+ this.host = host;
12849
+ this.roster = roster;
12850
+ this.colorFor = options.colorFor;
12851
+ this.alpha = options.alpha ?? DEFAULT_ALPHA;
12852
+ this.lineWidthPx = options.lineWidthPx ?? DEFAULT_LINE_WIDTH_PX;
12853
+ this.unregister = host.registerOverlay((ctx) => this.render(ctx));
12854
+ const invalidate = () => {
12855
+ this.storeDirty = true;
12856
+ host.requestRender();
12857
+ };
12858
+ this.unsubscribers.push(
12859
+ roster.onChange(() => host.requestRender()),
12860
+ host.store.onChange(invalidate),
12861
+ host.layerManager.on("change", invalidate)
12862
+ );
12863
+ }
12864
+ get disposed() {
12865
+ return this.isDisposed;
12866
+ }
12867
+ dispose() {
12868
+ if (this.isDisposed) return;
12869
+ this.isDisposed = true;
12870
+ for (const unsub of this.unsubscribers) unsub();
12871
+ this.unsubscribers.length = 0;
12872
+ this.unregister?.();
12873
+ this.unregister = null;
12874
+ this.signatures = [];
12875
+ this.outlines = [];
12876
+ this.host.requestRender();
12877
+ }
12878
+ resolveColor(peer) {
12879
+ return this.colorFor?.(peer) ?? peer.color ?? defaultPeerColor(peer.id);
12880
+ }
12881
+ /** Recomputes outlines only when the selection signature or the store/layers changed. */
12882
+ rebuild() {
12883
+ const peers = this.roster.getPeers();
12884
+ const next = [];
12885
+ for (const peer of peers) {
12886
+ if (peer.selection.length === 0) continue;
12887
+ next.push({ from: peer.from, selection: peer.selection, color: this.resolveColor(peer) });
12888
+ }
12889
+ let changed = this.storeDirty || next.length !== this.signatures.length;
12890
+ if (!changed) {
12891
+ for (let i = 0; i < next.length; i++) {
12892
+ const a = next[i];
12893
+ const b = this.signatures[i];
12894
+ if (!a || !b || a.from !== b.from || a.selection !== b.selection || a.color !== b.color) {
12895
+ changed = true;
12896
+ break;
12897
+ }
12898
+ }
12899
+ }
12900
+ if (!changed) return;
12901
+ this.storeDirty = false;
12902
+ this.signatures = next;
12903
+ if (next.length === 0) {
12904
+ this.outlines = [];
12905
+ return;
12906
+ }
12907
+ const colorById = /* @__PURE__ */ new Map();
12908
+ for (const sig of next) {
12909
+ for (const id of sig.selection) if (!colorById.has(id)) colorById.set(id, sig.color);
12910
+ }
12911
+ const layers = this.host.layerManager;
12912
+ const rects = computeElementRects(
12913
+ this.host.store,
12914
+ (element) => colorById.has(element.id) && layers.isLayerVisible(element.layerId) ? element.id : null
12915
+ );
12916
+ this.outlines = rects.map((rect) => ({ rect, color: colorById.get(rect.id) ?? "#2563eb" }));
12917
+ }
12918
+ render(ctx) {
12919
+ if (this.isDisposed) return;
12920
+ this.rebuild();
12921
+ if (this.outlines.length === 0) return;
12922
+ const zoom = this.host.camera.zoom;
12923
+ const inv = zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
12924
+ ctx.save();
12925
+ ctx.globalAlpha = this.alpha;
12926
+ ctx.lineWidth = this.lineWidthPx * inv;
12927
+ for (const { rect, color } of this.outlines) {
12928
+ ctx.save();
12929
+ ctx.strokeStyle = color;
12930
+ ctx.translate(rect.x + rect.w / 2, rect.y + rect.h / 2);
12931
+ if (rect.rotation !== 0) ctx.rotate(rect.rotation);
12932
+ ctx.strokeRect(-rect.w / 2, -rect.h / 2, rect.w, rect.h);
12933
+ ctx.restore();
12934
+ }
12935
+ ctx.restore();
12936
+ }
12937
+ };
12938
+
12939
+ // src/canvas/attach-awareness.ts
12940
+ function attachAwareness(viewport, channel, options) {
12941
+ const {
12942
+ roster: rosterOptions,
12943
+ cursors: cursorOptions,
12944
+ selections: selectionOptions,
12945
+ publish,
12946
+ ...localOptions
12947
+ } = options;
12948
+ const roster = new PeerRoster(rosterOptions);
12949
+ let local = null;
12950
+ let cursors = null;
12951
+ let selections = null;
12952
+ const unsubscribers = [];
12953
+ try {
12954
+ local = publish === false ? null : new LocalAwareness(viewport, {
12955
+ ...localOptions,
12956
+ send: (data) => channel.sendPresence(data)
12957
+ });
12958
+ cursors = cursorOptions === false ? null : new RemoteCursorOverlay(viewport, roster, cursorOptions ?? {});
12959
+ selections = selectionOptions === void 0 || selectionOptions === false ? null : new RemoteSelectionOverlay(
12960
+ viewport,
12961
+ roster,
12962
+ selectionOptions === true ? {} : selectionOptions
12963
+ );
12964
+ if (publish !== false) unsubscribers.push(roster.onDiscover(() => local?.announce()));
12965
+ unsubscribers.push(
12966
+ channel.onPresence((from, data) => {
12967
+ roster.apply(from, data);
12968
+ })
12969
+ );
12970
+ unsubscribers.push(channel.onPresenceLeave((from) => roster.remove(from)));
12971
+ } catch (error) {
12972
+ for (let i = unsubscribers.length - 1; i >= 0; i--) {
12973
+ try {
12974
+ unsubscribers[i]?.();
12975
+ } catch {
12976
+ }
12977
+ }
12978
+ unsubscribers.length = 0;
12979
+ try {
12980
+ selections?.dispose();
12981
+ } catch {
12982
+ }
12983
+ try {
12984
+ cursors?.dispose();
12985
+ } catch {
12986
+ }
12987
+ try {
12988
+ local?.dispose();
12989
+ } catch {
12990
+ }
12991
+ try {
12992
+ roster.dispose();
12993
+ } catch {
12994
+ }
12995
+ throw error;
12996
+ }
12997
+ let disposed = false;
12998
+ return {
12999
+ roster,
13000
+ local,
13001
+ cursors,
13002
+ selections,
13003
+ announce: () => local?.announce(),
13004
+ setFields: (fields) => local?.setFields(fields),
13005
+ dispose: () => {
13006
+ if (disposed) return;
13007
+ disposed = true;
13008
+ try {
13009
+ local?.dispose();
13010
+ } catch {
13011
+ }
13012
+ try {
13013
+ cursors?.dispose();
13014
+ } catch {
13015
+ }
13016
+ try {
13017
+ selections?.dispose();
13018
+ } catch {
13019
+ }
13020
+ try {
13021
+ roster.dispose();
13022
+ } catch {
13023
+ }
13024
+ for (const unsub of unsubscribers) {
13025
+ try {
13026
+ unsub();
13027
+ } catch {
13028
+ }
13029
+ }
13030
+ unsubscribers.length = 0;
13031
+ }
13032
+ };
13033
+ }
13034
+
12231
13035
  // src/tools/hand-tool.ts
12232
13036
  var HandTool = class {
12233
13037
  name = "hand";
@@ -13893,7 +14697,7 @@ var MeasureTool = class {
13893
14697
  // src/tools/path-tool.ts
13894
14698
  var EPS2 = 1e-6;
13895
14699
  var DEFAULT_COMMIT_TAP_RADIUS_PX = 12;
13896
- function samePoint2(a, b) {
14700
+ function samePoint3(a, b) {
13897
14701
  return Math.abs(a.x - b.x) < EPS2 && Math.abs(a.y - b.y) < EPS2;
13898
14702
  }
13899
14703
  var PathTool = class {
@@ -14037,7 +14841,7 @@ var PathTool = class {
14037
14841
  return;
14038
14842
  }
14039
14843
  const last = this.lastWaypoint();
14040
- if (this.cursor && last && !samePoint2(this.cursor, last)) {
14844
+ if (this.cursor && last && !samePoint3(this.cursor, last)) {
14041
14845
  this.waypoints.push({ ...this.cursor });
14042
14846
  }
14043
14847
  this.scheduleEmission();
@@ -14098,7 +14902,7 @@ var PathTool = class {
14098
14902
  * world point.
14099
14903
  */
14100
14904
  withinCommitRadius(point, last, ctx) {
14101
- if (samePoint2(point, last)) return true;
14905
+ if (samePoint3(point, last)) return true;
14102
14906
  if (this.hasSnappingGrid()) return false;
14103
14907
  const zoom = ctx.camera.zoom;
14104
14908
  if (!(zoom > 0)) return false;
@@ -14146,7 +14950,7 @@ var PathTool = class {
14146
14950
  measure(cursor) {
14147
14951
  const points = this.waypoints.map((p) => ({ ...p }));
14148
14952
  const last = points[points.length - 1];
14149
- if (cursor && (!last || !samePoint2(cursor, last))) points.push({ ...cursor });
14953
+ if (cursor && (!last || !samePoint3(cursor, last))) points.push({ ...cursor });
14150
14954
  const { total, cumulative } = pathDistanceCells(points, {
14151
14955
  gridSize: this.gridSize,
14152
14956
  gridType: this.gridType,
@@ -14794,9 +15598,11 @@ var PingTool = class {
14794
15598
  };
14795
15599
 
14796
15600
  // src/index.ts
14797
- var VERSION = "0.64.0";
15601
+ var VERSION = "0.65.0";
14798
15602
  // Annotate the CommonJS export names for ESM import in node:
14799
15603
  0 && (module.exports = {
15604
+ AWARENESS_MAX_SELECTION,
15605
+ AWARENESS_PRESENCE_KIND,
14800
15606
  ArrowTool,
14801
15607
  AutoSave,
14802
15608
  Camera,
@@ -14815,6 +15621,7 @@ var VERSION = "0.64.0";
14815
15621
  LASER_TRAIL_PRESENCE_KIND,
14816
15622
  LaserTool,
14817
15623
  LayerManager,
15624
+ LocalAwareness,
14818
15625
  LocalStorageAdapter,
14819
15626
  MEASURE_PRESENCE_KIND,
14820
15627
  MeasureTool,
@@ -14823,16 +15630,20 @@ var VERSION = "0.64.0";
14823
15630
  NoteTool,
14824
15631
  PATH_PRESENCE_KIND,
14825
15632
  PATH_PRESENCE_MAX_POINTS,
15633
+ PEER_COLORS,
14826
15634
  PING_PRESENCE_KIND,
14827
15635
  PathTool,
15636
+ PeerRoster,
14828
15637
  PencilTool,
14829
15638
  PingInput,
14830
15639
  PingTool,
15640
+ RemoteCursorOverlay,
14831
15641
  RemoteFocusReceiver,
14832
15642
  RemoteLaserOverlay,
14833
15643
  RemoteMeasureOverlay,
14834
15644
  RemotePathOverlay,
14835
15645
  RemotePingOverlay,
15646
+ RemoteSelectionOverlay,
14836
15647
  SelectTool,
14837
15648
  ShapeTool,
14838
15649
  TemplateTool,
@@ -14841,6 +15652,7 @@ var VERSION = "0.64.0";
14841
15652
  VERSION,
14842
15653
  Viewport,
14843
15654
  applyCameraView,
15655
+ attachAwareness,
14844
15656
  boundsIntersect,
14845
15657
  cameraOriginForView,
14846
15658
  captureCameraView,
@@ -14854,6 +15666,7 @@ var VERSION = "0.64.0";
14854
15666
  createStroke,
14855
15667
  createTemplate,
14856
15668
  createText,
15669
+ defaultPeerColor,
14857
15670
  drawHexPath,
14858
15671
  elementRectsEqual,
14859
15672
  exportImage,
@@ -14876,6 +15689,7 @@ var VERSION = "0.64.0";
14876
15689
  getHexCellsInSquare,
14877
15690
  getHexDistance,
14878
15691
  gridDistanceCells,
15692
+ isAwarenessPresence,
14879
15693
  isFocusPresence,
14880
15694
  isLaserTrailPresence,
14881
15695
  isMeasurePresence,