@unhingged/vizu-core 0.1.1 → 0.1.3

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.
@@ -1,1033 +0,0 @@
1
- import {
2
- findByFingerprint
3
- } from "./chunk-OMIFOSQ2.js";
4
-
5
- // src/live-collab.ts
6
- import { createClient } from "@liveblocks/client";
7
-
8
- // src/cursor-colors.ts
9
- var CURSOR_COLORS = [
10
- "#4F46E5",
11
- // indigo
12
- "#15803D",
13
- // forest
14
- "#C2410C",
15
- // orange
16
- "#BE185D",
17
- // rose
18
- "#0E7490",
19
- // cyan
20
- "#7E22CE",
21
- // plum
22
- "#DC2626",
23
- // red
24
- "#0F766E"
25
- // teal
26
- ];
27
- function colorForUser(userId) {
28
- let h = 0;
29
- for (let i = 0; i < userId.length; i++) {
30
- h = h * 31 + userId.charCodeAt(i) | 0;
31
- }
32
- return CURSOR_COLORS[Math.abs(h) % CURSOR_COLORS.length];
33
- }
34
- function prefersReducedMotion() {
35
- if (typeof window === "undefined" || !window.matchMedia) return false;
36
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
37
- }
38
-
39
- // src/presence-stack.ts
40
- var Z_INDEX = 2147483640;
41
- var MAX_VISIBLE = 5;
42
- var AVATAR_SIZE = 28;
43
- var OVERLAP_PX = 6;
44
- var PresenceStack = class {
45
- constructor(opts) {
46
- this.container = null;
47
- this.avatars = /* @__PURE__ */ new Map();
48
- this.overflowEl = null;
49
- this.followingUserId = null;
50
- this.onAvatarClick = null;
51
- this.onAvatarClick = opts?.onAvatarClick ?? null;
52
- }
53
- mount() {
54
- if (typeof document === "undefined") return;
55
- if (this.container) return;
56
- this.container = document.createElement("div");
57
- this.container.setAttribute("data-vizu-presence-stack", "");
58
- this.container.style.cssText = `
59
- position: fixed;
60
- top: 16px;
61
- right: 16px;
62
- z-index: ${Z_INDEX};
63
- display: flex;
64
- align-items: center;
65
- pointer-events: auto;
66
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
67
- `;
68
- document.body.appendChild(this.container);
69
- }
70
- destroy() {
71
- for (const el of this.avatars.values()) el.remove();
72
- this.avatars.clear();
73
- this.overflowEl?.remove();
74
- this.overflowEl = null;
75
- this.container?.remove();
76
- this.container = null;
77
- }
78
- setOthers(others) {
79
- if (!this.container) return;
80
- const visible = others.slice(0, MAX_VISIBLE);
81
- const overflow = Math.max(0, others.length - visible.length);
82
- const nextIds = new Set(visible.map((u) => u.id));
83
- for (const [id, el] of Array.from(this.avatars)) {
84
- if (!nextIds.has(id)) {
85
- el.remove();
86
- this.avatars.delete(id);
87
- }
88
- }
89
- for (const user of visible) {
90
- let el = this.avatars.get(user.id);
91
- if (!el) {
92
- el = createAvatar(user, () => this.onAvatarClick?.(user.id));
93
- this.avatars.set(user.id, el);
94
- } else {
95
- updateAvatar(el, user);
96
- }
97
- paintAvatarFollowState(el, user, this.followingUserId === user.id);
98
- this.container.appendChild(el);
99
- }
100
- this.renderOverflow(overflow);
101
- }
102
- /**
103
- * Mark a user as the active followee so its avatar can render the
104
- * coral ring highlight. Pass null to clear.
105
- */
106
- setFollowing(userId) {
107
- this.followingUserId = userId;
108
- for (const [id, el] of this.avatars) {
109
- const user = avatarMeta(el);
110
- if (user) paintAvatarFollowState(el, user, id === userId);
111
- }
112
- }
113
- /* ────────────────── private ────────────────── */
114
- renderOverflow(count) {
115
- if (!this.container) return;
116
- if (count <= 0) {
117
- this.overflowEl?.remove();
118
- this.overflowEl = null;
119
- return;
120
- }
121
- if (!this.overflowEl) {
122
- this.overflowEl = createOverflowChip();
123
- }
124
- this.overflowEl.textContent = `+${count}`;
125
- this.container.appendChild(this.overflowEl);
126
- }
127
- };
128
- function createAvatar(user, onClick) {
129
- const wrap = document.createElement("div");
130
- wrap.setAttribute("data-vizu-presence-avatar", user.id);
131
- wrap.setAttribute("data-vizu-presence-name", user.name);
132
- wrap.setAttribute("data-vizu-presence-color", user.color);
133
- if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
134
- const reduced = prefersReducedMotion();
135
- const hoverTransition = reduced ? "none" : "transform 130ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 130ms ease-out";
136
- wrap.style.cssText = `
137
- position: relative;
138
- width: ${AVATAR_SIZE}px;
139
- height: ${AVATAR_SIZE}px;
140
- margin-left: -${OVERLAP_PX}px;
141
- border-radius: 50%;
142
- background: ${user.color};
143
- padding: 1.5px;
144
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
145
- cursor: pointer;
146
- flex-shrink: 0;
147
- transition: ${hoverTransition};
148
- `;
149
- wrap.addEventListener("click", (e) => {
150
- e.preventDefault();
151
- e.stopPropagation();
152
- onClick();
153
- });
154
- if (!reduced) {
155
- wrap.addEventListener(
156
- "mouseenter",
157
- () => wrap.style.transform = "translateY(-1px) scale(1.06)"
158
- );
159
- wrap.addEventListener("mouseleave", () => wrap.style.transform = "");
160
- }
161
- const inner = document.createElement("div");
162
- inner.setAttribute("data-vizu-presence-inner", "");
163
- inner.style.cssText = `
164
- width: 100%;
165
- height: 100%;
166
- border-radius: 50%;
167
- overflow: hidden;
168
- background: ${user.color};
169
- color: #ffffff;
170
- display: flex;
171
- align-items: center;
172
- justify-content: center;
173
- font-size: 10.5px;
174
- font-weight: 600;
175
- letter-spacing: -0.01em;
176
- line-height: 1;
177
- user-select: none;
178
- `;
179
- paintAvatarContent(inner, user);
180
- wrap.appendChild(inner);
181
- const tip = createTooltip(user.name);
182
- wrap.appendChild(tip);
183
- wrap.addEventListener("mouseenter", () => {
184
- tip.style.opacity = "1";
185
- tip.style.transform = "translateY(0)";
186
- });
187
- wrap.addEventListener("mouseleave", () => {
188
- tip.style.opacity = "0";
189
- tip.style.transform = "translateY(-2px)";
190
- });
191
- return wrap;
192
- }
193
- function updateAvatar(wrap, user) {
194
- const inner = wrap.querySelector("[data-vizu-presence-inner]");
195
- if (inner) paintAvatarContent(inner, user);
196
- const tip = wrap.querySelector("[data-vizu-presence-tip]");
197
- if (tip) tip.textContent = user.name;
198
- wrap.setAttribute("data-vizu-presence-name", user.name);
199
- if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
200
- else wrap.removeAttribute("data-vizu-presence-img");
201
- }
202
- function avatarMeta(wrap) {
203
- const id = wrap.getAttribute("data-vizu-presence-avatar");
204
- const name = wrap.getAttribute("data-vizu-presence-name");
205
- const color = wrap.getAttribute("data-vizu-presence-color");
206
- if (!id || !name || !color) return null;
207
- const avatar = wrap.getAttribute("data-vizu-presence-img") ?? void 0;
208
- return { id, name, color, avatar };
209
- }
210
- function paintAvatarFollowState(wrap, user, isFollowing) {
211
- if (isFollowing) {
212
- wrap.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.95), 0 0 0 4px var(--vizu-coral, #FF6647), 0 4px 12px rgba(255, 102, 71, 0.4)`;
213
- } else {
214
- wrap.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15)`;
215
- }
216
- void user;
217
- }
218
- function paintAvatarContent(inner, user) {
219
- inner.textContent = "";
220
- if (user.avatar) {
221
- const img = document.createElement("img");
222
- img.src = user.avatar;
223
- img.alt = user.name;
224
- img.referrerPolicy = "no-referrer";
225
- img.style.cssText = "width: 100%; height: 100%; object-fit: cover; display: block;";
226
- img.addEventListener(
227
- "error",
228
- () => {
229
- img.remove();
230
- inner.textContent = initialsOf(user.name);
231
- },
232
- { once: true }
233
- );
234
- inner.appendChild(img);
235
- } else {
236
- inner.textContent = initialsOf(user.name);
237
- }
238
- }
239
- function createTooltip(text) {
240
- const tip = document.createElement("div");
241
- tip.setAttribute("data-vizu-presence-tip", "");
242
- tip.textContent = text;
243
- tip.style.cssText = `
244
- position: absolute;
245
- top: calc(100% + 6px);
246
- right: 0;
247
- background: rgba(10, 10, 10, 0.92);
248
- color: #ffffff;
249
- font-size: 11px;
250
- font-weight: 500;
251
- padding: 4px 8px;
252
- border-radius: 4px;
253
- white-space: nowrap;
254
- pointer-events: none;
255
- opacity: 0;
256
- transform: translateY(-2px);
257
- transition: opacity 130ms ease-out, transform 130ms ease-out;
258
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
259
- max-width: 220px;
260
- overflow: hidden;
261
- text-overflow: ellipsis;
262
- `;
263
- return tip;
264
- }
265
- function createOverflowChip() {
266
- const chip = document.createElement("div");
267
- chip.setAttribute("data-vizu-presence-overflow", "");
268
- chip.style.cssText = `
269
- width: ${AVATAR_SIZE}px;
270
- height: ${AVATAR_SIZE}px;
271
- margin-left: -${OVERLAP_PX}px;
272
- border-radius: 50%;
273
- background: rgba(82, 82, 82, 0.95);
274
- color: #ffffff;
275
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
276
- display: flex;
277
- align-items: center;
278
- justify-content: center;
279
- font-size: 10.5px;
280
- font-weight: 600;
281
- letter-spacing: -0.02em;
282
- line-height: 1;
283
- user-select: none;
284
- flex-shrink: 0;
285
- `;
286
- return chip;
287
- }
288
- function initialsOf(name) {
289
- const trimmed = name.trim();
290
- if (!trimmed) return "?";
291
- const parts = trimmed.split(/\s+/);
292
- if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
293
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
294
- }
295
- return trimmed.slice(0, 2).toUpperCase();
296
- }
297
-
298
- // src/selection-overlay.ts
299
- var Z_INDEX2 = 2147483639;
300
- var SelectionOverlay = class {
301
- constructor() {
302
- this.container = null;
303
- this.overlays = /* @__PURE__ */ new Map();
304
- this.scrollListener = null;
305
- this.resizeListener = null;
306
- this.rafScheduled = false;
307
- }
308
- mount() {
309
- if (typeof document === "undefined") return;
310
- if (this.container) return;
311
- this.container = document.createElement("div");
312
- this.container.setAttribute("data-vizu-selections", "");
313
- this.container.style.cssText = `
314
- position: fixed; inset: 0;
315
- pointer-events: none;
316
- z-index: ${Z_INDEX2};
317
- contain: strict;
318
- `;
319
- document.body.appendChild(this.container);
320
- this.scrollListener = () => this.scheduleReposition();
321
- this.resizeListener = () => this.scheduleReposition();
322
- window.addEventListener("scroll", this.scrollListener, { passive: true, capture: true });
323
- window.addEventListener("resize", this.resizeListener);
324
- }
325
- destroy() {
326
- if (this.scrollListener) {
327
- window.removeEventListener("scroll", this.scrollListener, true);
328
- this.scrollListener = null;
329
- }
330
- if (this.resizeListener) {
331
- window.removeEventListener("resize", this.resizeListener);
332
- this.resizeListener = null;
333
- }
334
- for (const e of this.overlays.values()) e.el.remove();
335
- this.overlays.clear();
336
- this.container?.remove();
337
- this.container = null;
338
- }
339
- setSelections(selections) {
340
- if (!this.container) return;
341
- const nextIds = new Set(selections.map((s) => s.id));
342
- for (const [id, entry] of Array.from(this.overlays)) {
343
- if (!nextIds.has(id)) {
344
- entry.el.remove();
345
- this.overlays.delete(id);
346
- }
347
- }
348
- for (const sel of selections) {
349
- let entry = this.overlays.get(sel.id);
350
- if (!entry) {
351
- const el = createOverlay(sel.color, sel.name);
352
- this.container.appendChild(el);
353
- entry = { el, fingerprint: sel.fingerprint, color: sel.color, name: sel.name };
354
- this.overlays.set(sel.id, entry);
355
- } else {
356
- entry.fingerprint = sel.fingerprint;
357
- if (entry.color !== sel.color) {
358
- entry.color = sel.color;
359
- entry.el.style.borderColor = sel.color;
360
- }
361
- if (entry.name !== sel.name) {
362
- entry.name = sel.name;
363
- const label = entry.el.querySelector("[data-selection-label]");
364
- if (label) {
365
- label.textContent = sel.name;
366
- label.style.background = sel.color;
367
- }
368
- }
369
- }
370
- this.positionOverlay(entry);
371
- }
372
- }
373
- /* ────────────────── private ────────────────── */
374
- positionOverlay(entry) {
375
- const match = findByFingerprint(entry.fingerprint);
376
- const target = match?.element ?? null;
377
- if (!target) {
378
- entry.el.style.display = "none";
379
- return;
380
- }
381
- const rect = target.getBoundingClientRect();
382
- if (rect.width === 0 || rect.height === 0) {
383
- entry.el.style.display = "none";
384
- return;
385
- }
386
- entry.el.style.display = "block";
387
- entry.el.style.left = `${rect.left}px`;
388
- entry.el.style.top = `${rect.top}px`;
389
- entry.el.style.width = `${rect.width}px`;
390
- entry.el.style.height = `${rect.height}px`;
391
- }
392
- scheduleReposition() {
393
- if (this.rafScheduled) return;
394
- this.rafScheduled = true;
395
- requestAnimationFrame(() => {
396
- this.rafScheduled = false;
397
- for (const entry of this.overlays.values()) this.positionOverlay(entry);
398
- });
399
- }
400
- };
401
- function createOverlay(color, name) {
402
- const el = document.createElement("div");
403
- el.setAttribute("data-vizu-selection-overlay", "");
404
- const positionTransition = prefersReducedMotion() ? "none" : "left 90ms ease-out, top 90ms ease-out, width 90ms ease-out, height 90ms ease-out";
405
- el.style.cssText = `
406
- position: fixed;
407
- pointer-events: none;
408
- display: none;
409
- border: 2px dashed ${color};
410
- border-radius: 4px;
411
- background: ${color}10;
412
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
413
- transition: ${positionTransition};
414
- will-change: left, top, width, height;
415
- `;
416
- const label = document.createElement("div");
417
- label.setAttribute("data-selection-label", "");
418
- label.textContent = name;
419
- label.style.cssText = `
420
- position: absolute;
421
- top: -22px;
422
- left: -2px;
423
- padding: 2px 7px;
424
- background: ${color};
425
- color: #ffffff;
426
- font: 500 10.5px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
427
- border-radius: 3px 3px 3px 0;
428
- white-space: nowrap;
429
- max-width: 180px;
430
- overflow: hidden;
431
- text-overflow: ellipsis;
432
- box-shadow: 0 2px 4px rgba(0,0,0,0.15);
433
- `;
434
- el.appendChild(label);
435
- return el;
436
- }
437
-
438
- // src/live-collab.ts
439
- var MOUSEMOVE_THROTTLE_MS = 50;
440
- var SCROLL_THROTTLE_MS = 100;
441
- var INACTIVITY_FADE_MS = 5e3;
442
- var CURSOR_TRANSITION_MS = 110;
443
- var Z_INDEX3 = 2147483641;
444
- var FOLLOW_SCROLL_THRESHOLD_PX = 8;
445
- function fnv1a32Hex(input) {
446
- let h = 2166136261;
447
- for (let i = 0; i < input.length; i++) {
448
- h ^= input.charCodeAt(i);
449
- h = Math.imul(h, 16777619);
450
- }
451
- return (h >>> 0).toString(16).padStart(8, "0");
452
- }
453
- function roomIdFor(workspaceSlug, pageUrl) {
454
- return `ws_${workspaceSlug}__pg_${fnv1a32Hex(pageUrl)}`;
455
- }
456
- var LiveCollab = class {
457
- constructor(opts) {
458
- this.opts = opts;
459
- this.client = null;
460
- this.room = null;
461
- this.leave = null;
462
- this.container = null;
463
- this.cursors = /* @__PURE__ */ new Map();
464
- // keyed by connectionId
465
- this.presenceStack = null;
466
- this.followPill = null;
467
- this.selectionOverlay = null;
468
- this.mouseListener = null;
469
- this.scrollListener = null;
470
- this.visibilityListener = null;
471
- this.keydownListener = null;
472
- this.statusIndicator = null;
473
- this.throttleTimer = null;
474
- this.scrollThrottleTimer = null;
475
- this.pendingCursor = null;
476
- this.lastBroadcast = 0;
477
- this.lastScrollBroadcast = 0;
478
- this.unsubscribers = [];
479
- this.destroyed = false;
480
- // Follow mode state
481
- /** Clerk user id we're currently following, or null. */
482
- this.followingUserId = null;
483
- /** Last scrollY we received from the followee — for delta gating. */
484
- this.lastFolloweeScrollY = null;
485
- }
486
- async start() {
487
- if (typeof window === "undefined" || typeof document === "undefined") return;
488
- if (this.destroyed) return;
489
- try {
490
- void this.opts.publicKey;
491
- this.client = createClient({
492
- authEndpoint: async (room2) => {
493
- const res = await fetch(this.opts.authEndpoint, {
494
- method: "POST",
495
- headers: { "content-type": "application/json" },
496
- credentials: "include",
497
- body: JSON.stringify({ room: room2 })
498
- });
499
- if (!res.ok) {
500
- throw new Error(`vizu-liveblocks-auth: HTTP ${res.status}`);
501
- }
502
- return res.json();
503
- }
504
- });
505
- const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
506
- const room = this.opts.workspaceSlug ? roomIdFor(this.opts.workspaceSlug, pageUrl) : null;
507
- if (!room) return;
508
- const result = this.client.enterRoom(room, {
509
- initialPresence: {
510
- cursor: null,
511
- scrollY: typeof window !== "undefined" ? window.scrollY : 0,
512
- selecting: null
513
- }
514
- });
515
- this.room = result.room;
516
- this.leave = result.leave;
517
- this.mountContainer();
518
- this.presenceStack = new PresenceStack({
519
- onAvatarClick: (userId) => this.setFollowing(userId)
520
- });
521
- this.presenceStack.mount();
522
- this.followPill = new FollowPill({
523
- onStop: () => this.setFollowing(null)
524
- });
525
- this.selectionOverlay = new SelectionOverlay();
526
- this.selectionOverlay.mount();
527
- this.statusIndicator = new ConnectionIndicator();
528
- this.statusIndicator.mount();
529
- this.subscribeOthers();
530
- this.subscribeStatus();
531
- this.attachMouseListener();
532
- this.attachScrollListener();
533
- this.attachVisibilityListener();
534
- this.attachKeyboardListener();
535
- } catch (err) {
536
- if (typeof console !== "undefined") {
537
- console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
538
- }
539
- this.cleanup();
540
- }
541
- }
542
- destroy() {
543
- this.destroyed = true;
544
- this.cleanup();
545
- }
546
- /* ────────────────── private ────────────────── */
547
- cleanup() {
548
- if (this.mouseListener) {
549
- window.removeEventListener("mousemove", this.mouseListener);
550
- this.mouseListener = null;
551
- }
552
- if (this.scrollListener) {
553
- window.removeEventListener("scroll", this.scrollListener);
554
- this.scrollListener = null;
555
- }
556
- if (this.visibilityListener) {
557
- document.removeEventListener("visibilitychange", this.visibilityListener);
558
- this.visibilityListener = null;
559
- }
560
- if (this.keydownListener) {
561
- document.removeEventListener("keydown", this.keydownListener);
562
- this.keydownListener = null;
563
- }
564
- if (this.throttleTimer != null) {
565
- window.clearTimeout(this.throttleTimer);
566
- this.throttleTimer = null;
567
- }
568
- if (this.scrollThrottleTimer != null) {
569
- window.clearTimeout(this.scrollThrottleTimer);
570
- this.scrollThrottleTimer = null;
571
- }
572
- for (const unsub of this.unsubscribers) unsub();
573
- this.unsubscribers = [];
574
- for (const c of this.cursors.values()) {
575
- if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
576
- c.el.remove();
577
- }
578
- this.cursors.clear();
579
- this.presenceStack?.destroy();
580
- this.presenceStack = null;
581
- this.followPill?.destroy();
582
- this.followPill = null;
583
- this.selectionOverlay?.destroy();
584
- this.selectionOverlay = null;
585
- this.statusIndicator?.destroy();
586
- this.statusIndicator = null;
587
- this.followingUserId = null;
588
- this.lastFolloweeScrollY = null;
589
- this.container?.remove();
590
- this.container = null;
591
- this.leave?.();
592
- this.leave = null;
593
- this.room = null;
594
- this.client = null;
595
- }
596
- /**
597
- * Public API: broadcast the local user's currently-hovered element
598
- * (highlighter mode) so other clients can render a ghost outline on
599
- * it. Pass null to clear (highlighter stopped, popover opened, etc.).
600
- * Idempotent on no-op transitions. Slice 5 of live-collab.md.
601
- */
602
- setLocalSelection(fingerprint) {
603
- if (!this.room) return;
604
- this.room.updatePresence({
605
- selecting: fingerprint ? { fingerprintJson: JSON.stringify(fingerprint) } : null
606
- });
607
- }
608
- /**
609
- * Public API: start/stop following a user by Clerk id. Null clears.
610
- * Called by the avatar click handler and by the FollowPill stop button.
611
- * Idempotent — same id again is a no-op.
612
- */
613
- setFollowing(userId) {
614
- if (this.followingUserId === userId) return;
615
- this.followingUserId = userId;
616
- this.lastFolloweeScrollY = null;
617
- this.presenceStack?.setFollowing(userId);
618
- this.followPill?.setFollowing(userId ? this.lookupUserForPill(userId) : null);
619
- if (userId) this.scrollToFolloweeIfKnown();
620
- }
621
- lookupUserForPill(userId) {
622
- if (!this.room) return null;
623
- const others = this.room.getOthers();
624
- for (const o of others) {
625
- const meta = o;
626
- if ((meta.id ?? `c${o.connectionId}`) === userId) {
627
- return {
628
- id: userId,
629
- name: meta.info?.name ?? "Anonymous",
630
- color: colorForUser(userId),
631
- avatar: meta.info?.avatar
632
- };
633
- }
634
- }
635
- return null;
636
- }
637
- scrollToFolloweeIfKnown() {
638
- if (!this.followingUserId || !this.room) return;
639
- const others = this.room.getOthers();
640
- for (const o of others) {
641
- const meta = o;
642
- if ((meta.id ?? `c${o.connectionId}`) === this.followingUserId) {
643
- const presence = o.presence;
644
- if (typeof presence.scrollY === "number") {
645
- this.followScrollTo(presence.scrollY);
646
- }
647
- return;
648
- }
649
- }
650
- }
651
- followScrollTo(scrollY) {
652
- if (this.lastFolloweeScrollY !== null && Math.abs(this.lastFolloweeScrollY - scrollY) < FOLLOW_SCROLL_THRESHOLD_PX) {
653
- return;
654
- }
655
- this.lastFolloweeScrollY = scrollY;
656
- window.scrollTo({ top: scrollY, behavior: "smooth" });
657
- }
658
- mountContainer() {
659
- this.container = document.createElement("div");
660
- this.container.setAttribute("data-vizu-live-cursors", "");
661
- this.container.style.cssText = `
662
- position: fixed; inset: 0;
663
- pointer-events: none;
664
- z-index: ${Z_INDEX3};
665
- contain: strict;
666
- `;
667
- document.body.appendChild(this.container);
668
- }
669
- attachMouseListener() {
670
- this.mouseListener = (e) => {
671
- const xPct = e.clientX / window.innerWidth * 100;
672
- const yPct = e.clientY / window.innerHeight * 100;
673
- this.pendingCursor = { xPct, yPct };
674
- this.flushThrottled();
675
- };
676
- window.addEventListener("mousemove", this.mouseListener, { passive: true });
677
- }
678
- attachScrollListener() {
679
- this.scrollListener = () => {
680
- const now = performance.now();
681
- const elapsed = now - this.lastScrollBroadcast;
682
- if (elapsed >= SCROLL_THROTTLE_MS) {
683
- this.flushScroll();
684
- return;
685
- }
686
- if (this.scrollThrottleTimer == null) {
687
- this.scrollThrottleTimer = window.setTimeout(() => {
688
- this.scrollThrottleTimer = null;
689
- this.flushScroll();
690
- }, SCROLL_THROTTLE_MS - elapsed);
691
- }
692
- };
693
- window.addEventListener("scroll", this.scrollListener, { passive: true });
694
- }
695
- flushScroll() {
696
- if (!this.room) return;
697
- this.room.updatePresence({ scrollY: window.scrollY });
698
- this.lastScrollBroadcast = performance.now();
699
- }
700
- attachVisibilityListener() {
701
- this.visibilityListener = () => {
702
- if (document.hidden && this.room) {
703
- this.room.updatePresence({ cursor: null });
704
- }
705
- };
706
- document.addEventListener("visibilitychange", this.visibilityListener);
707
- }
708
- attachKeyboardListener() {
709
- this.keydownListener = (e) => {
710
- if (e.key !== "Escape") return;
711
- if (!this.followingUserId) return;
712
- this.setFollowing(null);
713
- };
714
- document.addEventListener("keydown", this.keydownListener);
715
- }
716
- subscribeStatus() {
717
- if (!this.room) return;
718
- this.statusIndicator?.setStatus(this.room.getStatus());
719
- const unsub = this.room.subscribe("status", (status) => {
720
- this.statusIndicator?.setStatus(status);
721
- });
722
- this.unsubscribers.push(unsub);
723
- }
724
- flushThrottled() {
725
- const now = performance.now();
726
- const elapsed = now - this.lastBroadcast;
727
- if (elapsed >= MOUSEMOVE_THROTTLE_MS) {
728
- this.flush();
729
- return;
730
- }
731
- if (this.throttleTimer == null) {
732
- this.throttleTimer = window.setTimeout(() => {
733
- this.throttleTimer = null;
734
- this.flush();
735
- }, MOUSEMOVE_THROTTLE_MS - elapsed);
736
- }
737
- }
738
- flush() {
739
- if (!this.pendingCursor || !this.room) return;
740
- this.room.updatePresence({ cursor: this.pendingCursor });
741
- this.pendingCursor = null;
742
- this.lastBroadcast = performance.now();
743
- }
744
- subscribeOthers() {
745
- if (!this.room) return;
746
- const unsub = this.room.subscribe("others", (others) => {
747
- const seen = /* @__PURE__ */ new Set();
748
- const stackUsers = /* @__PURE__ */ new Map();
749
- const selections = [];
750
- let followeeScrollY = null;
751
- let followeeStillPresent = false;
752
- for (const other of others) {
753
- seen.add(other.connectionId);
754
- const meta = other;
755
- const userId = meta.id ?? `c${other.connectionId}`;
756
- const name = meta.info?.name ?? "Anonymous";
757
- const color = colorForUser(userId);
758
- if (!stackUsers.has(userId)) {
759
- stackUsers.set(userId, {
760
- id: userId,
761
- name,
762
- color,
763
- avatar: meta.info?.avatar
764
- });
765
- }
766
- const presence = other.presence;
767
- if (this.followingUserId === userId && followeeScrollY === null) {
768
- followeeStillPresent = true;
769
- if (typeof presence.scrollY === "number") {
770
- followeeScrollY = presence.scrollY;
771
- }
772
- }
773
- if (presence.selecting && !selections.find((s) => s.id === userId)) {
774
- try {
775
- const fp = JSON.parse(presence.selecting.fingerprintJson);
776
- selections.push({ id: userId, name, color, fingerprint: fp });
777
- } catch {
778
- }
779
- }
780
- if (!presence.cursor) {
781
- this.removeCursor(other.connectionId);
782
- continue;
783
- }
784
- this.upsertCursor(other.connectionId, userId, name, presence.cursor);
785
- }
786
- for (const id of Array.from(this.cursors.keys())) {
787
- if (!seen.has(id)) this.removeCursor(id);
788
- }
789
- this.presenceStack?.setOthers(Array.from(stackUsers.values()));
790
- this.selectionOverlay?.setSelections(selections);
791
- if (this.followingUserId) {
792
- if (!followeeStillPresent) {
793
- this.setFollowing(null);
794
- } else if (followeeScrollY !== null) {
795
- this.followScrollTo(followeeScrollY);
796
- }
797
- }
798
- });
799
- this.unsubscribers.push(unsub);
800
- }
801
- upsertCursor(connectionId, userId, name, cursor) {
802
- if (!this.container) return;
803
- let entry = this.cursors.get(connectionId);
804
- if (!entry) {
805
- const color = colorForUser(userId);
806
- const el = createCursorElement(name, color);
807
- this.container.appendChild(el);
808
- entry = { el, fadeTimer: null };
809
- this.cursors.set(connectionId, entry);
810
- }
811
- const inViewport = cursor.xPct >= 0 && cursor.xPct <= 100 && cursor.yPct >= 0 && cursor.yPct <= 100;
812
- if (!inViewport) {
813
- entry.el.style.opacity = "0";
814
- return;
815
- }
816
- const x = window.innerWidth * cursor.xPct / 100;
817
- const y = window.innerHeight * cursor.yPct / 100;
818
- entry.el.style.transform = `translate(${x}px, ${y}px)`;
819
- entry.el.style.opacity = "1";
820
- if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
821
- entry.fadeTimer = window.setTimeout(() => {
822
- if (entry) entry.el.style.opacity = "0";
823
- }, INACTIVITY_FADE_MS);
824
- }
825
- removeCursor(connectionId) {
826
- const entry = this.cursors.get(connectionId);
827
- if (!entry) return;
828
- if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
829
- entry.el.style.opacity = "0";
830
- window.setTimeout(() => entry.el.remove(), 220);
831
- this.cursors.delete(connectionId);
832
- }
833
- };
834
- function createCursorElement(name, color) {
835
- const wrap = document.createElement("div");
836
- wrap.setAttribute("data-vizu-cursor", "");
837
- const transformTransition = prefersReducedMotion() ? "none" : `transform ${CURSOR_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`;
838
- wrap.style.cssText = `
839
- position: absolute; top: 0; left: 0;
840
- pointer-events: none;
841
- opacity: 0;
842
- transform: translate(0px, 0px);
843
- transition: ${transformTransition}, opacity 220ms ease-out;
844
- will-change: transform;
845
- `;
846
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
847
- svg.setAttribute("width", "18");
848
- svg.setAttribute("height", "20");
849
- svg.setAttribute("viewBox", "0 0 16 18");
850
- svg.style.cssText = "display: block; filter: drop-shadow(0 1px 1.5px rgba(0,0,0,0.3));";
851
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
852
- path.setAttribute("d", "M0 0 L0 13 L4 9 L7 16 L9 15 L6 8 L11 8 Z");
853
- path.setAttribute("fill", color);
854
- path.setAttribute("stroke", "#ffffff");
855
- path.setAttribute("stroke-width", "1.2");
856
- path.setAttribute("stroke-linejoin", "round");
857
- svg.appendChild(path);
858
- wrap.appendChild(svg);
859
- const label = document.createElement("div");
860
- label.textContent = name;
861
- label.style.cssText = `
862
- margin-top: 2px;
863
- margin-left: 10px;
864
- padding: 2px 7px;
865
- background: ${color};
866
- color: #ffffff;
867
- font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
868
- border-radius: 4px;
869
- white-space: nowrap;
870
- box-shadow: 0 2px 6px rgba(0,0,0,0.18);
871
- max-width: 160px;
872
- overflow: hidden;
873
- text-overflow: ellipsis;
874
- `;
875
- wrap.appendChild(label);
876
- return wrap;
877
- }
878
- var ConnectionIndicator = class {
879
- constructor() {
880
- this.el = null;
881
- this.label = null;
882
- }
883
- mount() {
884
- if (typeof document === "undefined") return;
885
- if (this.el) return;
886
- this.el = document.createElement("div");
887
- this.el.setAttribute("data-vizu-connection-status", "");
888
- this.el.style.cssText = `
889
- position: fixed;
890
- top: 18px;
891
- right: 200px;
892
- z-index: ${Z_INDEX3};
893
- display: none;
894
- align-items: center;
895
- gap: 7px;
896
- padding: 4px 10px 4px 8px;
897
- background: rgba(10, 10, 10, 0.88);
898
- color: #fafafa;
899
- font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
900
- border-radius: 999px;
901
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
902
- pointer-events: none;
903
- `;
904
- const dot = document.createElement("span");
905
- dot.style.cssText = `
906
- display: inline-block;
907
- width: 6px; height: 6px;
908
- border-radius: 50%;
909
- background: #F59E0B;
910
- box-shadow: 0 0 6px #F59E0B;
911
- `;
912
- const label = document.createElement("span");
913
- this.label = label;
914
- this.el.appendChild(dot);
915
- this.el.appendChild(label);
916
- document.body.appendChild(this.el);
917
- }
918
- setStatus(status) {
919
- if (!this.el || !this.label) return;
920
- if (status === "connected") {
921
- this.el.style.display = "none";
922
- return;
923
- }
924
- this.label.textContent = labelForStatus(status);
925
- this.el.style.display = "inline-flex";
926
- }
927
- destroy() {
928
- this.el?.remove();
929
- this.el = null;
930
- this.label = null;
931
- }
932
- };
933
- function labelForStatus(status) {
934
- switch (status) {
935
- case "connecting":
936
- return "Connecting\u2026";
937
- case "reconnecting":
938
- return "Reconnecting\u2026";
939
- case "disconnected":
940
- return "Offline";
941
- case "initial":
942
- return "Connecting\u2026";
943
- case "connected":
944
- return "";
945
- }
946
- }
947
- var FollowPill = class {
948
- constructor(opts) {
949
- this.el = null;
950
- this.onStop = opts.onStop;
951
- }
952
- setFollowing(user) {
953
- if (typeof document === "undefined") return;
954
- if (!user) {
955
- this.el?.remove();
956
- this.el = null;
957
- return;
958
- }
959
- if (!this.el) this.el = this.mount();
960
- this.paint(user);
961
- }
962
- destroy() {
963
- this.el?.remove();
964
- this.el = null;
965
- }
966
- mount() {
967
- const pill = document.createElement("div");
968
- pill.setAttribute("data-vizu-follow-pill", "");
969
- pill.style.cssText = `
970
- position: fixed;
971
- top: 56px;
972
- right: 16px;
973
- z-index: ${Z_INDEX3};
974
- display: inline-flex;
975
- align-items: center;
976
- gap: 8px;
977
- padding: 6px 6px 6px 12px;
978
- background: rgba(10, 10, 10, 0.92);
979
- color: #ffffff;
980
- border-radius: 999px;
981
- font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
982
- box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
983
- pointer-events: auto;
984
- `;
985
- document.body.appendChild(pill);
986
- return pill;
987
- }
988
- paint(user) {
989
- if (!this.el) return;
990
- this.el.textContent = "";
991
- const dot = document.createElement("span");
992
- dot.style.cssText = `
993
- display: inline-block;
994
- width: 8px; height: 8px;
995
- border-radius: 50%;
996
- background: ${user.color};
997
- box-shadow: 0 0 8px ${user.color};
998
- `;
999
- this.el.appendChild(dot);
1000
- const label = document.createElement("span");
1001
- label.textContent = `Following ${user.name}`;
1002
- label.style.cssText = "max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
1003
- this.el.appendChild(label);
1004
- const stop = document.createElement("button");
1005
- stop.type = "button";
1006
- stop.setAttribute("aria-label", `Stop following ${user.name}`);
1007
- stop.innerHTML = '<svg width="11" height="11" viewBox="0 0 12 12" aria-hidden="true"><path d="M1 1 L11 11 M11 1 L1 11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>';
1008
- stop.style.cssText = `
1009
- display: inline-flex;
1010
- align-items: center;
1011
- justify-content: center;
1012
- width: 22px; height: 22px;
1013
- border: none;
1014
- background: rgba(255, 255, 255, 0.12);
1015
- color: #ffffff;
1016
- border-radius: 50%;
1017
- cursor: pointer;
1018
- transition: background 120ms ease-out;
1019
- `;
1020
- stop.addEventListener("mouseenter", () => stop.style.background = "rgba(255, 255, 255, 0.22)");
1021
- stop.addEventListener("mouseleave", () => stop.style.background = "rgba(255, 255, 255, 0.12)");
1022
- stop.addEventListener("click", (e) => {
1023
- e.preventDefault();
1024
- this.onStop();
1025
- });
1026
- this.el.appendChild(stop);
1027
- }
1028
- };
1029
- export {
1030
- LiveCollab,
1031
- roomIdFor
1032
- };
1033
- //# sourceMappingURL=live-collab-IRUNFAPE.js.map