@unhingged/vizu-core 0.1.0 → 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.
- package/dist/auto.cjs +330 -180
- package/dist/auto.cjs.map +1 -1
- package/dist/auto.js +94 -11
- package/dist/auto.js.map +1 -1
- package/dist/index.cjs +330 -180
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +94 -11
- package/dist/index.js.map +1 -1
- package/dist/{live-collab-IRUNFAPE.js → live-collab-BVLNJ5QI.js} +237 -172
- package/dist/live-collab-BVLNJ5QI.js.map +1 -0
- package/dist/vizu.min.js +107 -109
- package/dist/vizu.min.js.map +1 -1
- package/package.json +24 -27
- package/dist/live-collab-IRUNFAPE.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -770,6 +770,11 @@ __export(live_collab_exports, {
|
|
|
770
770
|
LiveCollab: () => LiveCollab,
|
|
771
771
|
roomIdFor: () => roomIdFor
|
|
772
772
|
});
|
|
773
|
+
function generateConnId() {
|
|
774
|
+
const a = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
775
|
+
const b = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
776
|
+
return `${a}${b}`;
|
|
777
|
+
}
|
|
773
778
|
function fnv1a32Hex(input) {
|
|
774
779
|
let h = 2166136261;
|
|
775
780
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -839,29 +844,27 @@ function labelForStatus(status) {
|
|
|
839
844
|
return "";
|
|
840
845
|
}
|
|
841
846
|
}
|
|
842
|
-
var
|
|
847
|
+
var PUSH_INTERVAL_MS, PULL_INTERVAL_MS, INACTIVITY_FADE_MS, CURSOR_TRANSITION_MS, Z_INDEX3, FOLLOW_SCROLL_THRESHOLD_PX, RECONNECT_AFTER_ERRORS, DISCONNECT_AFTER_ERRORS, LiveCollab, ConnectionIndicator, FollowPill;
|
|
843
848
|
var init_live_collab = __esm({
|
|
844
849
|
"src/live-collab.ts"() {
|
|
845
850
|
"use strict";
|
|
846
|
-
import_client = require("@liveblocks/client");
|
|
847
851
|
init_presence_stack();
|
|
848
852
|
init_selection_overlay();
|
|
849
853
|
init_cursor_colors();
|
|
850
|
-
|
|
851
|
-
|
|
854
|
+
PUSH_INTERVAL_MS = 100;
|
|
855
|
+
PULL_INTERVAL_MS = 200;
|
|
852
856
|
INACTIVITY_FADE_MS = 5e3;
|
|
853
|
-
CURSOR_TRANSITION_MS =
|
|
857
|
+
CURSOR_TRANSITION_MS = 220;
|
|
854
858
|
Z_INDEX3 = 2147483641;
|
|
855
859
|
FOLLOW_SCROLL_THRESHOLD_PX = 8;
|
|
860
|
+
RECONNECT_AFTER_ERRORS = 2;
|
|
861
|
+
DISCONNECT_AFTER_ERRORS = 5;
|
|
856
862
|
LiveCollab = class {
|
|
857
863
|
constructor(opts) {
|
|
858
864
|
this.opts = opts;
|
|
859
|
-
this.client = null;
|
|
860
|
-
this.room = null;
|
|
861
|
-
this.leave = null;
|
|
862
865
|
this.container = null;
|
|
863
866
|
this.cursors = /* @__PURE__ */ new Map();
|
|
864
|
-
// keyed by connectionId
|
|
867
|
+
// keyed by connId now (was numeric connectionId)
|
|
865
868
|
this.presenceStack = null;
|
|
866
869
|
this.followPill = null;
|
|
867
870
|
this.selectionOverlay = null;
|
|
@@ -870,50 +873,36 @@ var init_live_collab = __esm({
|
|
|
870
873
|
this.visibilityListener = null;
|
|
871
874
|
this.keydownListener = null;
|
|
872
875
|
this.statusIndicator = null;
|
|
873
|
-
this.
|
|
874
|
-
this.
|
|
875
|
-
this.pendingCursor = null;
|
|
876
|
-
this.lastBroadcast = 0;
|
|
877
|
-
this.lastScrollBroadcast = 0;
|
|
878
|
-
this.unsubscribers = [];
|
|
876
|
+
this.pushTimer = null;
|
|
877
|
+
this.pullTimer = null;
|
|
879
878
|
this.destroyed = false;
|
|
879
|
+
/** Full room id, built once in start() from workspace + pageUrl. */
|
|
880
|
+
this.roomId = null;
|
|
881
|
+
/** Latest snapshot of other connections from /api/live/[room]/others. */
|
|
882
|
+
this.latestOthers = [];
|
|
883
|
+
/** Local presence — mouse + scroll + selection update this in place; pushTimer ships it. */
|
|
884
|
+
this.currentPresence = { cursor: null, scrollY: 0, selecting: null };
|
|
885
|
+
/** True when something changed since the last push — skip the POST otherwise. */
|
|
886
|
+
this.presenceDirty = false;
|
|
887
|
+
/** Connection-health tracker for the indicator. */
|
|
888
|
+
this.status = "initial";
|
|
889
|
+
this.consecutiveErrors = 0;
|
|
880
890
|
// Follow mode state
|
|
881
891
|
/** Clerk user id we're currently following, or null. */
|
|
882
892
|
this.followingUserId = null;
|
|
883
893
|
/** Last scrollY we received from the followee — for delta gating. */
|
|
884
894
|
this.lastFolloweeScrollY = null;
|
|
895
|
+
this.connId = generateConnId();
|
|
896
|
+
if (typeof window !== "undefined") {
|
|
897
|
+
this.currentPresence.scrollY = window.scrollY;
|
|
898
|
+
}
|
|
885
899
|
}
|
|
886
900
|
async start() {
|
|
887
901
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
888
902
|
if (this.destroyed) return;
|
|
889
903
|
try {
|
|
890
|
-
void this.opts.publicKey;
|
|
891
|
-
this.client = (0, import_client.createClient)({
|
|
892
|
-
authEndpoint: async (room2) => {
|
|
893
|
-
const res = await fetch(this.opts.authEndpoint, {
|
|
894
|
-
method: "POST",
|
|
895
|
-
headers: { "content-type": "application/json" },
|
|
896
|
-
credentials: "include",
|
|
897
|
-
body: JSON.stringify({ room: room2 })
|
|
898
|
-
});
|
|
899
|
-
if (!res.ok) {
|
|
900
|
-
throw new Error(`vizu-liveblocks-auth: HTTP ${res.status}`);
|
|
901
|
-
}
|
|
902
|
-
return res.json();
|
|
903
|
-
}
|
|
904
|
-
});
|
|
905
904
|
const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
|
|
906
|
-
|
|
907
|
-
if (!room) return;
|
|
908
|
-
const result = this.client.enterRoom(room, {
|
|
909
|
-
initialPresence: {
|
|
910
|
-
cursor: null,
|
|
911
|
-
scrollY: typeof window !== "undefined" ? window.scrollY : 0,
|
|
912
|
-
selecting: null
|
|
913
|
-
}
|
|
914
|
-
});
|
|
915
|
-
this.room = result.room;
|
|
916
|
-
this.leave = result.leave;
|
|
905
|
+
this.roomId = roomIdFor(this.opts.workspaceSlug, pageUrl);
|
|
917
906
|
this.mountContainer();
|
|
918
907
|
this.presenceStack = new PresenceStack({
|
|
919
908
|
onAvatarClick: (userId) => this.setFollowing(userId)
|
|
@@ -926,12 +915,16 @@ var init_live_collab = __esm({
|
|
|
926
915
|
this.selectionOverlay.mount();
|
|
927
916
|
this.statusIndicator = new ConnectionIndicator();
|
|
928
917
|
this.statusIndicator.mount();
|
|
929
|
-
this.
|
|
930
|
-
this.subscribeStatus();
|
|
918
|
+
this.setStatus("connecting");
|
|
931
919
|
this.attachMouseListener();
|
|
932
920
|
this.attachScrollListener();
|
|
933
921
|
this.attachVisibilityListener();
|
|
934
922
|
this.attachKeyboardListener();
|
|
923
|
+
this.pushTimer = window.setInterval(() => void this.push(), PUSH_INTERVAL_MS);
|
|
924
|
+
this.pullTimer = window.setInterval(() => void this.pull(), PULL_INTERVAL_MS);
|
|
925
|
+
this.presenceDirty = true;
|
|
926
|
+
void this.push();
|
|
927
|
+
void this.pull();
|
|
935
928
|
} catch (err) {
|
|
936
929
|
if (typeof console !== "undefined") {
|
|
937
930
|
console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
|
|
@@ -945,6 +938,7 @@ var init_live_collab = __esm({
|
|
|
945
938
|
}
|
|
946
939
|
/* ────────────────── private ────────────────── */
|
|
947
940
|
cleanup() {
|
|
941
|
+
void this.clearRemotePresence();
|
|
948
942
|
if (this.mouseListener) {
|
|
949
943
|
window.removeEventListener("mousemove", this.mouseListener);
|
|
950
944
|
this.mouseListener = null;
|
|
@@ -961,16 +955,14 @@ var init_live_collab = __esm({
|
|
|
961
955
|
document.removeEventListener("keydown", this.keydownListener);
|
|
962
956
|
this.keydownListener = null;
|
|
963
957
|
}
|
|
964
|
-
if (this.
|
|
965
|
-
window.
|
|
966
|
-
this.
|
|
958
|
+
if (this.pushTimer != null) {
|
|
959
|
+
window.clearInterval(this.pushTimer);
|
|
960
|
+
this.pushTimer = null;
|
|
967
961
|
}
|
|
968
|
-
if (this.
|
|
969
|
-
window.
|
|
970
|
-
this.
|
|
962
|
+
if (this.pullTimer != null) {
|
|
963
|
+
window.clearInterval(this.pullTimer);
|
|
964
|
+
this.pullTimer = null;
|
|
971
965
|
}
|
|
972
|
-
for (const unsub of this.unsubscribers) unsub();
|
|
973
|
-
this.unsubscribers = [];
|
|
974
966
|
for (const c of this.cursors.values()) {
|
|
975
967
|
if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
|
|
976
968
|
c.el.remove();
|
|
@@ -988,10 +980,8 @@ var init_live_collab = __esm({
|
|
|
988
980
|
this.lastFolloweeScrollY = null;
|
|
989
981
|
this.container?.remove();
|
|
990
982
|
this.container = null;
|
|
991
|
-
this.
|
|
992
|
-
this.
|
|
993
|
-
this.room = null;
|
|
994
|
-
this.client = null;
|
|
983
|
+
this.roomId = null;
|
|
984
|
+
this.latestOthers = [];
|
|
995
985
|
}
|
|
996
986
|
/**
|
|
997
987
|
* Public API: broadcast the local user's currently-hovered element
|
|
@@ -1000,10 +990,8 @@ var init_live_collab = __esm({
|
|
|
1000
990
|
* Idempotent on no-op transitions. Slice 5 of live-collab.md.
|
|
1001
991
|
*/
|
|
1002
992
|
setLocalSelection(fingerprint2) {
|
|
1003
|
-
|
|
1004
|
-
this.
|
|
1005
|
-
selecting: fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null
|
|
1006
|
-
});
|
|
993
|
+
this.currentPresence.selecting = fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null;
|
|
994
|
+
this.presenceDirty = true;
|
|
1007
995
|
}
|
|
1008
996
|
/**
|
|
1009
997
|
* Public API: start/stop following a user by Clerk id. Null clears.
|
|
@@ -1019,30 +1007,24 @@ var init_live_collab = __esm({
|
|
|
1019
1007
|
if (userId) this.scrollToFolloweeIfKnown();
|
|
1020
1008
|
}
|
|
1021
1009
|
lookupUserForPill(userId) {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
for (const o of others) {
|
|
1025
|
-
const meta = o;
|
|
1026
|
-
if ((meta.id ?? `c${o.connectionId}`) === userId) {
|
|
1010
|
+
for (const o of this.latestOthers) {
|
|
1011
|
+
if (o.user.userId === userId) {
|
|
1027
1012
|
return {
|
|
1028
1013
|
id: userId,
|
|
1029
|
-
name:
|
|
1014
|
+
name: o.user.name ?? "Anonymous",
|
|
1030
1015
|
color: colorForUser(userId),
|
|
1031
|
-
avatar:
|
|
1016
|
+
avatar: o.user.avatar
|
|
1032
1017
|
};
|
|
1033
1018
|
}
|
|
1034
1019
|
}
|
|
1035
1020
|
return null;
|
|
1036
1021
|
}
|
|
1037
1022
|
scrollToFolloweeIfKnown() {
|
|
1038
|
-
if (!this.followingUserId
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
const presence = o.presence;
|
|
1044
|
-
if (typeof presence.scrollY === "number") {
|
|
1045
|
-
this.followScrollTo(presence.scrollY);
|
|
1023
|
+
if (!this.followingUserId) return;
|
|
1024
|
+
for (const o of this.latestOthers) {
|
|
1025
|
+
if (o.user.userId === this.followingUserId) {
|
|
1026
|
+
if (typeof o.presence.scrollY === "number") {
|
|
1027
|
+
this.followScrollTo(o.presence.scrollY);
|
|
1046
1028
|
}
|
|
1047
1029
|
return;
|
|
1048
1030
|
}
|
|
@@ -1068,39 +1050,27 @@ var init_live_collab = __esm({
|
|
|
1068
1050
|
}
|
|
1069
1051
|
attachMouseListener() {
|
|
1070
1052
|
this.mouseListener = (e) => {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1053
|
+
this.currentPresence.cursor = {
|
|
1054
|
+
xPct: e.clientX / window.innerWidth * 100,
|
|
1055
|
+
yPct: e.clientY / window.innerHeight * 100
|
|
1056
|
+
};
|
|
1057
|
+
this.presenceDirty = true;
|
|
1075
1058
|
};
|
|
1076
1059
|
window.addEventListener("mousemove", this.mouseListener, { passive: true });
|
|
1077
1060
|
}
|
|
1078
1061
|
attachScrollListener() {
|
|
1079
1062
|
this.scrollListener = () => {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (elapsed >= SCROLL_THROTTLE_MS) {
|
|
1083
|
-
this.flushScroll();
|
|
1084
|
-
return;
|
|
1085
|
-
}
|
|
1086
|
-
if (this.scrollThrottleTimer == null) {
|
|
1087
|
-
this.scrollThrottleTimer = window.setTimeout(() => {
|
|
1088
|
-
this.scrollThrottleTimer = null;
|
|
1089
|
-
this.flushScroll();
|
|
1090
|
-
}, SCROLL_THROTTLE_MS - elapsed);
|
|
1091
|
-
}
|
|
1063
|
+
this.currentPresence.scrollY = window.scrollY;
|
|
1064
|
+
this.presenceDirty = true;
|
|
1092
1065
|
};
|
|
1093
1066
|
window.addEventListener("scroll", this.scrollListener, { passive: true });
|
|
1094
1067
|
}
|
|
1095
|
-
flushScroll() {
|
|
1096
|
-
if (!this.room) return;
|
|
1097
|
-
this.room.updatePresence({ scrollY: window.scrollY });
|
|
1098
|
-
this.lastScrollBroadcast = performance.now();
|
|
1099
|
-
}
|
|
1100
1068
|
attachVisibilityListener() {
|
|
1101
1069
|
this.visibilityListener = () => {
|
|
1102
|
-
if (document.hidden
|
|
1103
|
-
this.
|
|
1070
|
+
if (document.hidden) {
|
|
1071
|
+
void this.clearRemotePresence();
|
|
1072
|
+
} else {
|
|
1073
|
+
this.presenceDirty = true;
|
|
1104
1074
|
}
|
|
1105
1075
|
};
|
|
1106
1076
|
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
@@ -1113,90 +1083,187 @@ var init_live_collab = __esm({
|
|
|
1113
1083
|
};
|
|
1114
1084
|
document.addEventListener("keydown", this.keydownListener);
|
|
1115
1085
|
}
|
|
1116
|
-
|
|
1117
|
-
if (
|
|
1118
|
-
this.
|
|
1119
|
-
|
|
1120
|
-
this.statusIndicator?.setStatus(status);
|
|
1121
|
-
});
|
|
1122
|
-
this.unsubscribers.push(unsub);
|
|
1086
|
+
setStatus(next) {
|
|
1087
|
+
if (this.status === next) return;
|
|
1088
|
+
this.status = next;
|
|
1089
|
+
this.statusIndicator?.setStatus(next);
|
|
1123
1090
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
if (this.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1091
|
+
/**
|
|
1092
|
+
* Push tick — POST current presence to /api/live/[room]/me. Skips
|
|
1093
|
+
* the request when nothing changed since last push, so an idle tab
|
|
1094
|
+
* generates near-zero traffic.
|
|
1095
|
+
*/
|
|
1096
|
+
async push() {
|
|
1097
|
+
if (this.destroyed || !this.roomId) return;
|
|
1098
|
+
if (!this.presenceDirty) return;
|
|
1099
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
1100
|
+
this.presenceDirty = false;
|
|
1101
|
+
try {
|
|
1102
|
+
const res = await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
|
|
1103
|
+
method: "POST",
|
|
1104
|
+
body: JSON.stringify({
|
|
1105
|
+
connId: this.connId,
|
|
1106
|
+
presence: this.currentPresence
|
|
1107
|
+
})
|
|
1108
|
+
});
|
|
1109
|
+
if (!res.ok) {
|
|
1110
|
+
if (res.status === 402 || res.status === 401 || res.status === 503) {
|
|
1111
|
+
this.setStatus("disconnected");
|
|
1112
|
+
if (this.pushTimer != null) {
|
|
1113
|
+
window.clearInterval(this.pushTimer);
|
|
1114
|
+
this.pushTimer = null;
|
|
1115
|
+
}
|
|
1116
|
+
if (this.pullTimer != null) {
|
|
1117
|
+
window.clearInterval(this.pullTimer);
|
|
1118
|
+
this.pullTimer = null;
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
throw new Error(`push HTTP ${res.status}`);
|
|
1123
|
+
}
|
|
1124
|
+
this.noteSuccess();
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
this.noteError(err);
|
|
1127
|
+
this.presenceDirty = true;
|
|
1136
1128
|
}
|
|
1137
1129
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const name = meta.info?.name ?? "Anonymous";
|
|
1157
|
-
const color = colorForUser(userId);
|
|
1158
|
-
if (!stackUsers.has(userId)) {
|
|
1159
|
-
stackUsers.set(userId, {
|
|
1160
|
-
id: userId,
|
|
1161
|
-
name,
|
|
1162
|
-
color,
|
|
1163
|
-
avatar: meta.info?.avatar
|
|
1164
|
-
});
|
|
1165
|
-
}
|
|
1166
|
-
const presence = other.presence;
|
|
1167
|
-
if (this.followingUserId === userId && followeeScrollY === null) {
|
|
1168
|
-
followeeStillPresent = true;
|
|
1169
|
-
if (typeof presence.scrollY === "number") {
|
|
1170
|
-
followeeScrollY = presence.scrollY;
|
|
1130
|
+
/**
|
|
1131
|
+
* Pull tick — GET /api/live/[room]/others and drive cursor + stack +
|
|
1132
|
+
* selection + follow updates. Same handler shape as the Liveblocks-era
|
|
1133
|
+
* subscribeOthers callback; only the data source changed.
|
|
1134
|
+
*/
|
|
1135
|
+
async pull() {
|
|
1136
|
+
if (this.destroyed || !this.roomId) return;
|
|
1137
|
+
try {
|
|
1138
|
+
const res = await this.fetchLive(
|
|
1139
|
+
`/api/live/${encodeURIComponent(this.roomId)}/others?me=${encodeURIComponent(this.connId)}`,
|
|
1140
|
+
{ method: "GET" }
|
|
1141
|
+
);
|
|
1142
|
+
if (!res.ok) {
|
|
1143
|
+
if (res.status === 503 || res.status === 401) {
|
|
1144
|
+
this.setStatus("disconnected");
|
|
1145
|
+
if (this.pullTimer != null) {
|
|
1146
|
+
window.clearInterval(this.pullTimer);
|
|
1147
|
+
this.pullTimer = null;
|
|
1171
1148
|
}
|
|
1149
|
+
return;
|
|
1172
1150
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1151
|
+
throw new Error(`pull HTTP ${res.status}`);
|
|
1152
|
+
}
|
|
1153
|
+
const body = await res.json();
|
|
1154
|
+
const others = (body.others ?? []).map((o) => ({
|
|
1155
|
+
connId: o.connId,
|
|
1156
|
+
presence: o.payload.presence,
|
|
1157
|
+
user: o.payload.user
|
|
1158
|
+
}));
|
|
1159
|
+
this.latestOthers = others;
|
|
1160
|
+
this.renderOthers(others);
|
|
1161
|
+
this.noteSuccess();
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
this.noteError(err);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
noteSuccess() {
|
|
1167
|
+
this.consecutiveErrors = 0;
|
|
1168
|
+
if (this.status !== "connected") this.setStatus("connected");
|
|
1169
|
+
}
|
|
1170
|
+
noteError(err) {
|
|
1171
|
+
this.consecutiveErrors++;
|
|
1172
|
+
if (this.consecutiveErrors >= DISCONNECT_AFTER_ERRORS) {
|
|
1173
|
+
this.setStatus("disconnected");
|
|
1174
|
+
} else if (this.consecutiveErrors >= RECONNECT_AFTER_ERRORS) {
|
|
1175
|
+
this.setStatus("reconnecting");
|
|
1176
|
+
}
|
|
1177
|
+
if (typeof console !== "undefined" && this.consecutiveErrors === 1) {
|
|
1178
|
+
console.warn("[vizu/live-collab] transport error", err);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Drive the cursor / stack / selection / follow layers from a fresh
|
|
1183
|
+
* `others` snapshot. Mirrors the body of the Liveblocks-era
|
|
1184
|
+
* subscribeOthers callback so DOM behavior is unchanged.
|
|
1185
|
+
*/
|
|
1186
|
+
renderOthers(others) {
|
|
1187
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1188
|
+
const stackUsers = /* @__PURE__ */ new Map();
|
|
1189
|
+
const selections = [];
|
|
1190
|
+
let followeeScrollY = null;
|
|
1191
|
+
let followeeStillPresent = false;
|
|
1192
|
+
for (const other of others) {
|
|
1193
|
+
seen.add(other.connId);
|
|
1194
|
+
const userId = other.user.userId;
|
|
1195
|
+
const name = other.user.name ?? "Anonymous";
|
|
1196
|
+
const color = colorForUser(userId);
|
|
1197
|
+
if (!stackUsers.has(userId)) {
|
|
1198
|
+
stackUsers.set(userId, { id: userId, name, color, avatar: other.user.avatar });
|
|
1199
|
+
}
|
|
1200
|
+
if (this.followingUserId === userId && followeeScrollY === null) {
|
|
1201
|
+
followeeStillPresent = true;
|
|
1202
|
+
if (typeof other.presence.scrollY === "number") {
|
|
1203
|
+
followeeScrollY = other.presence.scrollY;
|
|
1179
1204
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1205
|
+
}
|
|
1206
|
+
if (other.presence.selecting && !selections.find((s) => s.id === userId)) {
|
|
1207
|
+
try {
|
|
1208
|
+
const fp = JSON.parse(other.presence.selecting.fingerprintJson);
|
|
1209
|
+
selections.push({ id: userId, name, color, fingerprint: fp });
|
|
1210
|
+
} catch {
|
|
1183
1211
|
}
|
|
1184
|
-
this.upsertCursor(other.connectionId, userId, name, presence.cursor);
|
|
1185
1212
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1213
|
+
if (!other.presence.cursor) {
|
|
1214
|
+
this.removeCursor(other.connId);
|
|
1215
|
+
continue;
|
|
1188
1216
|
}
|
|
1189
|
-
this.
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1217
|
+
this.upsertCursor(other.connId, userId, name, other.presence.cursor);
|
|
1218
|
+
}
|
|
1219
|
+
for (const id of Array.from(this.cursors.keys())) {
|
|
1220
|
+
if (!seen.has(id)) this.removeCursor(id);
|
|
1221
|
+
}
|
|
1222
|
+
this.presenceStack?.setOthers(Array.from(stackUsers.values()));
|
|
1223
|
+
this.selectionOverlay?.setSelections(selections);
|
|
1224
|
+
if (this.followingUserId) {
|
|
1225
|
+
if (!followeeStillPresent) {
|
|
1226
|
+
this.setFollowing(null);
|
|
1227
|
+
} else if (followeeScrollY !== null) {
|
|
1228
|
+
this.followScrollTo(followeeScrollY);
|
|
1197
1229
|
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Async wrapper around fetch that injects the optional auth header
|
|
1234
|
+
* resolved from opts.getAuthHeader. Same-origin requests rely on
|
|
1235
|
+
* cookies via `credentials: 'include'`; cross-origin gets the
|
|
1236
|
+
* bearer token returned by the host.
|
|
1237
|
+
*/
|
|
1238
|
+
async fetchLive(path, init) {
|
|
1239
|
+
const headers = new Headers(init.headers);
|
|
1240
|
+
headers.set("content-type", "application/json");
|
|
1241
|
+
if (this.opts.getAuthHeader) {
|
|
1242
|
+
const extra = await this.opts.getAuthHeader();
|
|
1243
|
+
if (extra) {
|
|
1244
|
+
new Headers(extra).forEach((v, k) => headers.set(k, v));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return fetch(this.opts.apiUrl + path, {
|
|
1248
|
+
...init,
|
|
1249
|
+
headers,
|
|
1250
|
+
credentials: "include"
|
|
1198
1251
|
});
|
|
1199
|
-
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Fire-and-forget clearOnly POST so other clients see us drop within
|
|
1255
|
+
* one pull cycle (~200ms) instead of waiting for the 5s presence TTL.
|
|
1256
|
+
* Errors swallowed — the TTL is the safety net.
|
|
1257
|
+
*/
|
|
1258
|
+
async clearRemotePresence() {
|
|
1259
|
+
if (!this.roomId) return;
|
|
1260
|
+
try {
|
|
1261
|
+
await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
|
|
1262
|
+
method: "POST",
|
|
1263
|
+
body: JSON.stringify({ connId: this.connId, clearOnly: true })
|
|
1264
|
+
});
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1200
1267
|
}
|
|
1201
1268
|
upsertCursor(connectionId, userId, name, cursor) {
|
|
1202
1269
|
if (!this.container) return;
|
|
@@ -1724,10 +1791,40 @@ var CloudStorageAdapter = class {
|
|
|
1724
1791
|
this.cachedToken = null;
|
|
1725
1792
|
/** Single-flight: at most one popup at a time. Subsequent requests await this. */
|
|
1726
1793
|
this.pendingAuth = null;
|
|
1794
|
+
/** Whether we've already fired onAuthChanged for the current token. */
|
|
1795
|
+
this.lastNotifiedTokenId = null;
|
|
1727
1796
|
this.workspace = opts.workspace;
|
|
1728
1797
|
this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
1729
1798
|
this.autoSignIn = opts.autoSignIn !== false;
|
|
1799
|
+
this.onAuthChanged = opts.onAuthChanged;
|
|
1730
1800
|
this.cachedToken = this.readStoredToken();
|
|
1801
|
+
if (this.cachedToken && !this.isExpired(this.cachedToken)) {
|
|
1802
|
+
this.fireAuthChanged(this.cachedToken);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Open the sign-in popup right now (or no-op if a valid token is
|
|
1807
|
+
* cached). Vizu calls this on enable() so the user signs in BEFORE
|
|
1808
|
+
* trying to leave a comment — no surprise modal mid-comment.
|
|
1809
|
+
*
|
|
1810
|
+
* Throws auth_canceled if the user closes the popup. Caller can
|
|
1811
|
+
* decide whether to disable the surface or leave it in a read-only
|
|
1812
|
+
* state.
|
|
1813
|
+
*/
|
|
1814
|
+
async preflightAuth() {
|
|
1815
|
+
await this.resolveToken();
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Synchronous accessor for the currently cached bearer token, used
|
|
1819
|
+
* by sibling modules that need to authenticate against the same
|
|
1820
|
+
* backend (live-collab cross-origin polling). Returns null when no
|
|
1821
|
+
* valid token is cached — callers fall back to credentials:'include'
|
|
1822
|
+
* for same-origin or just disable themselves.
|
|
1823
|
+
*/
|
|
1824
|
+
getCachedAuthToken() {
|
|
1825
|
+
if (!this.cachedToken) return null;
|
|
1826
|
+
if (this.isExpired(this.cachedToken)) return null;
|
|
1827
|
+
return this.cachedToken.token;
|
|
1731
1828
|
}
|
|
1732
1829
|
/* ─── StorageAdapter v2 ───────────────────────────────────────────── */
|
|
1733
1830
|
async load(_namespace) {
|
|
@@ -1901,7 +1998,7 @@ var CloudStorageAdapter = class {
|
|
|
1901
1998
|
}
|
|
1902
1999
|
}
|
|
1903
2000
|
sessionKey() {
|
|
1904
|
-
return `vizu:cloud-token:${this.workspace}`;
|
|
2001
|
+
return `vizu:cloud-token:v2:${this.workspace}`;
|
|
1905
2002
|
}
|
|
1906
2003
|
/**
|
|
1907
2004
|
* Resolve a valid token, opening the popup if needed.
|
|
@@ -1916,6 +2013,7 @@ var CloudStorageAdapter = class {
|
|
|
1916
2013
|
const fresh = this.readStoredToken();
|
|
1917
2014
|
if (fresh) {
|
|
1918
2015
|
this.cachedToken = fresh;
|
|
2016
|
+
this.fireAuthChanged(fresh);
|
|
1919
2017
|
return fresh;
|
|
1920
2018
|
}
|
|
1921
2019
|
if (!this.autoSignIn) {
|
|
@@ -1924,6 +2022,7 @@ var CloudStorageAdapter = class {
|
|
|
1924
2022
|
if (!this.pendingAuth) {
|
|
1925
2023
|
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
1926
2024
|
this.writeStoredToken(t);
|
|
2025
|
+
this.fireAuthChanged(t);
|
|
1927
2026
|
return t;
|
|
1928
2027
|
}).finally(() => {
|
|
1929
2028
|
this.pendingAuth = null;
|
|
@@ -1931,6 +2030,22 @@ var CloudStorageAdapter = class {
|
|
|
1931
2030
|
}
|
|
1932
2031
|
return this.pendingAuth;
|
|
1933
2032
|
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Notify the host that the authenticated identity changed. De-duped
|
|
2035
|
+
* via the token string so multiple resolveToken() hits with the same
|
|
2036
|
+
* cached token don't re-call setUser on every fetch.
|
|
2037
|
+
*/
|
|
2038
|
+
fireAuthChanged(token) {
|
|
2039
|
+
if (this.lastNotifiedTokenId === token.token) return;
|
|
2040
|
+
this.lastNotifiedTokenId = token.token;
|
|
2041
|
+
try {
|
|
2042
|
+
this.onAuthChanged?.({ userId: token.userId, user: token.user });
|
|
2043
|
+
} catch (err) {
|
|
2044
|
+
if (typeof console !== "undefined") {
|
|
2045
|
+
console.warn("[vizu/cloud] onAuthChanged callback threw", err);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
1934
2049
|
openSignInPopup() {
|
|
1935
2050
|
return new Promise((resolve, reject) => {
|
|
1936
2051
|
if (typeof window === "undefined") {
|
|
@@ -1963,7 +2078,13 @@ var CloudStorageAdapter = class {
|
|
|
1963
2078
|
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
1964
2079
|
resolved = true;
|
|
1965
2080
|
cleanup();
|
|
1966
|
-
|
|
2081
|
+
const userPayload = data.user;
|
|
2082
|
+
const user = userPayload && typeof userPayload.name === "string" ? {
|
|
2083
|
+
id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
|
|
2084
|
+
name: userPayload.name,
|
|
2085
|
+
avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
|
|
2086
|
+
} : null;
|
|
2087
|
+
resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
|
|
1967
2088
|
};
|
|
1968
2089
|
const onPoll = window.setInterval(() => {
|
|
1969
2090
|
if (popup.closed) {
|
|
@@ -4064,7 +4185,7 @@ var DEFAULTS = {
|
|
|
4064
4185
|
namespace: "default",
|
|
4065
4186
|
accent: "#FF6647"
|
|
4066
4187
|
};
|
|
4067
|
-
function resolveStorage(storage, cloud, defaultMode) {
|
|
4188
|
+
function resolveStorage(storage, cloud, defaultMode, onAuthChanged) {
|
|
4068
4189
|
if (cloud) {
|
|
4069
4190
|
if (storage && typeof console !== "undefined") {
|
|
4070
4191
|
console.warn("[vizu] Both `cloud` and `storage` set; `cloud` wins. Drop `storage` to silence this.");
|
|
@@ -4072,7 +4193,8 @@ function resolveStorage(storage, cloud, defaultMode) {
|
|
|
4072
4193
|
return new CloudStorageAdapter({
|
|
4073
4194
|
workspace: cloud.workspace,
|
|
4074
4195
|
apiUrl: cloud.apiUrl,
|
|
4075
|
-
autoSignIn: cloud.autoSignIn
|
|
4196
|
+
autoSignIn: cloud.autoSignIn,
|
|
4197
|
+
onAuthChanged
|
|
4076
4198
|
});
|
|
4077
4199
|
}
|
|
4078
4200
|
if (!storage) return defaultMode === "local" ? new LocalStorageAdapter() : new InMemoryStorageAdapter();
|
|
@@ -4178,19 +4300,39 @@ var Vizu = class {
|
|
|
4178
4300
|
this.opts.namespace = options.namespace ?? DEFAULTS.namespace;
|
|
4179
4301
|
this.opts.accent = options.accent ?? DEFAULTS.accent;
|
|
4180
4302
|
this.parsedShortcut = parseShortcut(this.opts.shortcut);
|
|
4181
|
-
this.storage = resolveStorage(
|
|
4303
|
+
this.storage = resolveStorage(
|
|
4304
|
+
options.storage,
|
|
4305
|
+
options.cloud,
|
|
4306
|
+
_defaultStorage,
|
|
4307
|
+
(info) => {
|
|
4308
|
+
if (info.user) this.setUser(info.user);
|
|
4309
|
+
}
|
|
4310
|
+
);
|
|
4182
4311
|
this.user = options.user ?? null;
|
|
4183
4312
|
if (options.actions) this.actions = [...options.actions];
|
|
4184
4313
|
if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
|
|
4185
4314
|
if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
|
|
4186
|
-
if (options.
|
|
4187
|
-
const live = options.liveblocks;
|
|
4315
|
+
if (options.cloud) {
|
|
4188
4316
|
const cloud = options.cloud;
|
|
4317
|
+
const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
|
|
4189
4318
|
void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
|
|
4190
4319
|
this.liveCollab = new LiveCollab2({
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4320
|
+
workspaceSlug: cloud.workspace,
|
|
4321
|
+
apiUrl,
|
|
4322
|
+
getAuthHeader: async () => {
|
|
4323
|
+
if (typeof window === "undefined") return null;
|
|
4324
|
+
try {
|
|
4325
|
+
if (new URL(apiUrl).origin === window.location.origin) return null;
|
|
4326
|
+
} catch {
|
|
4327
|
+
return null;
|
|
4328
|
+
}
|
|
4329
|
+
const adapter = this.storage;
|
|
4330
|
+
if (adapter instanceof CloudStorageAdapter) {
|
|
4331
|
+
const token = adapter.getCachedAuthToken();
|
|
4332
|
+
if (token) return { Authorization: `Bearer ${token}` };
|
|
4333
|
+
}
|
|
4334
|
+
return null;
|
|
4335
|
+
}
|
|
4194
4336
|
});
|
|
4195
4337
|
void this.liveCollab.start();
|
|
4196
4338
|
});
|
|
@@ -4224,6 +4366,14 @@ var Vizu = class {
|
|
|
4224
4366
|
this.enabled = true;
|
|
4225
4367
|
this.bus.emit("enabled", {});
|
|
4226
4368
|
this.deferred(() => this.mount());
|
|
4369
|
+
if (this.storage instanceof CloudStorageAdapter) {
|
|
4370
|
+
void this.storage.preflightAuth().catch((err) => {
|
|
4371
|
+
if (err?.code === "auth_canceled" || err?.code === "popup_blocked") return;
|
|
4372
|
+
if (typeof console !== "undefined") {
|
|
4373
|
+
console.warn("[vizu] preflight auth failed:", err);
|
|
4374
|
+
}
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4227
4377
|
}
|
|
4228
4378
|
disable() {
|
|
4229
4379
|
if (!this.enabled) return;
|