@unhingged/vizu-core 0.1.1 → 0.1.2

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.
@@ -2,9 +2,6 @@ import {
2
2
  findByFingerprint
3
3
  } from "./chunk-OMIFOSQ2.js";
4
4
 
5
- // src/live-collab.ts
6
- import { createClient } from "@liveblocks/client";
7
-
8
5
  // src/cursor-colors.ts
9
6
  var CURSOR_COLORS = [
10
7
  "#4F46E5",
@@ -436,12 +433,19 @@ function createOverlay(color, name) {
436
433
  }
437
434
 
438
435
  // src/live-collab.ts
439
- var MOUSEMOVE_THROTTLE_MS = 50;
440
- var SCROLL_THROTTLE_MS = 100;
436
+ function generateConnId() {
437
+ const a = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
438
+ const b = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
439
+ return `${a}${b}`;
440
+ }
441
+ var PUSH_INTERVAL_MS = 100;
442
+ var PULL_INTERVAL_MS = 200;
441
443
  var INACTIVITY_FADE_MS = 5e3;
442
- var CURSOR_TRANSITION_MS = 110;
444
+ var CURSOR_TRANSITION_MS = 220;
443
445
  var Z_INDEX3 = 2147483641;
444
446
  var FOLLOW_SCROLL_THRESHOLD_PX = 8;
447
+ var RECONNECT_AFTER_ERRORS = 2;
448
+ var DISCONNECT_AFTER_ERRORS = 5;
445
449
  function fnv1a32Hex(input) {
446
450
  let h = 2166136261;
447
451
  for (let i = 0; i < input.length; i++) {
@@ -456,12 +460,9 @@ function roomIdFor(workspaceSlug, pageUrl) {
456
460
  var LiveCollab = class {
457
461
  constructor(opts) {
458
462
  this.opts = opts;
459
- this.client = null;
460
- this.room = null;
461
- this.leave = null;
462
463
  this.container = null;
463
464
  this.cursors = /* @__PURE__ */ new Map();
464
- // keyed by connectionId
465
+ // keyed by connId now (was numeric connectionId)
465
466
  this.presenceStack = null;
466
467
  this.followPill = null;
467
468
  this.selectionOverlay = null;
@@ -470,50 +471,36 @@ var LiveCollab = class {
470
471
  this.visibilityListener = null;
471
472
  this.keydownListener = null;
472
473
  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 = [];
474
+ this.pushTimer = null;
475
+ this.pullTimer = null;
479
476
  this.destroyed = false;
477
+ /** Full room id, built once in start() from workspace + pageUrl. */
478
+ this.roomId = null;
479
+ /** Latest snapshot of other connections from /api/live/[room]/others. */
480
+ this.latestOthers = [];
481
+ /** Local presence — mouse + scroll + selection update this in place; pushTimer ships it. */
482
+ this.currentPresence = { cursor: null, scrollY: 0, selecting: null };
483
+ /** True when something changed since the last push — skip the POST otherwise. */
484
+ this.presenceDirty = false;
485
+ /** Connection-health tracker for the indicator. */
486
+ this.status = "initial";
487
+ this.consecutiveErrors = 0;
480
488
  // Follow mode state
481
489
  /** Clerk user id we're currently following, or null. */
482
490
  this.followingUserId = null;
483
491
  /** Last scrollY we received from the followee — for delta gating. */
484
492
  this.lastFolloweeScrollY = null;
493
+ this.connId = generateConnId();
494
+ if (typeof window !== "undefined") {
495
+ this.currentPresence.scrollY = window.scrollY;
496
+ }
485
497
  }
486
498
  async start() {
487
499
  if (typeof window === "undefined" || typeof document === "undefined") return;
488
500
  if (this.destroyed) return;
489
501
  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
502
  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;
503
+ this.roomId = roomIdFor(this.opts.workspaceSlug, pageUrl);
517
504
  this.mountContainer();
518
505
  this.presenceStack = new PresenceStack({
519
506
  onAvatarClick: (userId) => this.setFollowing(userId)
@@ -526,12 +513,16 @@ var LiveCollab = class {
526
513
  this.selectionOverlay.mount();
527
514
  this.statusIndicator = new ConnectionIndicator();
528
515
  this.statusIndicator.mount();
529
- this.subscribeOthers();
530
- this.subscribeStatus();
516
+ this.setStatus("connecting");
531
517
  this.attachMouseListener();
532
518
  this.attachScrollListener();
533
519
  this.attachVisibilityListener();
534
520
  this.attachKeyboardListener();
521
+ this.pushTimer = window.setInterval(() => void this.push(), PUSH_INTERVAL_MS);
522
+ this.pullTimer = window.setInterval(() => void this.pull(), PULL_INTERVAL_MS);
523
+ this.presenceDirty = true;
524
+ void this.push();
525
+ void this.pull();
535
526
  } catch (err) {
536
527
  if (typeof console !== "undefined") {
537
528
  console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
@@ -545,6 +536,7 @@ var LiveCollab = class {
545
536
  }
546
537
  /* ────────────────── private ────────────────── */
547
538
  cleanup() {
539
+ void this.clearRemotePresence();
548
540
  if (this.mouseListener) {
549
541
  window.removeEventListener("mousemove", this.mouseListener);
550
542
  this.mouseListener = null;
@@ -561,16 +553,14 @@ var LiveCollab = class {
561
553
  document.removeEventListener("keydown", this.keydownListener);
562
554
  this.keydownListener = null;
563
555
  }
564
- if (this.throttleTimer != null) {
565
- window.clearTimeout(this.throttleTimer);
566
- this.throttleTimer = null;
556
+ if (this.pushTimer != null) {
557
+ window.clearInterval(this.pushTimer);
558
+ this.pushTimer = null;
567
559
  }
568
- if (this.scrollThrottleTimer != null) {
569
- window.clearTimeout(this.scrollThrottleTimer);
570
- this.scrollThrottleTimer = null;
560
+ if (this.pullTimer != null) {
561
+ window.clearInterval(this.pullTimer);
562
+ this.pullTimer = null;
571
563
  }
572
- for (const unsub of this.unsubscribers) unsub();
573
- this.unsubscribers = [];
574
564
  for (const c of this.cursors.values()) {
575
565
  if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
576
566
  c.el.remove();
@@ -588,10 +578,8 @@ var LiveCollab = class {
588
578
  this.lastFolloweeScrollY = null;
589
579
  this.container?.remove();
590
580
  this.container = null;
591
- this.leave?.();
592
- this.leave = null;
593
- this.room = null;
594
- this.client = null;
581
+ this.roomId = null;
582
+ this.latestOthers = [];
595
583
  }
596
584
  /**
597
585
  * Public API: broadcast the local user's currently-hovered element
@@ -600,10 +588,8 @@ var LiveCollab = class {
600
588
  * Idempotent on no-op transitions. Slice 5 of live-collab.md.
601
589
  */
602
590
  setLocalSelection(fingerprint) {
603
- if (!this.room) return;
604
- this.room.updatePresence({
605
- selecting: fingerprint ? { fingerprintJson: JSON.stringify(fingerprint) } : null
606
- });
591
+ this.currentPresence.selecting = fingerprint ? { fingerprintJson: JSON.stringify(fingerprint) } : null;
592
+ this.presenceDirty = true;
607
593
  }
608
594
  /**
609
595
  * Public API: start/stop following a user by Clerk id. Null clears.
@@ -619,30 +605,24 @@ var LiveCollab = class {
619
605
  if (userId) this.scrollToFolloweeIfKnown();
620
606
  }
621
607
  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) {
608
+ for (const o of this.latestOthers) {
609
+ if (o.user.userId === userId) {
627
610
  return {
628
611
  id: userId,
629
- name: meta.info?.name ?? "Anonymous",
612
+ name: o.user.name ?? "Anonymous",
630
613
  color: colorForUser(userId),
631
- avatar: meta.info?.avatar
614
+ avatar: o.user.avatar
632
615
  };
633
616
  }
634
617
  }
635
618
  return null;
636
619
  }
637
620
  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);
621
+ if (!this.followingUserId) return;
622
+ for (const o of this.latestOthers) {
623
+ if (o.user.userId === this.followingUserId) {
624
+ if (typeof o.presence.scrollY === "number") {
625
+ this.followScrollTo(o.presence.scrollY);
646
626
  }
647
627
  return;
648
628
  }
@@ -668,39 +648,27 @@ var LiveCollab = class {
668
648
  }
669
649
  attachMouseListener() {
670
650
  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();
651
+ this.currentPresence.cursor = {
652
+ xPct: e.clientX / window.innerWidth * 100,
653
+ yPct: e.clientY / window.innerHeight * 100
654
+ };
655
+ this.presenceDirty = true;
675
656
  };
676
657
  window.addEventListener("mousemove", this.mouseListener, { passive: true });
677
658
  }
678
659
  attachScrollListener() {
679
660
  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
- }
661
+ this.currentPresence.scrollY = window.scrollY;
662
+ this.presenceDirty = true;
692
663
  };
693
664
  window.addEventListener("scroll", this.scrollListener, { passive: true });
694
665
  }
695
- flushScroll() {
696
- if (!this.room) return;
697
- this.room.updatePresence({ scrollY: window.scrollY });
698
- this.lastScrollBroadcast = performance.now();
699
- }
700
666
  attachVisibilityListener() {
701
667
  this.visibilityListener = () => {
702
- if (document.hidden && this.room) {
703
- this.room.updatePresence({ cursor: null });
668
+ if (document.hidden) {
669
+ void this.clearRemotePresence();
670
+ } else {
671
+ this.presenceDirty = true;
704
672
  }
705
673
  };
706
674
  document.addEventListener("visibilitychange", this.visibilityListener);
@@ -713,90 +681,187 @@ var LiveCollab = class {
713
681
  };
714
682
  document.addEventListener("keydown", this.keydownListener);
715
683
  }
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);
684
+ setStatus(next) {
685
+ if (this.status === next) return;
686
+ this.status = next;
687
+ this.statusIndicator?.setStatus(next);
723
688
  }
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);
689
+ /**
690
+ * Push tick — POST current presence to /api/live/[room]/me. Skips
691
+ * the request when nothing changed since last push, so an idle tab
692
+ * generates near-zero traffic.
693
+ */
694
+ async push() {
695
+ if (this.destroyed || !this.roomId) return;
696
+ if (!this.presenceDirty) return;
697
+ if (typeof document !== "undefined" && document.hidden) return;
698
+ this.presenceDirty = false;
699
+ try {
700
+ const res = await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
701
+ method: "POST",
702
+ body: JSON.stringify({
703
+ connId: this.connId,
704
+ presence: this.currentPresence
705
+ })
706
+ });
707
+ if (!res.ok) {
708
+ if (res.status === 402 || res.status === 401 || res.status === 503) {
709
+ this.setStatus("disconnected");
710
+ if (this.pushTimer != null) {
711
+ window.clearInterval(this.pushTimer);
712
+ this.pushTimer = null;
713
+ }
714
+ if (this.pullTimer != null) {
715
+ window.clearInterval(this.pullTimer);
716
+ this.pullTimer = null;
717
+ }
718
+ return;
719
+ }
720
+ throw new Error(`push HTTP ${res.status}`);
721
+ }
722
+ this.noteSuccess();
723
+ } catch (err) {
724
+ this.noteError(err);
725
+ this.presenceDirty = true;
736
726
  }
737
727
  }
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;
728
+ /**
729
+ * Pull tick — GET /api/live/[room]/others and drive cursor + stack +
730
+ * selection + follow updates. Same handler shape as the Liveblocks-era
731
+ * subscribeOthers callback; only the data source changed.
732
+ */
733
+ async pull() {
734
+ if (this.destroyed || !this.roomId) return;
735
+ try {
736
+ const res = await this.fetchLive(
737
+ `/api/live/${encodeURIComponent(this.roomId)}/others?me=${encodeURIComponent(this.connId)}`,
738
+ { method: "GET" }
739
+ );
740
+ if (!res.ok) {
741
+ if (res.status === 503 || res.status === 401) {
742
+ this.setStatus("disconnected");
743
+ if (this.pullTimer != null) {
744
+ window.clearInterval(this.pullTimer);
745
+ this.pullTimer = null;
771
746
  }
747
+ return;
772
748
  }
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
- }
749
+ throw new Error(`pull HTTP ${res.status}`);
750
+ }
751
+ const body = await res.json();
752
+ const others = (body.others ?? []).map((o) => ({
753
+ connId: o.connId,
754
+ presence: o.payload.presence,
755
+ user: o.payload.user
756
+ }));
757
+ this.latestOthers = others;
758
+ this.renderOthers(others);
759
+ this.noteSuccess();
760
+ } catch (err) {
761
+ this.noteError(err);
762
+ }
763
+ }
764
+ noteSuccess() {
765
+ this.consecutiveErrors = 0;
766
+ if (this.status !== "connected") this.setStatus("connected");
767
+ }
768
+ noteError(err) {
769
+ this.consecutiveErrors++;
770
+ if (this.consecutiveErrors >= DISCONNECT_AFTER_ERRORS) {
771
+ this.setStatus("disconnected");
772
+ } else if (this.consecutiveErrors >= RECONNECT_AFTER_ERRORS) {
773
+ this.setStatus("reconnecting");
774
+ }
775
+ if (typeof console !== "undefined" && this.consecutiveErrors === 1) {
776
+ console.warn("[vizu/live-collab] transport error", err);
777
+ }
778
+ }
779
+ /**
780
+ * Drive the cursor / stack / selection / follow layers from a fresh
781
+ * `others` snapshot. Mirrors the body of the Liveblocks-era
782
+ * subscribeOthers callback so DOM behavior is unchanged.
783
+ */
784
+ renderOthers(others) {
785
+ const seen = /* @__PURE__ */ new Set();
786
+ const stackUsers = /* @__PURE__ */ new Map();
787
+ const selections = [];
788
+ let followeeScrollY = null;
789
+ let followeeStillPresent = false;
790
+ for (const other of others) {
791
+ seen.add(other.connId);
792
+ const userId = other.user.userId;
793
+ const name = other.user.name ?? "Anonymous";
794
+ const color = colorForUser(userId);
795
+ if (!stackUsers.has(userId)) {
796
+ stackUsers.set(userId, { id: userId, name, color, avatar: other.user.avatar });
797
+ }
798
+ if (this.followingUserId === userId && followeeScrollY === null) {
799
+ followeeStillPresent = true;
800
+ if (typeof other.presence.scrollY === "number") {
801
+ followeeScrollY = other.presence.scrollY;
779
802
  }
780
- if (!presence.cursor) {
781
- this.removeCursor(other.connectionId);
782
- continue;
803
+ }
804
+ if (other.presence.selecting && !selections.find((s) => s.id === userId)) {
805
+ try {
806
+ const fp = JSON.parse(other.presence.selecting.fingerprintJson);
807
+ selections.push({ id: userId, name, color, fingerprint: fp });
808
+ } catch {
783
809
  }
784
- this.upsertCursor(other.connectionId, userId, name, presence.cursor);
785
810
  }
786
- for (const id of Array.from(this.cursors.keys())) {
787
- if (!seen.has(id)) this.removeCursor(id);
811
+ if (!other.presence.cursor) {
812
+ this.removeCursor(other.connId);
813
+ continue;
788
814
  }
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
- }
815
+ this.upsertCursor(other.connId, userId, name, other.presence.cursor);
816
+ }
817
+ for (const id of Array.from(this.cursors.keys())) {
818
+ if (!seen.has(id)) this.removeCursor(id);
819
+ }
820
+ this.presenceStack?.setOthers(Array.from(stackUsers.values()));
821
+ this.selectionOverlay?.setSelections(selections);
822
+ if (this.followingUserId) {
823
+ if (!followeeStillPresent) {
824
+ this.setFollowing(null);
825
+ } else if (followeeScrollY !== null) {
826
+ this.followScrollTo(followeeScrollY);
827
+ }
828
+ }
829
+ }
830
+ /**
831
+ * Async wrapper around fetch that injects the optional auth header
832
+ * resolved from opts.getAuthHeader. Same-origin requests rely on
833
+ * cookies via `credentials: 'include'`; cross-origin gets the
834
+ * bearer token returned by the host.
835
+ */
836
+ async fetchLive(path, init) {
837
+ const headers = new Headers(init.headers);
838
+ headers.set("content-type", "application/json");
839
+ if (this.opts.getAuthHeader) {
840
+ const extra = await this.opts.getAuthHeader();
841
+ if (extra) {
842
+ new Headers(extra).forEach((v, k) => headers.set(k, v));
797
843
  }
844
+ }
845
+ return fetch(this.opts.apiUrl + path, {
846
+ ...init,
847
+ headers,
848
+ credentials: "include"
798
849
  });
799
- this.unsubscribers.push(unsub);
850
+ }
851
+ /**
852
+ * Fire-and-forget clearOnly POST so other clients see us drop within
853
+ * one pull cycle (~200ms) instead of waiting for the 5s presence TTL.
854
+ * Errors swallowed — the TTL is the safety net.
855
+ */
856
+ async clearRemotePresence() {
857
+ if (!this.roomId) return;
858
+ try {
859
+ await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
860
+ method: "POST",
861
+ body: JSON.stringify({ connId: this.connId, clearOnly: true })
862
+ });
863
+ } catch {
864
+ }
800
865
  }
801
866
  upsertCursor(connectionId, userId, name, cursor) {
802
867
  if (!this.container) return;
@@ -1030,4 +1095,4 @@ export {
1030
1095
  LiveCollab,
1031
1096
  roomIdFor
1032
1097
  };
1033
- //# sourceMappingURL=live-collab-IRUNFAPE.js.map
1098
+ //# sourceMappingURL=live-collab-BVLNJ5QI.js.map