@unhingged/vizu-core 0.1.2 → 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.
- package/dist/auto.cjs +663 -1850
- package/dist/auto.cjs.map +1 -1
- package/dist/auto.js +289 -57
- package/dist/auto.js.map +1 -1
- package/dist/index.cjs +662 -1849
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -39
- package/dist/index.d.ts +0 -39
- package/dist/index.js +289 -57
- package/dist/index.js.map +1 -1
- package/dist/vizu.min.js +55 -234
- package/dist/vizu.min.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-OMIFOSQ2.js +0 -295
- package/dist/chunk-OMIFOSQ2.js.map +0 -1
- package/dist/live-collab-BVLNJ5QI.js +0 -1098
- package/dist/live-collab-BVLNJ5QI.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -3,9 +3,6 @@ var __defProp = Object.defineProperty;
|
|
|
3
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __esm = (fn, res) => function __init() {
|
|
7
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
-
};
|
|
9
6
|
var __export = (target, all) => {
|
|
10
7
|
for (var name in all)
|
|
11
8
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -20,1579 +17,161 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
20
17
|
};
|
|
21
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
19
|
|
|
23
|
-
// src/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
CloudStorageAdapter: () => CloudStorageAdapter,
|
|
24
|
+
InMemoryStorageAdapter: () => InMemoryStorageAdapter,
|
|
25
|
+
LocalStorageAdapter: () => LocalStorageAdapter,
|
|
26
|
+
NullStorageAdapter: () => NullStorageAdapter,
|
|
27
|
+
SCHEMA_VERSION: () => SCHEMA_VERSION,
|
|
28
|
+
Vizu: () => Vizu,
|
|
29
|
+
findByFingerprint: () => findByFingerprint,
|
|
30
|
+
findElementByFingerprint: () => findElementByFingerprint,
|
|
31
|
+
fingerprint: () => fingerprint,
|
|
32
|
+
fingerprintKey: () => fingerprintKey,
|
|
33
|
+
fingerprintLabel: () => fingerprintLabel
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(src_exports);
|
|
36
|
+
|
|
37
|
+
// src/types.ts
|
|
38
|
+
var SCHEMA_VERSION = 2;
|
|
39
|
+
|
|
40
|
+
// src/util.ts
|
|
41
|
+
function uuid() {
|
|
42
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
43
|
+
return crypto.randomUUID();
|
|
47
44
|
}
|
|
48
|
-
return
|
|
45
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
49
46
|
}
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
function isInside(target, selector) {
|
|
48
|
+
return !!target.closest(selector);
|
|
49
|
+
}
|
|
50
|
+
function escapeHtml(s) {
|
|
51
|
+
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
52
|
+
}
|
|
53
|
+
function formatTime(ts) {
|
|
54
|
+
const d = new Date(ts);
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const diff = now - ts;
|
|
57
|
+
if (diff < 6e4) return "just now";
|
|
58
|
+
if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
|
|
59
|
+
if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
|
|
60
|
+
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
61
|
+
}
|
|
62
|
+
function initials(name) {
|
|
63
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
|
|
64
|
+
}
|
|
65
|
+
function avatarHtml(user, className = "vz-comment-author-avatar") {
|
|
66
|
+
if (!user) return `<span class="${className}">\xB7</span>`;
|
|
67
|
+
if (user.avatarUrl) {
|
|
68
|
+
return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
|
|
56
69
|
}
|
|
57
|
-
return {
|
|
58
|
-
selector: buildSelector(el),
|
|
59
|
-
parentSelector: parent ? buildSelector(parent) : "",
|
|
60
|
-
tagName: el.tagName,
|
|
61
|
-
textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
|
|
62
|
-
siblingIndex,
|
|
63
|
-
attributes: {
|
|
64
|
-
id: el.id || void 0,
|
|
65
|
-
classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
|
|
66
|
-
role: el.getAttribute("role") || void 0,
|
|
67
|
-
ariaLabel: el.getAttribute("aria-label") || void 0,
|
|
68
|
-
dataKey: el.getAttribute("data-vizu-key") || void 0
|
|
69
|
-
},
|
|
70
|
-
ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
|
|
71
|
-
algorithmVersion: 2
|
|
72
|
-
};
|
|
70
|
+
return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
|
|
73
71
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
function refId() {
|
|
73
|
+
return Math.random().toString(36).slice(2, 10);
|
|
74
|
+
}
|
|
75
|
+
var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
|
|
76
|
+
var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
|
|
77
|
+
function renderCommentText(text, commentId) {
|
|
78
|
+
const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
|
|
79
|
+
return parts.map((part) => {
|
|
80
|
+
const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
|
|
81
|
+
if (refMatch) {
|
|
82
|
+
return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
|
|
85
|
+
if (mentionMatch) {
|
|
86
|
+
return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
|
|
87
|
+
}
|
|
88
|
+
return escapeHtml(part);
|
|
89
|
+
}).join("");
|
|
90
|
+
}
|
|
91
|
+
function extractMentions(text) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const m of text.matchAll(MENTION_TOKEN_RE)) {
|
|
94
|
+
if (!out.includes(m[2])) out.push(m[2]);
|
|
86
95
|
}
|
|
87
|
-
return
|
|
96
|
+
return out;
|
|
88
97
|
}
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
function renderAttachmentsHtml(attachments) {
|
|
99
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return "";
|
|
100
|
+
const items = attachments.map((a) => {
|
|
101
|
+
const isImage = a.mimeType?.startsWith("image/");
|
|
102
|
+
const filename = a.filename ?? a.url.split("/").pop() ?? "file";
|
|
103
|
+
if (isImage) {
|
|
104
|
+
return `
|
|
105
|
+
<a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
|
|
106
|
+
<img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
|
|
107
|
+
</a>
|
|
108
|
+
`;
|
|
95
109
|
}
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
return `
|
|
111
|
+
<a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
|
|
112
|
+
<span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
|
|
113
|
+
<span class="vz-attachment-filename">${escapeHtml(filename)}</span>
|
|
114
|
+
</a>
|
|
115
|
+
`;
|
|
116
|
+
}).join("");
|
|
117
|
+
return `<div class="vz-attachment-grid">${items}</div>`;
|
|
118
|
+
}
|
|
119
|
+
function migrateComment(raw) {
|
|
120
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
121
|
+
const next = { ...raw };
|
|
122
|
+
if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
|
|
123
|
+
if (next.fingerprint) {
|
|
124
|
+
next.fingerprints = [next.fingerprint];
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const m = findByClassSignature(root, fp.tagName, classes);
|
|
109
|
-
if (m?.element) candidates.push({ el: m.element, rung: "class" });
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const bySelector = root.querySelector(fp.selector);
|
|
113
|
-
if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
|
|
114
|
-
} catch {
|
|
115
|
-
}
|
|
116
|
-
if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
|
|
117
|
-
const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
|
|
118
|
-
if (m?.element) candidates.push({ el: m.element, rung: "chain" });
|
|
119
|
-
} else {
|
|
120
|
-
try {
|
|
121
|
-
const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
|
|
122
|
-
if (parent) {
|
|
123
|
-
const candidate = parent.children[fp.siblingIndex];
|
|
124
|
-
if (candidate && candidate.tagName === fp.tagName) {
|
|
125
|
-
candidates.push({ el: candidate, rung: "chain" });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} catch {
|
|
129
|
-
}
|
|
127
|
+
delete next.fingerprint;
|
|
128
|
+
if (next.url && !next.pageUrl) {
|
|
129
|
+
next.pageUrl = next.url;
|
|
130
130
|
}
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
|
|
132
|
+
if (!Array.isArray(next.replies)) next.replies = [];
|
|
133
|
+
if (!Array.isArray(next.attachments)) next.attachments = [];
|
|
134
|
+
if (!Array.isArray(next.mentions)) next.mentions = [];
|
|
135
|
+
if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
|
|
136
|
+
next.schemaVersion = SCHEMA_VERSION;
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
function isValidStatus(s) {
|
|
140
|
+
return s === "open" || s === "resolved" || s === "wontfix";
|
|
141
|
+
}
|
|
142
|
+
function migrateComments(raws) {
|
|
143
|
+
if (!Array.isArray(raws)) return [];
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const raw of raws) {
|
|
146
|
+
if (!raw || typeof raw !== "object") continue;
|
|
147
|
+
out.push(migrateComment(raw));
|
|
134
148
|
}
|
|
135
|
-
|
|
136
|
-
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
function needsPersist(raws) {
|
|
152
|
+
if (!Array.isArray(raws)) return false;
|
|
153
|
+
for (const raw of raws) {
|
|
154
|
+
if (!raw || typeof raw !== "object") continue;
|
|
155
|
+
const c = raw;
|
|
156
|
+
if (c.schemaVersion !== SCHEMA_VERSION) return true;
|
|
157
|
+
if ("fingerprint" in c) return true;
|
|
158
|
+
if (!Array.isArray(c.replies)) return true;
|
|
159
|
+
if (!Array.isArray(c.attachments)) return true;
|
|
160
|
+
if (!Array.isArray(c.mentions)) return true;
|
|
137
161
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/storage.ts
|
|
166
|
+
var key = (ns) => `vizu:comments:${ns}`;
|
|
167
|
+
var LocalStorageAdapter = class {
|
|
168
|
+
async load(namespace) {
|
|
169
|
+
return readArray(namespace);
|
|
146
170
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!best) return { element: null, confidence: "orphaned" };
|
|
152
|
-
if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
|
|
153
|
-
if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
|
|
154
|
-
if (best.rungs.has("class") || best.rungs.has("chain")) {
|
|
155
|
-
return { element: best.el, confidence: "drifted" };
|
|
156
|
-
}
|
|
157
|
-
return { element: null, confidence: "orphaned" };
|
|
158
|
-
}
|
|
159
|
-
function findByClassSignature(root, tagName, needle) {
|
|
160
|
-
const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
|
|
161
|
-
if (needleSet.size === 0) return null;
|
|
162
|
-
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
163
|
-
let best = null;
|
|
164
|
-
let secondScore = 0;
|
|
165
|
-
for (const el of all) {
|
|
166
|
-
const haystack = new Set(
|
|
167
|
-
Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
|
|
168
|
-
);
|
|
169
|
-
if (haystack.size === 0) continue;
|
|
170
|
-
const score = jaccardSimilarity(needleSet, haystack);
|
|
171
|
-
if (score < CLASS_JACCARD_THRESHOLD) continue;
|
|
172
|
-
if (!best || score > best.score) {
|
|
173
|
-
secondScore = best?.score ?? 0;
|
|
174
|
-
best = { el, score };
|
|
175
|
-
} else if (score > secondScore) {
|
|
176
|
-
secondScore = score;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (!best) return null;
|
|
180
|
-
if (best.score - secondScore < 0.1) return null;
|
|
181
|
-
return { element: best.el, confidence: "likely" };
|
|
182
|
-
}
|
|
183
|
-
function findByAncestorChain(root, tagName, steps) {
|
|
184
|
-
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
185
|
-
let best = null;
|
|
186
|
-
for (const el of all) {
|
|
187
|
-
const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
|
|
188
|
-
const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
|
|
189
|
-
if (score < 2) continue;
|
|
190
|
-
if (!best || score > best.score) best = { el, score };
|
|
191
|
-
}
|
|
192
|
-
if (!best) return null;
|
|
193
|
-
return {
|
|
194
|
-
element: best.el,
|
|
195
|
-
confidence: best.score === steps.length ? "likely" : "drifted"
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
function collectAncestorSteps(el, limit) {
|
|
199
|
-
const steps = [];
|
|
200
|
-
let current = el.parentElement;
|
|
201
|
-
while (current && steps.length < limit) {
|
|
202
|
-
const parent = current.parentElement;
|
|
203
|
-
let nth = 1;
|
|
204
|
-
if (parent) {
|
|
205
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
206
|
-
nth = sameTag.indexOf(current) + 1;
|
|
207
|
-
}
|
|
208
|
-
steps.push({ tag: current.tagName, nthOfType: nth });
|
|
209
|
-
current = parent;
|
|
210
|
-
}
|
|
211
|
-
return steps;
|
|
212
|
-
}
|
|
213
|
-
function findByTextSnippet(root, tagName, snippet) {
|
|
214
|
-
const needle = normalizeText(snippet);
|
|
215
|
-
if (!needle) return null;
|
|
216
|
-
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
217
|
-
let best = null;
|
|
218
|
-
for (const el of all) {
|
|
219
|
-
const raw = el.innerText || el.textContent || "";
|
|
220
|
-
const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
|
|
221
|
-
if (!candidate) continue;
|
|
222
|
-
const score = levenshteinRatio(needle, candidate);
|
|
223
|
-
if (score < TEXT_RATIO_THRESHOLD) continue;
|
|
224
|
-
if (!best || score > best.score) {
|
|
225
|
-
best = { el, score };
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return best ? { element: best.el, confidence: "drifted" } : null;
|
|
229
|
-
}
|
|
230
|
-
function normalizeText(input) {
|
|
231
|
-
return input.toLowerCase().replace(/\s+/g, " ").trim();
|
|
232
|
-
}
|
|
233
|
-
function levenshteinRatio(a, b) {
|
|
234
|
-
if (a === b) return 1;
|
|
235
|
-
if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
|
|
236
|
-
const maxLen = Math.max(a.length, b.length);
|
|
237
|
-
const distance = levenshteinDistance(a, b);
|
|
238
|
-
return 1 - distance / maxLen;
|
|
239
|
-
}
|
|
240
|
-
function levenshteinDistance(a, b) {
|
|
241
|
-
if (a.length > b.length) {
|
|
242
|
-
const tmp = a;
|
|
243
|
-
a = b;
|
|
244
|
-
b = tmp;
|
|
245
|
-
}
|
|
246
|
-
const prev = new Array(a.length + 1);
|
|
247
|
-
const curr = new Array(a.length + 1);
|
|
248
|
-
for (let i = 0; i <= a.length; i++) prev[i] = i;
|
|
249
|
-
for (let j = 1; j <= b.length; j++) {
|
|
250
|
-
curr[0] = j;
|
|
251
|
-
for (let i = 1; i <= a.length; i++) {
|
|
252
|
-
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
253
|
-
curr[i] = Math.min(
|
|
254
|
-
curr[i - 1] + 1,
|
|
255
|
-
// insert
|
|
256
|
-
prev[i] + 1,
|
|
257
|
-
// delete
|
|
258
|
-
prev[i - 1] + cost
|
|
259
|
-
// substitute
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
|
|
263
|
-
}
|
|
264
|
-
return prev[a.length];
|
|
265
|
-
}
|
|
266
|
-
function scoreChainAgainstAncestors(actual, captured, maxShift) {
|
|
267
|
-
let best = 0;
|
|
268
|
-
for (let offset = 0; offset <= maxShift; offset++) {
|
|
269
|
-
let score = 0;
|
|
270
|
-
for (let i = 0; i < captured.length; i++) {
|
|
271
|
-
const a = actual[i + offset];
|
|
272
|
-
if (!a) break;
|
|
273
|
-
if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
|
|
274
|
-
score++;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (score > best) best = score;
|
|
278
|
-
}
|
|
279
|
-
return best;
|
|
280
|
-
}
|
|
281
|
-
function jaccardSimilarity(a, b) {
|
|
282
|
-
if (a.size === 0 && b.size === 0) return 1;
|
|
283
|
-
let intersection = 0;
|
|
284
|
-
for (const item of a) if (b.has(item)) intersection++;
|
|
285
|
-
const union = a.size + b.size - intersection;
|
|
286
|
-
return union === 0 ? 0 : intersection / union;
|
|
287
|
-
}
|
|
288
|
-
function findElementByFingerprint(fp, root) {
|
|
289
|
-
return findByFingerprint(fp, root).element;
|
|
290
|
-
}
|
|
291
|
-
function fingerprintKey(fp) {
|
|
292
|
-
return fp.selector + "|" + fp.textSnippet;
|
|
293
|
-
}
|
|
294
|
-
function fingerprintLabel(fp) {
|
|
295
|
-
const tag = fp.tagName.toLowerCase();
|
|
296
|
-
if (fp.textSnippet) {
|
|
297
|
-
const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
|
|
298
|
-
return tag + ": " + snippet;
|
|
299
|
-
}
|
|
300
|
-
if (fp.attributes.id) return tag + "#" + fp.attributes.id;
|
|
301
|
-
if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
|
|
302
|
-
return tag;
|
|
303
|
-
}
|
|
304
|
-
var TEXT_SNIPPET_MAX, ANCESTOR_DEPTH, ANCESTOR_MAX_SHIFT, TEXT_RATIO_THRESHOLD, CLASS_JACCARD_THRESHOLD;
|
|
305
|
-
var init_fingerprint = __esm({
|
|
306
|
-
"src/fingerprint.ts"() {
|
|
307
|
-
"use strict";
|
|
308
|
-
TEXT_SNIPPET_MAX = 80;
|
|
309
|
-
ANCESTOR_DEPTH = 4;
|
|
310
|
-
ANCESTOR_MAX_SHIFT = 3;
|
|
311
|
-
TEXT_RATIO_THRESHOLD = 0.75;
|
|
312
|
-
CLASS_JACCARD_THRESHOLD = 0.6;
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// src/cursor-colors.ts
|
|
317
|
-
function colorForUser(userId) {
|
|
318
|
-
let h = 0;
|
|
319
|
-
for (let i = 0; i < userId.length; i++) {
|
|
320
|
-
h = h * 31 + userId.charCodeAt(i) | 0;
|
|
321
|
-
}
|
|
322
|
-
return CURSOR_COLORS[Math.abs(h) % CURSOR_COLORS.length];
|
|
323
|
-
}
|
|
324
|
-
function prefersReducedMotion() {
|
|
325
|
-
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
326
|
-
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
327
|
-
}
|
|
328
|
-
var CURSOR_COLORS;
|
|
329
|
-
var init_cursor_colors = __esm({
|
|
330
|
-
"src/cursor-colors.ts"() {
|
|
331
|
-
"use strict";
|
|
332
|
-
CURSOR_COLORS = [
|
|
333
|
-
"#4F46E5",
|
|
334
|
-
// indigo
|
|
335
|
-
"#15803D",
|
|
336
|
-
// forest
|
|
337
|
-
"#C2410C",
|
|
338
|
-
// orange
|
|
339
|
-
"#BE185D",
|
|
340
|
-
// rose
|
|
341
|
-
"#0E7490",
|
|
342
|
-
// cyan
|
|
343
|
-
"#7E22CE",
|
|
344
|
-
// plum
|
|
345
|
-
"#DC2626",
|
|
346
|
-
// red
|
|
347
|
-
"#0F766E"
|
|
348
|
-
// teal
|
|
349
|
-
];
|
|
350
|
-
}
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// src/presence-stack.ts
|
|
354
|
-
function createAvatar(user, onClick) {
|
|
355
|
-
const wrap = document.createElement("div");
|
|
356
|
-
wrap.setAttribute("data-vizu-presence-avatar", user.id);
|
|
357
|
-
wrap.setAttribute("data-vizu-presence-name", user.name);
|
|
358
|
-
wrap.setAttribute("data-vizu-presence-color", user.color);
|
|
359
|
-
if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
|
|
360
|
-
const reduced = prefersReducedMotion();
|
|
361
|
-
const hoverTransition = reduced ? "none" : "transform 130ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 130ms ease-out";
|
|
362
|
-
wrap.style.cssText = `
|
|
363
|
-
position: relative;
|
|
364
|
-
width: ${AVATAR_SIZE}px;
|
|
365
|
-
height: ${AVATAR_SIZE}px;
|
|
366
|
-
margin-left: -${OVERLAP_PX}px;
|
|
367
|
-
border-radius: 50%;
|
|
368
|
-
background: ${user.color};
|
|
369
|
-
padding: 1.5px;
|
|
370
|
-
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
|
|
371
|
-
cursor: pointer;
|
|
372
|
-
flex-shrink: 0;
|
|
373
|
-
transition: ${hoverTransition};
|
|
374
|
-
`;
|
|
375
|
-
wrap.addEventListener("click", (e) => {
|
|
376
|
-
e.preventDefault();
|
|
377
|
-
e.stopPropagation();
|
|
378
|
-
onClick();
|
|
379
|
-
});
|
|
380
|
-
if (!reduced) {
|
|
381
|
-
wrap.addEventListener(
|
|
382
|
-
"mouseenter",
|
|
383
|
-
() => wrap.style.transform = "translateY(-1px) scale(1.06)"
|
|
384
|
-
);
|
|
385
|
-
wrap.addEventListener("mouseleave", () => wrap.style.transform = "");
|
|
386
|
-
}
|
|
387
|
-
const inner = document.createElement("div");
|
|
388
|
-
inner.setAttribute("data-vizu-presence-inner", "");
|
|
389
|
-
inner.style.cssText = `
|
|
390
|
-
width: 100%;
|
|
391
|
-
height: 100%;
|
|
392
|
-
border-radius: 50%;
|
|
393
|
-
overflow: hidden;
|
|
394
|
-
background: ${user.color};
|
|
395
|
-
color: #ffffff;
|
|
396
|
-
display: flex;
|
|
397
|
-
align-items: center;
|
|
398
|
-
justify-content: center;
|
|
399
|
-
font-size: 10.5px;
|
|
400
|
-
font-weight: 600;
|
|
401
|
-
letter-spacing: -0.01em;
|
|
402
|
-
line-height: 1;
|
|
403
|
-
user-select: none;
|
|
404
|
-
`;
|
|
405
|
-
paintAvatarContent(inner, user);
|
|
406
|
-
wrap.appendChild(inner);
|
|
407
|
-
const tip = createTooltip(user.name);
|
|
408
|
-
wrap.appendChild(tip);
|
|
409
|
-
wrap.addEventListener("mouseenter", () => {
|
|
410
|
-
tip.style.opacity = "1";
|
|
411
|
-
tip.style.transform = "translateY(0)";
|
|
412
|
-
});
|
|
413
|
-
wrap.addEventListener("mouseleave", () => {
|
|
414
|
-
tip.style.opacity = "0";
|
|
415
|
-
tip.style.transform = "translateY(-2px)";
|
|
416
|
-
});
|
|
417
|
-
return wrap;
|
|
418
|
-
}
|
|
419
|
-
function updateAvatar(wrap, user) {
|
|
420
|
-
const inner = wrap.querySelector("[data-vizu-presence-inner]");
|
|
421
|
-
if (inner) paintAvatarContent(inner, user);
|
|
422
|
-
const tip = wrap.querySelector("[data-vizu-presence-tip]");
|
|
423
|
-
if (tip) tip.textContent = user.name;
|
|
424
|
-
wrap.setAttribute("data-vizu-presence-name", user.name);
|
|
425
|
-
if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
|
|
426
|
-
else wrap.removeAttribute("data-vizu-presence-img");
|
|
427
|
-
}
|
|
428
|
-
function avatarMeta(wrap) {
|
|
429
|
-
const id = wrap.getAttribute("data-vizu-presence-avatar");
|
|
430
|
-
const name = wrap.getAttribute("data-vizu-presence-name");
|
|
431
|
-
const color = wrap.getAttribute("data-vizu-presence-color");
|
|
432
|
-
if (!id || !name || !color) return null;
|
|
433
|
-
const avatar = wrap.getAttribute("data-vizu-presence-img") ?? void 0;
|
|
434
|
-
return { id, name, color, avatar };
|
|
435
|
-
}
|
|
436
|
-
function paintAvatarFollowState(wrap, user, isFollowing) {
|
|
437
|
-
if (isFollowing) {
|
|
438
|
-
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)`;
|
|
439
|
-
} else {
|
|
440
|
-
wrap.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15)`;
|
|
441
|
-
}
|
|
442
|
-
void user;
|
|
443
|
-
}
|
|
444
|
-
function paintAvatarContent(inner, user) {
|
|
445
|
-
inner.textContent = "";
|
|
446
|
-
if (user.avatar) {
|
|
447
|
-
const img = document.createElement("img");
|
|
448
|
-
img.src = user.avatar;
|
|
449
|
-
img.alt = user.name;
|
|
450
|
-
img.referrerPolicy = "no-referrer";
|
|
451
|
-
img.style.cssText = "width: 100%; height: 100%; object-fit: cover; display: block;";
|
|
452
|
-
img.addEventListener(
|
|
453
|
-
"error",
|
|
454
|
-
() => {
|
|
455
|
-
img.remove();
|
|
456
|
-
inner.textContent = initialsOf(user.name);
|
|
457
|
-
},
|
|
458
|
-
{ once: true }
|
|
459
|
-
);
|
|
460
|
-
inner.appendChild(img);
|
|
461
|
-
} else {
|
|
462
|
-
inner.textContent = initialsOf(user.name);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
function createTooltip(text) {
|
|
466
|
-
const tip = document.createElement("div");
|
|
467
|
-
tip.setAttribute("data-vizu-presence-tip", "");
|
|
468
|
-
tip.textContent = text;
|
|
469
|
-
tip.style.cssText = `
|
|
470
|
-
position: absolute;
|
|
471
|
-
top: calc(100% + 6px);
|
|
472
|
-
right: 0;
|
|
473
|
-
background: rgba(10, 10, 10, 0.92);
|
|
474
|
-
color: #ffffff;
|
|
475
|
-
font-size: 11px;
|
|
476
|
-
font-weight: 500;
|
|
477
|
-
padding: 4px 8px;
|
|
478
|
-
border-radius: 4px;
|
|
479
|
-
white-space: nowrap;
|
|
480
|
-
pointer-events: none;
|
|
481
|
-
opacity: 0;
|
|
482
|
-
transform: translateY(-2px);
|
|
483
|
-
transition: opacity 130ms ease-out, transform 130ms ease-out;
|
|
484
|
-
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
|
|
485
|
-
max-width: 220px;
|
|
486
|
-
overflow: hidden;
|
|
487
|
-
text-overflow: ellipsis;
|
|
488
|
-
`;
|
|
489
|
-
return tip;
|
|
490
|
-
}
|
|
491
|
-
function createOverflowChip() {
|
|
492
|
-
const chip = document.createElement("div");
|
|
493
|
-
chip.setAttribute("data-vizu-presence-overflow", "");
|
|
494
|
-
chip.style.cssText = `
|
|
495
|
-
width: ${AVATAR_SIZE}px;
|
|
496
|
-
height: ${AVATAR_SIZE}px;
|
|
497
|
-
margin-left: -${OVERLAP_PX}px;
|
|
498
|
-
border-radius: 50%;
|
|
499
|
-
background: rgba(82, 82, 82, 0.95);
|
|
500
|
-
color: #ffffff;
|
|
501
|
-
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
|
|
502
|
-
display: flex;
|
|
503
|
-
align-items: center;
|
|
504
|
-
justify-content: center;
|
|
505
|
-
font-size: 10.5px;
|
|
506
|
-
font-weight: 600;
|
|
507
|
-
letter-spacing: -0.02em;
|
|
508
|
-
line-height: 1;
|
|
509
|
-
user-select: none;
|
|
510
|
-
flex-shrink: 0;
|
|
511
|
-
`;
|
|
512
|
-
return chip;
|
|
513
|
-
}
|
|
514
|
-
function initialsOf(name) {
|
|
515
|
-
const trimmed = name.trim();
|
|
516
|
-
if (!trimmed) return "?";
|
|
517
|
-
const parts = trimmed.split(/\s+/);
|
|
518
|
-
if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
|
|
519
|
-
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
520
|
-
}
|
|
521
|
-
return trimmed.slice(0, 2).toUpperCase();
|
|
522
|
-
}
|
|
523
|
-
var Z_INDEX, MAX_VISIBLE, AVATAR_SIZE, OVERLAP_PX, PresenceStack;
|
|
524
|
-
var init_presence_stack = __esm({
|
|
525
|
-
"src/presence-stack.ts"() {
|
|
526
|
-
"use strict";
|
|
527
|
-
init_cursor_colors();
|
|
528
|
-
Z_INDEX = 2147483640;
|
|
529
|
-
MAX_VISIBLE = 5;
|
|
530
|
-
AVATAR_SIZE = 28;
|
|
531
|
-
OVERLAP_PX = 6;
|
|
532
|
-
PresenceStack = class {
|
|
533
|
-
constructor(opts) {
|
|
534
|
-
this.container = null;
|
|
535
|
-
this.avatars = /* @__PURE__ */ new Map();
|
|
536
|
-
this.overflowEl = null;
|
|
537
|
-
this.followingUserId = null;
|
|
538
|
-
this.onAvatarClick = null;
|
|
539
|
-
this.onAvatarClick = opts?.onAvatarClick ?? null;
|
|
540
|
-
}
|
|
541
|
-
mount() {
|
|
542
|
-
if (typeof document === "undefined") return;
|
|
543
|
-
if (this.container) return;
|
|
544
|
-
this.container = document.createElement("div");
|
|
545
|
-
this.container.setAttribute("data-vizu-presence-stack", "");
|
|
546
|
-
this.container.style.cssText = `
|
|
547
|
-
position: fixed;
|
|
548
|
-
top: 16px;
|
|
549
|
-
right: 16px;
|
|
550
|
-
z-index: ${Z_INDEX};
|
|
551
|
-
display: flex;
|
|
552
|
-
align-items: center;
|
|
553
|
-
pointer-events: auto;
|
|
554
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
555
|
-
`;
|
|
556
|
-
document.body.appendChild(this.container);
|
|
557
|
-
}
|
|
558
|
-
destroy() {
|
|
559
|
-
for (const el of this.avatars.values()) el.remove();
|
|
560
|
-
this.avatars.clear();
|
|
561
|
-
this.overflowEl?.remove();
|
|
562
|
-
this.overflowEl = null;
|
|
563
|
-
this.container?.remove();
|
|
564
|
-
this.container = null;
|
|
565
|
-
}
|
|
566
|
-
setOthers(others) {
|
|
567
|
-
if (!this.container) return;
|
|
568
|
-
const visible = others.slice(0, MAX_VISIBLE);
|
|
569
|
-
const overflow = Math.max(0, others.length - visible.length);
|
|
570
|
-
const nextIds = new Set(visible.map((u) => u.id));
|
|
571
|
-
for (const [id, el] of Array.from(this.avatars)) {
|
|
572
|
-
if (!nextIds.has(id)) {
|
|
573
|
-
el.remove();
|
|
574
|
-
this.avatars.delete(id);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
for (const user of visible) {
|
|
578
|
-
let el = this.avatars.get(user.id);
|
|
579
|
-
if (!el) {
|
|
580
|
-
el = createAvatar(user, () => this.onAvatarClick?.(user.id));
|
|
581
|
-
this.avatars.set(user.id, el);
|
|
582
|
-
} else {
|
|
583
|
-
updateAvatar(el, user);
|
|
584
|
-
}
|
|
585
|
-
paintAvatarFollowState(el, user, this.followingUserId === user.id);
|
|
586
|
-
this.container.appendChild(el);
|
|
587
|
-
}
|
|
588
|
-
this.renderOverflow(overflow);
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Mark a user as the active followee so its avatar can render the
|
|
592
|
-
* coral ring highlight. Pass null to clear.
|
|
593
|
-
*/
|
|
594
|
-
setFollowing(userId) {
|
|
595
|
-
this.followingUserId = userId;
|
|
596
|
-
for (const [id, el] of this.avatars) {
|
|
597
|
-
const user = avatarMeta(el);
|
|
598
|
-
if (user) paintAvatarFollowState(el, user, id === userId);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
/* ────────────────── private ────────────────── */
|
|
602
|
-
renderOverflow(count) {
|
|
603
|
-
if (!this.container) return;
|
|
604
|
-
if (count <= 0) {
|
|
605
|
-
this.overflowEl?.remove();
|
|
606
|
-
this.overflowEl = null;
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
if (!this.overflowEl) {
|
|
610
|
-
this.overflowEl = createOverflowChip();
|
|
611
|
-
}
|
|
612
|
-
this.overflowEl.textContent = `+${count}`;
|
|
613
|
-
this.container.appendChild(this.overflowEl);
|
|
614
|
-
}
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// src/selection-overlay.ts
|
|
620
|
-
function createOverlay(color, name) {
|
|
621
|
-
const el = document.createElement("div");
|
|
622
|
-
el.setAttribute("data-vizu-selection-overlay", "");
|
|
623
|
-
const positionTransition = prefersReducedMotion() ? "none" : "left 90ms ease-out, top 90ms ease-out, width 90ms ease-out, height 90ms ease-out";
|
|
624
|
-
el.style.cssText = `
|
|
625
|
-
position: fixed;
|
|
626
|
-
pointer-events: none;
|
|
627
|
-
display: none;
|
|
628
|
-
border: 2px dashed ${color};
|
|
629
|
-
border-radius: 4px;
|
|
630
|
-
background: ${color}10;
|
|
631
|
-
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
|
|
632
|
-
transition: ${positionTransition};
|
|
633
|
-
will-change: left, top, width, height;
|
|
634
|
-
`;
|
|
635
|
-
const label = document.createElement("div");
|
|
636
|
-
label.setAttribute("data-selection-label", "");
|
|
637
|
-
label.textContent = name;
|
|
638
|
-
label.style.cssText = `
|
|
639
|
-
position: absolute;
|
|
640
|
-
top: -22px;
|
|
641
|
-
left: -2px;
|
|
642
|
-
padding: 2px 7px;
|
|
643
|
-
background: ${color};
|
|
644
|
-
color: #ffffff;
|
|
645
|
-
font: 500 10.5px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
646
|
-
border-radius: 3px 3px 3px 0;
|
|
647
|
-
white-space: nowrap;
|
|
648
|
-
max-width: 180px;
|
|
649
|
-
overflow: hidden;
|
|
650
|
-
text-overflow: ellipsis;
|
|
651
|
-
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
|
652
|
-
`;
|
|
653
|
-
el.appendChild(label);
|
|
654
|
-
return el;
|
|
655
|
-
}
|
|
656
|
-
var Z_INDEX2, SelectionOverlay;
|
|
657
|
-
var init_selection_overlay = __esm({
|
|
658
|
-
"src/selection-overlay.ts"() {
|
|
659
|
-
"use strict";
|
|
660
|
-
init_fingerprint();
|
|
661
|
-
init_cursor_colors();
|
|
662
|
-
Z_INDEX2 = 2147483639;
|
|
663
|
-
SelectionOverlay = class {
|
|
664
|
-
constructor() {
|
|
665
|
-
this.container = null;
|
|
666
|
-
this.overlays = /* @__PURE__ */ new Map();
|
|
667
|
-
this.scrollListener = null;
|
|
668
|
-
this.resizeListener = null;
|
|
669
|
-
this.rafScheduled = false;
|
|
670
|
-
}
|
|
671
|
-
mount() {
|
|
672
|
-
if (typeof document === "undefined") return;
|
|
673
|
-
if (this.container) return;
|
|
674
|
-
this.container = document.createElement("div");
|
|
675
|
-
this.container.setAttribute("data-vizu-selections", "");
|
|
676
|
-
this.container.style.cssText = `
|
|
677
|
-
position: fixed; inset: 0;
|
|
678
|
-
pointer-events: none;
|
|
679
|
-
z-index: ${Z_INDEX2};
|
|
680
|
-
contain: strict;
|
|
681
|
-
`;
|
|
682
|
-
document.body.appendChild(this.container);
|
|
683
|
-
this.scrollListener = () => this.scheduleReposition();
|
|
684
|
-
this.resizeListener = () => this.scheduleReposition();
|
|
685
|
-
window.addEventListener("scroll", this.scrollListener, { passive: true, capture: true });
|
|
686
|
-
window.addEventListener("resize", this.resizeListener);
|
|
687
|
-
}
|
|
688
|
-
destroy() {
|
|
689
|
-
if (this.scrollListener) {
|
|
690
|
-
window.removeEventListener("scroll", this.scrollListener, true);
|
|
691
|
-
this.scrollListener = null;
|
|
692
|
-
}
|
|
693
|
-
if (this.resizeListener) {
|
|
694
|
-
window.removeEventListener("resize", this.resizeListener);
|
|
695
|
-
this.resizeListener = null;
|
|
696
|
-
}
|
|
697
|
-
for (const e of this.overlays.values()) e.el.remove();
|
|
698
|
-
this.overlays.clear();
|
|
699
|
-
this.container?.remove();
|
|
700
|
-
this.container = null;
|
|
701
|
-
}
|
|
702
|
-
setSelections(selections) {
|
|
703
|
-
if (!this.container) return;
|
|
704
|
-
const nextIds = new Set(selections.map((s) => s.id));
|
|
705
|
-
for (const [id, entry] of Array.from(this.overlays)) {
|
|
706
|
-
if (!nextIds.has(id)) {
|
|
707
|
-
entry.el.remove();
|
|
708
|
-
this.overlays.delete(id);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
for (const sel of selections) {
|
|
712
|
-
let entry = this.overlays.get(sel.id);
|
|
713
|
-
if (!entry) {
|
|
714
|
-
const el = createOverlay(sel.color, sel.name);
|
|
715
|
-
this.container.appendChild(el);
|
|
716
|
-
entry = { el, fingerprint: sel.fingerprint, color: sel.color, name: sel.name };
|
|
717
|
-
this.overlays.set(sel.id, entry);
|
|
718
|
-
} else {
|
|
719
|
-
entry.fingerprint = sel.fingerprint;
|
|
720
|
-
if (entry.color !== sel.color) {
|
|
721
|
-
entry.color = sel.color;
|
|
722
|
-
entry.el.style.borderColor = sel.color;
|
|
723
|
-
}
|
|
724
|
-
if (entry.name !== sel.name) {
|
|
725
|
-
entry.name = sel.name;
|
|
726
|
-
const label = entry.el.querySelector("[data-selection-label]");
|
|
727
|
-
if (label) {
|
|
728
|
-
label.textContent = sel.name;
|
|
729
|
-
label.style.background = sel.color;
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
this.positionOverlay(entry);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
/* ────────────────── private ────────────────── */
|
|
737
|
-
positionOverlay(entry) {
|
|
738
|
-
const match = findByFingerprint(entry.fingerprint);
|
|
739
|
-
const target = match?.element ?? null;
|
|
740
|
-
if (!target) {
|
|
741
|
-
entry.el.style.display = "none";
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
const rect = target.getBoundingClientRect();
|
|
745
|
-
if (rect.width === 0 || rect.height === 0) {
|
|
746
|
-
entry.el.style.display = "none";
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
entry.el.style.display = "block";
|
|
750
|
-
entry.el.style.left = `${rect.left}px`;
|
|
751
|
-
entry.el.style.top = `${rect.top}px`;
|
|
752
|
-
entry.el.style.width = `${rect.width}px`;
|
|
753
|
-
entry.el.style.height = `${rect.height}px`;
|
|
754
|
-
}
|
|
755
|
-
scheduleReposition() {
|
|
756
|
-
if (this.rafScheduled) return;
|
|
757
|
-
this.rafScheduled = true;
|
|
758
|
-
requestAnimationFrame(() => {
|
|
759
|
-
this.rafScheduled = false;
|
|
760
|
-
for (const entry of this.overlays.values()) this.positionOverlay(entry);
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
// src/live-collab.ts
|
|
768
|
-
var live_collab_exports = {};
|
|
769
|
-
__export(live_collab_exports, {
|
|
770
|
-
LiveCollab: () => LiveCollab,
|
|
771
|
-
roomIdFor: () => roomIdFor
|
|
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
|
-
}
|
|
778
|
-
function fnv1a32Hex(input) {
|
|
779
|
-
let h = 2166136261;
|
|
780
|
-
for (let i = 0; i < input.length; i++) {
|
|
781
|
-
h ^= input.charCodeAt(i);
|
|
782
|
-
h = Math.imul(h, 16777619);
|
|
783
|
-
}
|
|
784
|
-
return (h >>> 0).toString(16).padStart(8, "0");
|
|
785
|
-
}
|
|
786
|
-
function roomIdFor(workspaceSlug, pageUrl) {
|
|
787
|
-
return `ws_${workspaceSlug}__pg_${fnv1a32Hex(pageUrl)}`;
|
|
788
|
-
}
|
|
789
|
-
function createCursorElement(name, color) {
|
|
790
|
-
const wrap = document.createElement("div");
|
|
791
|
-
wrap.setAttribute("data-vizu-cursor", "");
|
|
792
|
-
const transformTransition = prefersReducedMotion() ? "none" : `transform ${CURSOR_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`;
|
|
793
|
-
wrap.style.cssText = `
|
|
794
|
-
position: absolute; top: 0; left: 0;
|
|
795
|
-
pointer-events: none;
|
|
796
|
-
opacity: 0;
|
|
797
|
-
transform: translate(0px, 0px);
|
|
798
|
-
transition: ${transformTransition}, opacity 220ms ease-out;
|
|
799
|
-
will-change: transform;
|
|
800
|
-
`;
|
|
801
|
-
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
802
|
-
svg.setAttribute("width", "18");
|
|
803
|
-
svg.setAttribute("height", "20");
|
|
804
|
-
svg.setAttribute("viewBox", "0 0 16 18");
|
|
805
|
-
svg.style.cssText = "display: block; filter: drop-shadow(0 1px 1.5px rgba(0,0,0,0.3));";
|
|
806
|
-
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
807
|
-
path.setAttribute("d", "M0 0 L0 13 L4 9 L7 16 L9 15 L6 8 L11 8 Z");
|
|
808
|
-
path.setAttribute("fill", color);
|
|
809
|
-
path.setAttribute("stroke", "#ffffff");
|
|
810
|
-
path.setAttribute("stroke-width", "1.2");
|
|
811
|
-
path.setAttribute("stroke-linejoin", "round");
|
|
812
|
-
svg.appendChild(path);
|
|
813
|
-
wrap.appendChild(svg);
|
|
814
|
-
const label = document.createElement("div");
|
|
815
|
-
label.textContent = name;
|
|
816
|
-
label.style.cssText = `
|
|
817
|
-
margin-top: 2px;
|
|
818
|
-
margin-left: 10px;
|
|
819
|
-
padding: 2px 7px;
|
|
820
|
-
background: ${color};
|
|
821
|
-
color: #ffffff;
|
|
822
|
-
font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
823
|
-
border-radius: 4px;
|
|
824
|
-
white-space: nowrap;
|
|
825
|
-
box-shadow: 0 2px 6px rgba(0,0,0,0.18);
|
|
826
|
-
max-width: 160px;
|
|
827
|
-
overflow: hidden;
|
|
828
|
-
text-overflow: ellipsis;
|
|
829
|
-
`;
|
|
830
|
-
wrap.appendChild(label);
|
|
831
|
-
return wrap;
|
|
832
|
-
}
|
|
833
|
-
function labelForStatus(status) {
|
|
834
|
-
switch (status) {
|
|
835
|
-
case "connecting":
|
|
836
|
-
return "Connecting\u2026";
|
|
837
|
-
case "reconnecting":
|
|
838
|
-
return "Reconnecting\u2026";
|
|
839
|
-
case "disconnected":
|
|
840
|
-
return "Offline";
|
|
841
|
-
case "initial":
|
|
842
|
-
return "Connecting\u2026";
|
|
843
|
-
case "connected":
|
|
844
|
-
return "";
|
|
845
|
-
}
|
|
846
|
-
}
|
|
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;
|
|
848
|
-
var init_live_collab = __esm({
|
|
849
|
-
"src/live-collab.ts"() {
|
|
850
|
-
"use strict";
|
|
851
|
-
init_presence_stack();
|
|
852
|
-
init_selection_overlay();
|
|
853
|
-
init_cursor_colors();
|
|
854
|
-
PUSH_INTERVAL_MS = 100;
|
|
855
|
-
PULL_INTERVAL_MS = 200;
|
|
856
|
-
INACTIVITY_FADE_MS = 5e3;
|
|
857
|
-
CURSOR_TRANSITION_MS = 220;
|
|
858
|
-
Z_INDEX3 = 2147483641;
|
|
859
|
-
FOLLOW_SCROLL_THRESHOLD_PX = 8;
|
|
860
|
-
RECONNECT_AFTER_ERRORS = 2;
|
|
861
|
-
DISCONNECT_AFTER_ERRORS = 5;
|
|
862
|
-
LiveCollab = class {
|
|
863
|
-
constructor(opts) {
|
|
864
|
-
this.opts = opts;
|
|
865
|
-
this.container = null;
|
|
866
|
-
this.cursors = /* @__PURE__ */ new Map();
|
|
867
|
-
// keyed by connId now (was numeric connectionId)
|
|
868
|
-
this.presenceStack = null;
|
|
869
|
-
this.followPill = null;
|
|
870
|
-
this.selectionOverlay = null;
|
|
871
|
-
this.mouseListener = null;
|
|
872
|
-
this.scrollListener = null;
|
|
873
|
-
this.visibilityListener = null;
|
|
874
|
-
this.keydownListener = null;
|
|
875
|
-
this.statusIndicator = null;
|
|
876
|
-
this.pushTimer = null;
|
|
877
|
-
this.pullTimer = null;
|
|
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;
|
|
890
|
-
// Follow mode state
|
|
891
|
-
/** Clerk user id we're currently following, or null. */
|
|
892
|
-
this.followingUserId = null;
|
|
893
|
-
/** Last scrollY we received from the followee — for delta gating. */
|
|
894
|
-
this.lastFolloweeScrollY = null;
|
|
895
|
-
this.connId = generateConnId();
|
|
896
|
-
if (typeof window !== "undefined") {
|
|
897
|
-
this.currentPresence.scrollY = window.scrollY;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
async start() {
|
|
901
|
-
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
902
|
-
if (this.destroyed) return;
|
|
903
|
-
try {
|
|
904
|
-
const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
|
|
905
|
-
this.roomId = roomIdFor(this.opts.workspaceSlug, pageUrl);
|
|
906
|
-
this.mountContainer();
|
|
907
|
-
this.presenceStack = new PresenceStack({
|
|
908
|
-
onAvatarClick: (userId) => this.setFollowing(userId)
|
|
909
|
-
});
|
|
910
|
-
this.presenceStack.mount();
|
|
911
|
-
this.followPill = new FollowPill({
|
|
912
|
-
onStop: () => this.setFollowing(null)
|
|
913
|
-
});
|
|
914
|
-
this.selectionOverlay = new SelectionOverlay();
|
|
915
|
-
this.selectionOverlay.mount();
|
|
916
|
-
this.statusIndicator = new ConnectionIndicator();
|
|
917
|
-
this.statusIndicator.mount();
|
|
918
|
-
this.setStatus("connecting");
|
|
919
|
-
this.attachMouseListener();
|
|
920
|
-
this.attachScrollListener();
|
|
921
|
-
this.attachVisibilityListener();
|
|
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();
|
|
928
|
-
} catch (err) {
|
|
929
|
-
if (typeof console !== "undefined") {
|
|
930
|
-
console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
|
|
931
|
-
}
|
|
932
|
-
this.cleanup();
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
destroy() {
|
|
936
|
-
this.destroyed = true;
|
|
937
|
-
this.cleanup();
|
|
938
|
-
}
|
|
939
|
-
/* ────────────────── private ────────────────── */
|
|
940
|
-
cleanup() {
|
|
941
|
-
void this.clearRemotePresence();
|
|
942
|
-
if (this.mouseListener) {
|
|
943
|
-
window.removeEventListener("mousemove", this.mouseListener);
|
|
944
|
-
this.mouseListener = null;
|
|
945
|
-
}
|
|
946
|
-
if (this.scrollListener) {
|
|
947
|
-
window.removeEventListener("scroll", this.scrollListener);
|
|
948
|
-
this.scrollListener = null;
|
|
949
|
-
}
|
|
950
|
-
if (this.visibilityListener) {
|
|
951
|
-
document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
952
|
-
this.visibilityListener = null;
|
|
953
|
-
}
|
|
954
|
-
if (this.keydownListener) {
|
|
955
|
-
document.removeEventListener("keydown", this.keydownListener);
|
|
956
|
-
this.keydownListener = null;
|
|
957
|
-
}
|
|
958
|
-
if (this.pushTimer != null) {
|
|
959
|
-
window.clearInterval(this.pushTimer);
|
|
960
|
-
this.pushTimer = null;
|
|
961
|
-
}
|
|
962
|
-
if (this.pullTimer != null) {
|
|
963
|
-
window.clearInterval(this.pullTimer);
|
|
964
|
-
this.pullTimer = null;
|
|
965
|
-
}
|
|
966
|
-
for (const c of this.cursors.values()) {
|
|
967
|
-
if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
|
|
968
|
-
c.el.remove();
|
|
969
|
-
}
|
|
970
|
-
this.cursors.clear();
|
|
971
|
-
this.presenceStack?.destroy();
|
|
972
|
-
this.presenceStack = null;
|
|
973
|
-
this.followPill?.destroy();
|
|
974
|
-
this.followPill = null;
|
|
975
|
-
this.selectionOverlay?.destroy();
|
|
976
|
-
this.selectionOverlay = null;
|
|
977
|
-
this.statusIndicator?.destroy();
|
|
978
|
-
this.statusIndicator = null;
|
|
979
|
-
this.followingUserId = null;
|
|
980
|
-
this.lastFolloweeScrollY = null;
|
|
981
|
-
this.container?.remove();
|
|
982
|
-
this.container = null;
|
|
983
|
-
this.roomId = null;
|
|
984
|
-
this.latestOthers = [];
|
|
985
|
-
}
|
|
986
|
-
/**
|
|
987
|
-
* Public API: broadcast the local user's currently-hovered element
|
|
988
|
-
* (highlighter mode) so other clients can render a ghost outline on
|
|
989
|
-
* it. Pass null to clear (highlighter stopped, popover opened, etc.).
|
|
990
|
-
* Idempotent on no-op transitions. Slice 5 of live-collab.md.
|
|
991
|
-
*/
|
|
992
|
-
setLocalSelection(fingerprint2) {
|
|
993
|
-
this.currentPresence.selecting = fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null;
|
|
994
|
-
this.presenceDirty = true;
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Public API: start/stop following a user by Clerk id. Null clears.
|
|
998
|
-
* Called by the avatar click handler and by the FollowPill stop button.
|
|
999
|
-
* Idempotent — same id again is a no-op.
|
|
1000
|
-
*/
|
|
1001
|
-
setFollowing(userId) {
|
|
1002
|
-
if (this.followingUserId === userId) return;
|
|
1003
|
-
this.followingUserId = userId;
|
|
1004
|
-
this.lastFolloweeScrollY = null;
|
|
1005
|
-
this.presenceStack?.setFollowing(userId);
|
|
1006
|
-
this.followPill?.setFollowing(userId ? this.lookupUserForPill(userId) : null);
|
|
1007
|
-
if (userId) this.scrollToFolloweeIfKnown();
|
|
1008
|
-
}
|
|
1009
|
-
lookupUserForPill(userId) {
|
|
1010
|
-
for (const o of this.latestOthers) {
|
|
1011
|
-
if (o.user.userId === userId) {
|
|
1012
|
-
return {
|
|
1013
|
-
id: userId,
|
|
1014
|
-
name: o.user.name ?? "Anonymous",
|
|
1015
|
-
color: colorForUser(userId),
|
|
1016
|
-
avatar: o.user.avatar
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
return null;
|
|
1021
|
-
}
|
|
1022
|
-
scrollToFolloweeIfKnown() {
|
|
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);
|
|
1028
|
-
}
|
|
1029
|
-
return;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
followScrollTo(scrollY) {
|
|
1034
|
-
if (this.lastFolloweeScrollY !== null && Math.abs(this.lastFolloweeScrollY - scrollY) < FOLLOW_SCROLL_THRESHOLD_PX) {
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
this.lastFolloweeScrollY = scrollY;
|
|
1038
|
-
window.scrollTo({ top: scrollY, behavior: "smooth" });
|
|
1039
|
-
}
|
|
1040
|
-
mountContainer() {
|
|
1041
|
-
this.container = document.createElement("div");
|
|
1042
|
-
this.container.setAttribute("data-vizu-live-cursors", "");
|
|
1043
|
-
this.container.style.cssText = `
|
|
1044
|
-
position: fixed; inset: 0;
|
|
1045
|
-
pointer-events: none;
|
|
1046
|
-
z-index: ${Z_INDEX3};
|
|
1047
|
-
contain: strict;
|
|
1048
|
-
`;
|
|
1049
|
-
document.body.appendChild(this.container);
|
|
1050
|
-
}
|
|
1051
|
-
attachMouseListener() {
|
|
1052
|
-
this.mouseListener = (e) => {
|
|
1053
|
-
this.currentPresence.cursor = {
|
|
1054
|
-
xPct: e.clientX / window.innerWidth * 100,
|
|
1055
|
-
yPct: e.clientY / window.innerHeight * 100
|
|
1056
|
-
};
|
|
1057
|
-
this.presenceDirty = true;
|
|
1058
|
-
};
|
|
1059
|
-
window.addEventListener("mousemove", this.mouseListener, { passive: true });
|
|
1060
|
-
}
|
|
1061
|
-
attachScrollListener() {
|
|
1062
|
-
this.scrollListener = () => {
|
|
1063
|
-
this.currentPresence.scrollY = window.scrollY;
|
|
1064
|
-
this.presenceDirty = true;
|
|
1065
|
-
};
|
|
1066
|
-
window.addEventListener("scroll", this.scrollListener, { passive: true });
|
|
1067
|
-
}
|
|
1068
|
-
attachVisibilityListener() {
|
|
1069
|
-
this.visibilityListener = () => {
|
|
1070
|
-
if (document.hidden) {
|
|
1071
|
-
void this.clearRemotePresence();
|
|
1072
|
-
} else {
|
|
1073
|
-
this.presenceDirty = true;
|
|
1074
|
-
}
|
|
1075
|
-
};
|
|
1076
|
-
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
1077
|
-
}
|
|
1078
|
-
attachKeyboardListener() {
|
|
1079
|
-
this.keydownListener = (e) => {
|
|
1080
|
-
if (e.key !== "Escape") return;
|
|
1081
|
-
if (!this.followingUserId) return;
|
|
1082
|
-
this.setFollowing(null);
|
|
1083
|
-
};
|
|
1084
|
-
document.addEventListener("keydown", this.keydownListener);
|
|
1085
|
-
}
|
|
1086
|
-
setStatus(next) {
|
|
1087
|
-
if (this.status === next) return;
|
|
1088
|
-
this.status = next;
|
|
1089
|
-
this.statusIndicator?.setStatus(next);
|
|
1090
|
-
}
|
|
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;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
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;
|
|
1148
|
-
}
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
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;
|
|
1204
|
-
}
|
|
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 {
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
if (!other.presence.cursor) {
|
|
1214
|
-
this.removeCursor(other.connId);
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
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);
|
|
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"
|
|
1251
|
-
});
|
|
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
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
upsertCursor(connectionId, userId, name, cursor) {
|
|
1269
|
-
if (!this.container) return;
|
|
1270
|
-
let entry = this.cursors.get(connectionId);
|
|
1271
|
-
if (!entry) {
|
|
1272
|
-
const color = colorForUser(userId);
|
|
1273
|
-
const el = createCursorElement(name, color);
|
|
1274
|
-
this.container.appendChild(el);
|
|
1275
|
-
entry = { el, fadeTimer: null };
|
|
1276
|
-
this.cursors.set(connectionId, entry);
|
|
1277
|
-
}
|
|
1278
|
-
const inViewport = cursor.xPct >= 0 && cursor.xPct <= 100 && cursor.yPct >= 0 && cursor.yPct <= 100;
|
|
1279
|
-
if (!inViewport) {
|
|
1280
|
-
entry.el.style.opacity = "0";
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
const x = window.innerWidth * cursor.xPct / 100;
|
|
1284
|
-
const y = window.innerHeight * cursor.yPct / 100;
|
|
1285
|
-
entry.el.style.transform = `translate(${x}px, ${y}px)`;
|
|
1286
|
-
entry.el.style.opacity = "1";
|
|
1287
|
-
if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
|
|
1288
|
-
entry.fadeTimer = window.setTimeout(() => {
|
|
1289
|
-
if (entry) entry.el.style.opacity = "0";
|
|
1290
|
-
}, INACTIVITY_FADE_MS);
|
|
1291
|
-
}
|
|
1292
|
-
removeCursor(connectionId) {
|
|
1293
|
-
const entry = this.cursors.get(connectionId);
|
|
1294
|
-
if (!entry) return;
|
|
1295
|
-
if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
|
|
1296
|
-
entry.el.style.opacity = "0";
|
|
1297
|
-
window.setTimeout(() => entry.el.remove(), 220);
|
|
1298
|
-
this.cursors.delete(connectionId);
|
|
1299
|
-
}
|
|
1300
|
-
};
|
|
1301
|
-
ConnectionIndicator = class {
|
|
1302
|
-
constructor() {
|
|
1303
|
-
this.el = null;
|
|
1304
|
-
this.label = null;
|
|
1305
|
-
}
|
|
1306
|
-
mount() {
|
|
1307
|
-
if (typeof document === "undefined") return;
|
|
1308
|
-
if (this.el) return;
|
|
1309
|
-
this.el = document.createElement("div");
|
|
1310
|
-
this.el.setAttribute("data-vizu-connection-status", "");
|
|
1311
|
-
this.el.style.cssText = `
|
|
1312
|
-
position: fixed;
|
|
1313
|
-
top: 18px;
|
|
1314
|
-
right: 200px;
|
|
1315
|
-
z-index: ${Z_INDEX3};
|
|
1316
|
-
display: none;
|
|
1317
|
-
align-items: center;
|
|
1318
|
-
gap: 7px;
|
|
1319
|
-
padding: 4px 10px 4px 8px;
|
|
1320
|
-
background: rgba(10, 10, 10, 0.88);
|
|
1321
|
-
color: #fafafa;
|
|
1322
|
-
font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1323
|
-
border-radius: 999px;
|
|
1324
|
-
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
|
|
1325
|
-
pointer-events: none;
|
|
1326
|
-
`;
|
|
1327
|
-
const dot = document.createElement("span");
|
|
1328
|
-
dot.style.cssText = `
|
|
1329
|
-
display: inline-block;
|
|
1330
|
-
width: 6px; height: 6px;
|
|
1331
|
-
border-radius: 50%;
|
|
1332
|
-
background: #F59E0B;
|
|
1333
|
-
box-shadow: 0 0 6px #F59E0B;
|
|
1334
|
-
`;
|
|
1335
|
-
const label = document.createElement("span");
|
|
1336
|
-
this.label = label;
|
|
1337
|
-
this.el.appendChild(dot);
|
|
1338
|
-
this.el.appendChild(label);
|
|
1339
|
-
document.body.appendChild(this.el);
|
|
1340
|
-
}
|
|
1341
|
-
setStatus(status) {
|
|
1342
|
-
if (!this.el || !this.label) return;
|
|
1343
|
-
if (status === "connected") {
|
|
1344
|
-
this.el.style.display = "none";
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
this.label.textContent = labelForStatus(status);
|
|
1348
|
-
this.el.style.display = "inline-flex";
|
|
1349
|
-
}
|
|
1350
|
-
destroy() {
|
|
1351
|
-
this.el?.remove();
|
|
1352
|
-
this.el = null;
|
|
1353
|
-
this.label = null;
|
|
1354
|
-
}
|
|
1355
|
-
};
|
|
1356
|
-
FollowPill = class {
|
|
1357
|
-
constructor(opts) {
|
|
1358
|
-
this.el = null;
|
|
1359
|
-
this.onStop = opts.onStop;
|
|
1360
|
-
}
|
|
1361
|
-
setFollowing(user) {
|
|
1362
|
-
if (typeof document === "undefined") return;
|
|
1363
|
-
if (!user) {
|
|
1364
|
-
this.el?.remove();
|
|
1365
|
-
this.el = null;
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
if (!this.el) this.el = this.mount();
|
|
1369
|
-
this.paint(user);
|
|
1370
|
-
}
|
|
1371
|
-
destroy() {
|
|
1372
|
-
this.el?.remove();
|
|
1373
|
-
this.el = null;
|
|
1374
|
-
}
|
|
1375
|
-
mount() {
|
|
1376
|
-
const pill = document.createElement("div");
|
|
1377
|
-
pill.setAttribute("data-vizu-follow-pill", "");
|
|
1378
|
-
pill.style.cssText = `
|
|
1379
|
-
position: fixed;
|
|
1380
|
-
top: 56px;
|
|
1381
|
-
right: 16px;
|
|
1382
|
-
z-index: ${Z_INDEX3};
|
|
1383
|
-
display: inline-flex;
|
|
1384
|
-
align-items: center;
|
|
1385
|
-
gap: 8px;
|
|
1386
|
-
padding: 6px 6px 6px 12px;
|
|
1387
|
-
background: rgba(10, 10, 10, 0.92);
|
|
1388
|
-
color: #ffffff;
|
|
1389
|
-
border-radius: 999px;
|
|
1390
|
-
font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1391
|
-
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
|
|
1392
|
-
pointer-events: auto;
|
|
1393
|
-
`;
|
|
1394
|
-
document.body.appendChild(pill);
|
|
1395
|
-
return pill;
|
|
1396
|
-
}
|
|
1397
|
-
paint(user) {
|
|
1398
|
-
if (!this.el) return;
|
|
1399
|
-
this.el.textContent = "";
|
|
1400
|
-
const dot = document.createElement("span");
|
|
1401
|
-
dot.style.cssText = `
|
|
1402
|
-
display: inline-block;
|
|
1403
|
-
width: 8px; height: 8px;
|
|
1404
|
-
border-radius: 50%;
|
|
1405
|
-
background: ${user.color};
|
|
1406
|
-
box-shadow: 0 0 8px ${user.color};
|
|
1407
|
-
`;
|
|
1408
|
-
this.el.appendChild(dot);
|
|
1409
|
-
const label = document.createElement("span");
|
|
1410
|
-
label.textContent = `Following ${user.name}`;
|
|
1411
|
-
label.style.cssText = "max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
|
|
1412
|
-
this.el.appendChild(label);
|
|
1413
|
-
const stop = document.createElement("button");
|
|
1414
|
-
stop.type = "button";
|
|
1415
|
-
stop.setAttribute("aria-label", `Stop following ${user.name}`);
|
|
1416
|
-
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>';
|
|
1417
|
-
stop.style.cssText = `
|
|
1418
|
-
display: inline-flex;
|
|
1419
|
-
align-items: center;
|
|
1420
|
-
justify-content: center;
|
|
1421
|
-
width: 22px; height: 22px;
|
|
1422
|
-
border: none;
|
|
1423
|
-
background: rgba(255, 255, 255, 0.12);
|
|
1424
|
-
color: #ffffff;
|
|
1425
|
-
border-radius: 50%;
|
|
1426
|
-
cursor: pointer;
|
|
1427
|
-
transition: background 120ms ease-out;
|
|
1428
|
-
`;
|
|
1429
|
-
stop.addEventListener("mouseenter", () => stop.style.background = "rgba(255, 255, 255, 0.22)");
|
|
1430
|
-
stop.addEventListener("mouseleave", () => stop.style.background = "rgba(255, 255, 255, 0.12)");
|
|
1431
|
-
stop.addEventListener("click", (e) => {
|
|
1432
|
-
e.preventDefault();
|
|
1433
|
-
this.onStop();
|
|
1434
|
-
});
|
|
1435
|
-
this.el.appendChild(stop);
|
|
1436
|
-
}
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
// src/index.ts
|
|
1442
|
-
var src_exports = {};
|
|
1443
|
-
__export(src_exports, {
|
|
1444
|
-
CloudStorageAdapter: () => CloudStorageAdapter,
|
|
1445
|
-
InMemoryStorageAdapter: () => InMemoryStorageAdapter,
|
|
1446
|
-
LocalStorageAdapter: () => LocalStorageAdapter,
|
|
1447
|
-
NullStorageAdapter: () => NullStorageAdapter,
|
|
1448
|
-
SCHEMA_VERSION: () => SCHEMA_VERSION,
|
|
1449
|
-
Vizu: () => Vizu,
|
|
1450
|
-
findByFingerprint: () => findByFingerprint,
|
|
1451
|
-
findElementByFingerprint: () => findElementByFingerprint,
|
|
1452
|
-
fingerprint: () => fingerprint,
|
|
1453
|
-
fingerprintKey: () => fingerprintKey,
|
|
1454
|
-
fingerprintLabel: () => fingerprintLabel
|
|
1455
|
-
});
|
|
1456
|
-
module.exports = __toCommonJS(src_exports);
|
|
1457
|
-
|
|
1458
|
-
// src/types.ts
|
|
1459
|
-
var SCHEMA_VERSION = 2;
|
|
1460
|
-
|
|
1461
|
-
// src/util.ts
|
|
1462
|
-
function uuid() {
|
|
1463
|
-
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1464
|
-
return crypto.randomUUID();
|
|
1465
|
-
}
|
|
1466
|
-
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1467
|
-
}
|
|
1468
|
-
function isInside(target, selector) {
|
|
1469
|
-
return !!target.closest(selector);
|
|
1470
|
-
}
|
|
1471
|
-
function escapeHtml(s) {
|
|
1472
|
-
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
1473
|
-
}
|
|
1474
|
-
function formatTime(ts) {
|
|
1475
|
-
const d = new Date(ts);
|
|
1476
|
-
const now = Date.now();
|
|
1477
|
-
const diff = now - ts;
|
|
1478
|
-
if (diff < 6e4) return "just now";
|
|
1479
|
-
if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
|
|
1480
|
-
if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
|
|
1481
|
-
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1482
|
-
}
|
|
1483
|
-
function initials(name) {
|
|
1484
|
-
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
|
|
1485
|
-
}
|
|
1486
|
-
function avatarHtml(user, className = "vz-comment-author-avatar") {
|
|
1487
|
-
if (!user) return `<span class="${className}">\xB7</span>`;
|
|
1488
|
-
if (user.avatarUrl) {
|
|
1489
|
-
return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
|
|
1490
|
-
}
|
|
1491
|
-
return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
|
|
1492
|
-
}
|
|
1493
|
-
function refId() {
|
|
1494
|
-
return Math.random().toString(36).slice(2, 10);
|
|
1495
|
-
}
|
|
1496
|
-
var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
|
|
1497
|
-
var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
|
|
1498
|
-
function renderCommentText(text, commentId) {
|
|
1499
|
-
const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
|
|
1500
|
-
return parts.map((part) => {
|
|
1501
|
-
const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
|
|
1502
|
-
if (refMatch) {
|
|
1503
|
-
return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
|
|
1504
|
-
}
|
|
1505
|
-
const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
|
|
1506
|
-
if (mentionMatch) {
|
|
1507
|
-
return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
|
|
1508
|
-
}
|
|
1509
|
-
return escapeHtml(part);
|
|
1510
|
-
}).join("");
|
|
1511
|
-
}
|
|
1512
|
-
function extractMentions(text) {
|
|
1513
|
-
const out = [];
|
|
1514
|
-
for (const m of text.matchAll(MENTION_TOKEN_RE)) {
|
|
1515
|
-
if (!out.includes(m[2])) out.push(m[2]);
|
|
1516
|
-
}
|
|
1517
|
-
return out;
|
|
1518
|
-
}
|
|
1519
|
-
function renderAttachmentsHtml(attachments) {
|
|
1520
|
-
if (!Array.isArray(attachments) || attachments.length === 0) return "";
|
|
1521
|
-
const items = attachments.map((a) => {
|
|
1522
|
-
const isImage = a.mimeType?.startsWith("image/");
|
|
1523
|
-
const filename = a.filename ?? a.url.split("/").pop() ?? "file";
|
|
1524
|
-
if (isImage) {
|
|
1525
|
-
return `
|
|
1526
|
-
<a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
|
|
1527
|
-
<img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
|
|
1528
|
-
</a>
|
|
1529
|
-
`;
|
|
1530
|
-
}
|
|
1531
|
-
return `
|
|
1532
|
-
<a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
|
|
1533
|
-
<span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
|
|
1534
|
-
<span class="vz-attachment-filename">${escapeHtml(filename)}</span>
|
|
1535
|
-
</a>
|
|
1536
|
-
`;
|
|
1537
|
-
}).join("");
|
|
1538
|
-
return `<div class="vz-attachment-grid">${items}</div>`;
|
|
1539
|
-
}
|
|
1540
|
-
function migrateComment(raw) {
|
|
1541
|
-
if (!raw || typeof raw !== "object") return raw;
|
|
1542
|
-
const next = { ...raw };
|
|
1543
|
-
if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
|
|
1544
|
-
if (next.fingerprint) {
|
|
1545
|
-
next.fingerprints = [next.fingerprint];
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
delete next.fingerprint;
|
|
1549
|
-
if (next.url && !next.pageUrl) {
|
|
1550
|
-
next.pageUrl = next.url;
|
|
1551
|
-
}
|
|
1552
|
-
if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
|
|
1553
|
-
if (!Array.isArray(next.replies)) next.replies = [];
|
|
1554
|
-
if (!Array.isArray(next.attachments)) next.attachments = [];
|
|
1555
|
-
if (!Array.isArray(next.mentions)) next.mentions = [];
|
|
1556
|
-
if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
|
|
1557
|
-
next.schemaVersion = SCHEMA_VERSION;
|
|
1558
|
-
return next;
|
|
1559
|
-
}
|
|
1560
|
-
function isValidStatus(s) {
|
|
1561
|
-
return s === "open" || s === "resolved" || s === "wontfix";
|
|
1562
|
-
}
|
|
1563
|
-
function migrateComments(raws) {
|
|
1564
|
-
if (!Array.isArray(raws)) return [];
|
|
1565
|
-
const out = [];
|
|
1566
|
-
for (const raw of raws) {
|
|
1567
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1568
|
-
out.push(migrateComment(raw));
|
|
1569
|
-
}
|
|
1570
|
-
return out;
|
|
1571
|
-
}
|
|
1572
|
-
function needsPersist(raws) {
|
|
1573
|
-
if (!Array.isArray(raws)) return false;
|
|
1574
|
-
for (const raw of raws) {
|
|
1575
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1576
|
-
const c = raw;
|
|
1577
|
-
if (c.schemaVersion !== SCHEMA_VERSION) return true;
|
|
1578
|
-
if ("fingerprint" in c) return true;
|
|
1579
|
-
if (!Array.isArray(c.replies)) return true;
|
|
1580
|
-
if (!Array.isArray(c.attachments)) return true;
|
|
1581
|
-
if (!Array.isArray(c.mentions)) return true;
|
|
1582
|
-
}
|
|
1583
|
-
return false;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
// src/storage.ts
|
|
1587
|
-
var key = (ns) => `vizu:comments:${ns}`;
|
|
1588
|
-
var LocalStorageAdapter = class {
|
|
1589
|
-
async load(namespace) {
|
|
1590
|
-
return readArray(namespace);
|
|
1591
|
-
}
|
|
1592
|
-
async addComment(namespace, comment) {
|
|
1593
|
-
const list = readArray(namespace);
|
|
1594
|
-
list.push(comment);
|
|
1595
|
-
writeArray(namespace, list);
|
|
171
|
+
async addComment(namespace, comment) {
|
|
172
|
+
const list = readArray(namespace);
|
|
173
|
+
list.push(comment);
|
|
174
|
+
writeArray(namespace, list);
|
|
1596
175
|
}
|
|
1597
176
|
async updateComment(namespace, id, patch) {
|
|
1598
177
|
const list = readArray(namespace);
|
|
@@ -1814,18 +393,6 @@ var CloudStorageAdapter = class {
|
|
|
1814
393
|
async preflightAuth() {
|
|
1815
394
|
await this.resolveToken();
|
|
1816
395
|
}
|
|
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;
|
|
1828
|
-
}
|
|
1829
396
|
/* ─── StorageAdapter v2 ───────────────────────────────────────────── */
|
|
1830
397
|
async load(_namespace) {
|
|
1831
398
|
const out = [];
|
|
@@ -1876,278 +443,562 @@ var CloudStorageAdapter = class {
|
|
|
1876
443
|
throw apiErr(res.status, json);
|
|
1877
444
|
}
|
|
1878
445
|
}
|
|
1879
|
-
async setAll(namespace, comments) {
|
|
1880
|
-
await this.clear(namespace);
|
|
1881
|
-
for (const c of comments) {
|
|
1882
|
-
await this.addComment(namespace, c);
|
|
446
|
+
async setAll(namespace, comments) {
|
|
447
|
+
await this.clear(namespace);
|
|
448
|
+
for (const c of comments) {
|
|
449
|
+
await this.addComment(namespace, c);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async clear(_namespace) {
|
|
453
|
+
const url = new URL(this.apiUrl + "/api/comments");
|
|
454
|
+
url.searchParams.set("workspace", this.workspace);
|
|
455
|
+
url.searchParams.set("all", "true");
|
|
456
|
+
const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
|
|
457
|
+
if (res.status === 404) return;
|
|
458
|
+
if (!res.ok) {
|
|
459
|
+
const json = await this.parseJson(res);
|
|
460
|
+
throw apiErr(res.status, json);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async addReply(_namespace, commentId, reply) {
|
|
464
|
+
const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
|
|
465
|
+
url.searchParams.set("workspace", this.workspace);
|
|
466
|
+
const res = await this.fetchAuthed(url.toString(), {
|
|
467
|
+
method: "POST",
|
|
468
|
+
headers: { "content-type": "application/json" },
|
|
469
|
+
body: JSON.stringify(reply)
|
|
470
|
+
});
|
|
471
|
+
if (res.status === 404) return null;
|
|
472
|
+
const json = await this.parseJson(res);
|
|
473
|
+
if (!res.ok) throw apiErr(res.status, json);
|
|
474
|
+
return json?.comment ? migrateComment(json.comment) : null;
|
|
475
|
+
}
|
|
476
|
+
async removeReply(_namespace, commentId, replyId) {
|
|
477
|
+
const url = new URL(
|
|
478
|
+
this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
|
|
479
|
+
);
|
|
480
|
+
url.searchParams.set("workspace", this.workspace);
|
|
481
|
+
const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
|
|
482
|
+
if (res.status === 404) return null;
|
|
483
|
+
if (!res.ok) {
|
|
484
|
+
const json = await this.parseJson(res);
|
|
485
|
+
throw apiErr(res.status, json);
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Upload a file as multipart/form-data to the host backend, which
|
|
491
|
+
* forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
|
|
492
|
+
* function body cap (our own 5 MB attachment cap will be enforced
|
|
493
|
+
* server-side before that's hit).
|
|
494
|
+
*
|
|
495
|
+
* Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
|
|
496
|
+
* - keeps `@vizu/core` free of any `@vercel/blob` dependency
|
|
497
|
+
* (script-tag and local-only consumers don't pull blob code)
|
|
498
|
+
* - the multipart path is one round-trip; the direct-upload flow
|
|
499
|
+
* is two (signed URL + upload). For attachments capped at 5 MB,
|
|
500
|
+
* the savings don't justify the dep cost.
|
|
501
|
+
*/
|
|
502
|
+
async uploadAttachment(_namespace, file) {
|
|
503
|
+
const form = new FormData();
|
|
504
|
+
form.append("file", file, file.name);
|
|
505
|
+
const url = new URL(this.apiUrl + "/api/uploads");
|
|
506
|
+
url.searchParams.set("workspace", this.workspace);
|
|
507
|
+
const res = await this.fetchAuthed(url.toString(), {
|
|
508
|
+
method: "POST",
|
|
509
|
+
body: form
|
|
510
|
+
// Don't set content-type; the browser fills in the multipart boundary.
|
|
511
|
+
});
|
|
512
|
+
const json = await this.parseJson(res);
|
|
513
|
+
if (!res.ok) throw apiErr(res.status, json);
|
|
514
|
+
if (!json?.url || typeof json.url !== "string") {
|
|
515
|
+
throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
id: uuid(),
|
|
519
|
+
url: json.url,
|
|
520
|
+
mimeType: file.type || "application/octet-stream",
|
|
521
|
+
sizeBytes: file.size,
|
|
522
|
+
uploadedAt: Date.now(),
|
|
523
|
+
filename: file.name
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
getAuthContext() {
|
|
527
|
+
if (!this.cachedToken) return null;
|
|
528
|
+
return {
|
|
529
|
+
userId: this.cachedToken.userId,
|
|
530
|
+
token: this.cachedToken.token,
|
|
531
|
+
expiresAt: this.cachedToken.expiresAt
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/* ─── Auth ────────────────────────────────────────────────────────── */
|
|
535
|
+
isExpired(t) {
|
|
536
|
+
if (!t) return true;
|
|
537
|
+
const exp = new Date(t.expiresAt).getTime();
|
|
538
|
+
return !Number.isFinite(exp) || exp - Date.now() < 3e4;
|
|
539
|
+
}
|
|
540
|
+
readStoredToken() {
|
|
541
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
542
|
+
const raw = sessionStorage.getItem(this.sessionKey());
|
|
543
|
+
if (!raw) return null;
|
|
544
|
+
try {
|
|
545
|
+
const parsed = JSON.parse(raw);
|
|
546
|
+
if (this.isExpired(parsed)) {
|
|
547
|
+
sessionStorage.removeItem(this.sessionKey());
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
return parsed;
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
writeStoredToken(t) {
|
|
556
|
+
this.cachedToken = t;
|
|
557
|
+
if (typeof sessionStorage !== "undefined") {
|
|
558
|
+
sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
clearStoredToken() {
|
|
562
|
+
this.cachedToken = null;
|
|
563
|
+
if (typeof sessionStorage !== "undefined") {
|
|
564
|
+
sessionStorage.removeItem(this.sessionKey());
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
sessionKey() {
|
|
568
|
+
return `vizu:cloud-token:v2:${this.workspace}`;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Resolve a valid token, opening the popup if needed.
|
|
572
|
+
*
|
|
573
|
+
* Single-flight: simultaneous requests await one popup, not N.
|
|
574
|
+
* Caller-friendly: throws a Error with `.code === 'auth_canceled'`
|
|
575
|
+
* if the user closes the popup; hosts can catch this and present
|
|
576
|
+
* a friendly message instead of a blank failure.
|
|
577
|
+
*/
|
|
578
|
+
async resolveToken() {
|
|
579
|
+
if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
|
|
580
|
+
const fresh = this.readStoredToken();
|
|
581
|
+
if (fresh) {
|
|
582
|
+
this.cachedToken = fresh;
|
|
583
|
+
this.fireAuthChanged(fresh);
|
|
584
|
+
return fresh;
|
|
585
|
+
}
|
|
586
|
+
if (!this.autoSignIn) {
|
|
587
|
+
throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
|
|
588
|
+
}
|
|
589
|
+
if (!this.pendingAuth) {
|
|
590
|
+
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
591
|
+
this.writeStoredToken(t);
|
|
592
|
+
this.fireAuthChanged(t);
|
|
593
|
+
return t;
|
|
594
|
+
}).finally(() => {
|
|
595
|
+
this.pendingAuth = null;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return this.pendingAuth;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Notify the host that the authenticated identity changed. De-duped
|
|
602
|
+
* via the token string so multiple resolveToken() hits with the same
|
|
603
|
+
* cached token don't re-call setUser on every fetch.
|
|
604
|
+
*/
|
|
605
|
+
fireAuthChanged(token) {
|
|
606
|
+
if (this.lastNotifiedTokenId === token.token) return;
|
|
607
|
+
this.lastNotifiedTokenId = token.token;
|
|
608
|
+
try {
|
|
609
|
+
this.onAuthChanged?.({ userId: token.userId, user: token.user });
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (typeof console !== "undefined") {
|
|
612
|
+
console.warn("[vizu/cloud] onAuthChanged callback threw", err);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
openSignInPopup() {
|
|
617
|
+
return new Promise((resolve, reject) => {
|
|
618
|
+
if (typeof window === "undefined") {
|
|
619
|
+
return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
|
|
620
|
+
}
|
|
621
|
+
const apiOrigin = new URL(this.apiUrl).origin;
|
|
622
|
+
const hostOrigin = window.location.origin;
|
|
623
|
+
const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
|
|
624
|
+
const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
|
|
625
|
+
const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
|
|
626
|
+
const popup = window.open(
|
|
627
|
+
popupUrl,
|
|
628
|
+
"vizu-connect",
|
|
629
|
+
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
|
|
630
|
+
);
|
|
631
|
+
if (!popup) {
|
|
632
|
+
return reject(
|
|
633
|
+
makeAuthError(
|
|
634
|
+
"popup_blocked",
|
|
635
|
+
"Sign-in popup was blocked by the browser. Allow popups for this site and try again."
|
|
636
|
+
)
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
let resolved = false;
|
|
640
|
+
const onMessage = (e) => {
|
|
641
|
+
if (e.origin !== apiOrigin) return;
|
|
642
|
+
const data = e.data;
|
|
643
|
+
if (!data || data.type !== "vizu:auth") return;
|
|
644
|
+
if (data.workspace !== this.workspace) return;
|
|
645
|
+
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
646
|
+
resolved = true;
|
|
647
|
+
cleanup();
|
|
648
|
+
const userPayload = data.user;
|
|
649
|
+
const user = userPayload && typeof userPayload.name === "string" ? {
|
|
650
|
+
id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
|
|
651
|
+
name: userPayload.name,
|
|
652
|
+
avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
|
|
653
|
+
} : null;
|
|
654
|
+
resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
|
|
655
|
+
};
|
|
656
|
+
const onPoll = window.setInterval(() => {
|
|
657
|
+
if (popup.closed) {
|
|
658
|
+
if (!resolved) {
|
|
659
|
+
cleanup();
|
|
660
|
+
reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}, 500);
|
|
664
|
+
function cleanup() {
|
|
665
|
+
window.clearInterval(onPoll);
|
|
666
|
+
window.removeEventListener("message", onMessage);
|
|
667
|
+
}
|
|
668
|
+
window.addEventListener("message", onMessage);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
/* ─── Fetch wrapper ───────────────────────────────────────────────── */
|
|
672
|
+
/**
|
|
673
|
+
* Perform an authenticated fetch with one-shot retry on 401 (re-opens
|
|
674
|
+
* the popup if the token expired during the request). 4xx other than
|
|
675
|
+
* 401 / 404 throw; 5xx throws; 2xx returns.
|
|
676
|
+
*/
|
|
677
|
+
async fetchAuthed(url, init, retried = false) {
|
|
678
|
+
const token = await this.resolveToken();
|
|
679
|
+
const headers = new Headers(init.headers);
|
|
680
|
+
headers.set("authorization", `Bearer ${token.token}`);
|
|
681
|
+
const res = await fetch(url, { ...init, headers, credentials: "omit" });
|
|
682
|
+
if (res.status === 401 && !retried) {
|
|
683
|
+
this.clearStoredToken();
|
|
684
|
+
return this.fetchAuthed(url, init, true);
|
|
685
|
+
}
|
|
686
|
+
return res;
|
|
687
|
+
}
|
|
688
|
+
async parseJson(res) {
|
|
689
|
+
try {
|
|
690
|
+
const text = await res.text();
|
|
691
|
+
return text ? JSON.parse(text) : null;
|
|
692
|
+
} catch {
|
|
693
|
+
return null;
|
|
1883
694
|
}
|
|
1884
695
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
696
|
+
};
|
|
697
|
+
function makeAuthError(code, message) {
|
|
698
|
+
const err = new Error(message);
|
|
699
|
+
err.code = code;
|
|
700
|
+
return err;
|
|
701
|
+
}
|
|
702
|
+
function apiErr(status, body) {
|
|
703
|
+
const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
|
|
704
|
+
const err = new Error(body?.message ?? `HTTP ${status}`);
|
|
705
|
+
err.code = code;
|
|
706
|
+
err.status = status;
|
|
707
|
+
err.body = body;
|
|
708
|
+
return err;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/fingerprint.ts
|
|
712
|
+
var TEXT_SNIPPET_MAX = 80;
|
|
713
|
+
var ANCESTOR_DEPTH = 4;
|
|
714
|
+
var ANCESTOR_MAX_SHIFT = 3;
|
|
715
|
+
var TEXT_RATIO_THRESHOLD = 0.75;
|
|
716
|
+
var CLASS_JACCARD_THRESHOLD = 0.6;
|
|
717
|
+
function buildSelector(el) {
|
|
718
|
+
const parts = [];
|
|
719
|
+
let current = el;
|
|
720
|
+
let depth = 0;
|
|
721
|
+
while (current && current !== document.documentElement && depth < 8) {
|
|
722
|
+
let part = current.tagName.toLowerCase();
|
|
723
|
+
if (current.id) {
|
|
724
|
+
parts.unshift("#" + CSS.escape(current.id));
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
|
|
728
|
+
if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
|
|
729
|
+
const parent = current.parentElement;
|
|
730
|
+
if (parent) {
|
|
731
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
732
|
+
if (sameTag.length > 1) {
|
|
733
|
+
const idx = sameTag.indexOf(current);
|
|
734
|
+
part += ":nth-of-type(" + (idx + 1) + ")";
|
|
735
|
+
}
|
|
1894
736
|
}
|
|
737
|
+
parts.unshift(part);
|
|
738
|
+
current = parent;
|
|
739
|
+
depth++;
|
|
1895
740
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
if (res.status === 404) return null;
|
|
1905
|
-
const json = await this.parseJson(res);
|
|
1906
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1907
|
-
return json?.comment ? migrateComment(json.comment) : null;
|
|
741
|
+
return parts.join(" > ");
|
|
742
|
+
}
|
|
743
|
+
function fingerprint(el) {
|
|
744
|
+
const text = el.innerText || el.textContent || "";
|
|
745
|
+
const parent = el.parentElement;
|
|
746
|
+
let siblingIndex = 0;
|
|
747
|
+
if (parent) {
|
|
748
|
+
siblingIndex = Array.from(parent.children).indexOf(el);
|
|
1908
749
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
750
|
+
return {
|
|
751
|
+
selector: buildSelector(el),
|
|
752
|
+
parentSelector: parent ? buildSelector(parent) : "",
|
|
753
|
+
tagName: el.tagName,
|
|
754
|
+
textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
|
|
755
|
+
siblingIndex,
|
|
756
|
+
attributes: {
|
|
757
|
+
id: el.id || void 0,
|
|
758
|
+
classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
|
|
759
|
+
role: el.getAttribute("role") || void 0,
|
|
760
|
+
ariaLabel: el.getAttribute("aria-label") || void 0,
|
|
761
|
+
dataKey: el.getAttribute("data-vizu-key") || void 0
|
|
762
|
+
},
|
|
763
|
+
ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
|
|
764
|
+
algorithmVersion: 2
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function captureAncestorChain(el, depth) {
|
|
768
|
+
const steps = [];
|
|
769
|
+
let current = el.parentElement;
|
|
770
|
+
while (current && steps.length < depth) {
|
|
771
|
+
const parent = current.parentElement;
|
|
772
|
+
let nthOfType = 1;
|
|
773
|
+
if (parent) {
|
|
774
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
775
|
+
nthOfType = sameTag.indexOf(current) + 1;
|
|
1919
776
|
}
|
|
1920
|
-
|
|
777
|
+
steps.push({ tag: current.tagName, nthOfType });
|
|
778
|
+
current = parent;
|
|
1921
779
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
* (script-tag and local-only consumers don't pull blob code)
|
|
1931
|
-
* - the multipart path is one round-trip; the direct-upload flow
|
|
1932
|
-
* is two (signed URL + upload). For attachments capped at 5 MB,
|
|
1933
|
-
* the savings don't justify the dep cost.
|
|
1934
|
-
*/
|
|
1935
|
-
async uploadAttachment(_namespace, file) {
|
|
1936
|
-
const form = new FormData();
|
|
1937
|
-
form.append("file", file, file.name);
|
|
1938
|
-
const url = new URL(this.apiUrl + "/api/uploads");
|
|
1939
|
-
url.searchParams.set("workspace", this.workspace);
|
|
1940
|
-
const res = await this.fetchAuthed(url.toString(), {
|
|
1941
|
-
method: "POST",
|
|
1942
|
-
body: form
|
|
1943
|
-
// Don't set content-type; the browser fills in the multipart boundary.
|
|
1944
|
-
});
|
|
1945
|
-
const json = await this.parseJson(res);
|
|
1946
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1947
|
-
if (!json?.url || typeof json.url !== "string") {
|
|
1948
|
-
throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
|
|
780
|
+
return steps.length > 0 ? { steps } : void 0;
|
|
781
|
+
}
|
|
782
|
+
function findByFingerprint(fp, root = document) {
|
|
783
|
+
if (fp.attributes.dataKey) {
|
|
784
|
+
try {
|
|
785
|
+
const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
|
|
786
|
+
if (byKey) return { element: byKey, confidence: "exact" };
|
|
787
|
+
} catch {
|
|
1949
788
|
}
|
|
1950
|
-
return {
|
|
1951
|
-
id: uuid(),
|
|
1952
|
-
url: json.url,
|
|
1953
|
-
mimeType: file.type || "application/octet-stream",
|
|
1954
|
-
sizeBytes: file.size,
|
|
1955
|
-
uploadedAt: Date.now(),
|
|
1956
|
-
filename: file.name
|
|
1957
|
-
};
|
|
789
|
+
return { element: null, confidence: "orphaned" };
|
|
1958
790
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
};
|
|
791
|
+
if (fp.attributes.id) {
|
|
792
|
+
try {
|
|
793
|
+
const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
|
|
794
|
+
if (byId) return { element: byId, confidence: "exact" };
|
|
795
|
+
} catch {
|
|
796
|
+
}
|
|
1966
797
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
const
|
|
1971
|
-
|
|
798
|
+
const candidates = [];
|
|
799
|
+
const classes = fp.attributes.classList;
|
|
800
|
+
if (classes && classes.length > 0) {
|
|
801
|
+
const m = findByClassSignature(root, fp.tagName, classes);
|
|
802
|
+
if (m?.element) candidates.push({ el: m.element, rung: "class" });
|
|
1972
803
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
804
|
+
try {
|
|
805
|
+
const bySelector = root.querySelector(fp.selector);
|
|
806
|
+
if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
|
|
810
|
+
const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
|
|
811
|
+
if (m?.element) candidates.push({ el: m.element, rung: "chain" });
|
|
812
|
+
} else {
|
|
1977
813
|
try {
|
|
1978
|
-
const
|
|
1979
|
-
if (
|
|
1980
|
-
|
|
1981
|
-
|
|
814
|
+
const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
|
|
815
|
+
if (parent) {
|
|
816
|
+
const candidate = parent.children[fp.siblingIndex];
|
|
817
|
+
if (candidate && candidate.tagName === fp.tagName) {
|
|
818
|
+
candidates.push({ el: candidate, rung: "chain" });
|
|
819
|
+
}
|
|
1982
820
|
}
|
|
1983
|
-
return parsed;
|
|
1984
821
|
} catch {
|
|
1985
|
-
return null;
|
|
1986
822
|
}
|
|
1987
823
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
if (
|
|
1991
|
-
|
|
824
|
+
if (fp.textSnippet) {
|
|
825
|
+
const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
|
|
826
|
+
if (m?.element) candidates.push({ el: m.element, rung: "text" });
|
|
827
|
+
}
|
|
828
|
+
if (candidates.length === 0) {
|
|
829
|
+
return { element: null, confidence: "orphaned" };
|
|
830
|
+
}
|
|
831
|
+
const votes = /* @__PURE__ */ new Map();
|
|
832
|
+
for (const c of candidates) {
|
|
833
|
+
let s = votes.get(c.el);
|
|
834
|
+
if (!s) {
|
|
835
|
+
s = /* @__PURE__ */ new Set();
|
|
836
|
+
votes.set(c.el, s);
|
|
1992
837
|
}
|
|
838
|
+
s.add(c.rung);
|
|
1993
839
|
}
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
if (
|
|
1997
|
-
|
|
840
|
+
let best = null;
|
|
841
|
+
for (const [el, rungs] of votes) {
|
|
842
|
+
if (!best || rungs.size > best.rungs.size) best = { el, rungs };
|
|
843
|
+
}
|
|
844
|
+
if (!best) return { element: null, confidence: "orphaned" };
|
|
845
|
+
if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
|
|
846
|
+
if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
|
|
847
|
+
if (best.rungs.has("class") || best.rungs.has("chain")) {
|
|
848
|
+
return { element: best.el, confidence: "drifted" };
|
|
849
|
+
}
|
|
850
|
+
return { element: null, confidence: "orphaned" };
|
|
851
|
+
}
|
|
852
|
+
function findByClassSignature(root, tagName, needle) {
|
|
853
|
+
const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
|
|
854
|
+
if (needleSet.size === 0) return null;
|
|
855
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
856
|
+
let best = null;
|
|
857
|
+
let secondScore = 0;
|
|
858
|
+
for (const el of all) {
|
|
859
|
+
const haystack = new Set(
|
|
860
|
+
Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
|
|
861
|
+
);
|
|
862
|
+
if (haystack.size === 0) continue;
|
|
863
|
+
const score = jaccardSimilarity(needleSet, haystack);
|
|
864
|
+
if (score < CLASS_JACCARD_THRESHOLD) continue;
|
|
865
|
+
if (!best || score > best.score) {
|
|
866
|
+
secondScore = best?.score ?? 0;
|
|
867
|
+
best = { el, score };
|
|
868
|
+
} else if (score > secondScore) {
|
|
869
|
+
secondScore = score;
|
|
1998
870
|
}
|
|
1999
871
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
872
|
+
if (!best) return null;
|
|
873
|
+
if (best.score - secondScore < 0.1) return null;
|
|
874
|
+
return { element: best.el, confidence: "likely" };
|
|
875
|
+
}
|
|
876
|
+
function findByAncestorChain(root, tagName, steps) {
|
|
877
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
878
|
+
let best = null;
|
|
879
|
+
for (const el of all) {
|
|
880
|
+
const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
|
|
881
|
+
const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
|
|
882
|
+
if (score < 2) continue;
|
|
883
|
+
if (!best || score > best.score) best = { el, score };
|
|
2002
884
|
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
const
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
if (!this.autoSignIn) {
|
|
2020
|
-
throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
|
|
2021
|
-
}
|
|
2022
|
-
if (!this.pendingAuth) {
|
|
2023
|
-
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
2024
|
-
this.writeStoredToken(t);
|
|
2025
|
-
this.fireAuthChanged(t);
|
|
2026
|
-
return t;
|
|
2027
|
-
}).finally(() => {
|
|
2028
|
-
this.pendingAuth = null;
|
|
2029
|
-
});
|
|
885
|
+
if (!best) return null;
|
|
886
|
+
return {
|
|
887
|
+
element: best.el,
|
|
888
|
+
confidence: best.score === steps.length ? "likely" : "drifted"
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function collectAncestorSteps(el, limit) {
|
|
892
|
+
const steps = [];
|
|
893
|
+
let current = el.parentElement;
|
|
894
|
+
while (current && steps.length < limit) {
|
|
895
|
+
const parent = current.parentElement;
|
|
896
|
+
let nth = 1;
|
|
897
|
+
if (parent) {
|
|
898
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
899
|
+
nth = sameTag.indexOf(current) + 1;
|
|
2030
900
|
}
|
|
2031
|
-
|
|
901
|
+
steps.push({ tag: current.tagName, nthOfType: nth });
|
|
902
|
+
current = parent;
|
|
2032
903
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
904
|
+
return steps;
|
|
905
|
+
}
|
|
906
|
+
function findByTextSnippet(root, tagName, snippet) {
|
|
907
|
+
const needle = normalizeText(snippet);
|
|
908
|
+
if (!needle) return null;
|
|
909
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
910
|
+
let best = null;
|
|
911
|
+
for (const el of all) {
|
|
912
|
+
const raw = el.innerText || el.textContent || "";
|
|
913
|
+
const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
|
|
914
|
+
if (!candidate) continue;
|
|
915
|
+
const score = levenshteinRatio(needle, candidate);
|
|
916
|
+
if (score < TEXT_RATIO_THRESHOLD) continue;
|
|
917
|
+
if (!best || score > best.score) {
|
|
918
|
+
best = { el, score };
|
|
2047
919
|
}
|
|
2048
920
|
}
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
makeAuthError(
|
|
2067
|
-
"popup_blocked",
|
|
2068
|
-
"Sign-in popup was blocked by the browser. Allow popups for this site and try again."
|
|
2069
|
-
)
|
|
2070
|
-
);
|
|
2071
|
-
}
|
|
2072
|
-
let resolved = false;
|
|
2073
|
-
const onMessage = (e) => {
|
|
2074
|
-
if (e.origin !== apiOrigin) return;
|
|
2075
|
-
const data = e.data;
|
|
2076
|
-
if (!data || data.type !== "vizu:auth") return;
|
|
2077
|
-
if (data.workspace !== this.workspace) return;
|
|
2078
|
-
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
2079
|
-
resolved = true;
|
|
2080
|
-
cleanup();
|
|
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 });
|
|
2088
|
-
};
|
|
2089
|
-
const onPoll = window.setInterval(() => {
|
|
2090
|
-
if (popup.closed) {
|
|
2091
|
-
if (!resolved) {
|
|
2092
|
-
cleanup();
|
|
2093
|
-
reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2096
|
-
}, 500);
|
|
2097
|
-
function cleanup() {
|
|
2098
|
-
window.clearInterval(onPoll);
|
|
2099
|
-
window.removeEventListener("message", onMessage);
|
|
2100
|
-
}
|
|
2101
|
-
window.addEventListener("message", onMessage);
|
|
2102
|
-
});
|
|
921
|
+
return best ? { element: best.el, confidence: "drifted" } : null;
|
|
922
|
+
}
|
|
923
|
+
function normalizeText(input) {
|
|
924
|
+
return input.toLowerCase().replace(/\s+/g, " ").trim();
|
|
925
|
+
}
|
|
926
|
+
function levenshteinRatio(a, b) {
|
|
927
|
+
if (a === b) return 1;
|
|
928
|
+
if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
|
|
929
|
+
const maxLen = Math.max(a.length, b.length);
|
|
930
|
+
const distance = levenshteinDistance(a, b);
|
|
931
|
+
return 1 - distance / maxLen;
|
|
932
|
+
}
|
|
933
|
+
function levenshteinDistance(a, b) {
|
|
934
|
+
if (a.length > b.length) {
|
|
935
|
+
const tmp = a;
|
|
936
|
+
a = b;
|
|
937
|
+
b = tmp;
|
|
2103
938
|
}
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
939
|
+
const prev = new Array(a.length + 1);
|
|
940
|
+
const curr = new Array(a.length + 1);
|
|
941
|
+
for (let i = 0; i <= a.length; i++) prev[i] = i;
|
|
942
|
+
for (let j = 1; j <= b.length; j++) {
|
|
943
|
+
curr[0] = j;
|
|
944
|
+
for (let i = 1; i <= a.length; i++) {
|
|
945
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
946
|
+
curr[i] = Math.min(
|
|
947
|
+
curr[i - 1] + 1,
|
|
948
|
+
// insert
|
|
949
|
+
prev[i] + 1,
|
|
950
|
+
// delete
|
|
951
|
+
prev[i - 1] + cost
|
|
952
|
+
// substitute
|
|
953
|
+
);
|
|
2118
954
|
}
|
|
2119
|
-
|
|
955
|
+
for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
|
|
2120
956
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
957
|
+
return prev[a.length];
|
|
958
|
+
}
|
|
959
|
+
function scoreChainAgainstAncestors(actual, captured, maxShift) {
|
|
960
|
+
let best = 0;
|
|
961
|
+
for (let offset = 0; offset <= maxShift; offset++) {
|
|
962
|
+
let score = 0;
|
|
963
|
+
for (let i = 0; i < captured.length; i++) {
|
|
964
|
+
const a = actual[i + offset];
|
|
965
|
+
if (!a) break;
|
|
966
|
+
if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
|
|
967
|
+
score++;
|
|
968
|
+
}
|
|
2127
969
|
}
|
|
970
|
+
if (score > best) best = score;
|
|
2128
971
|
}
|
|
2129
|
-
|
|
2130
|
-
function makeAuthError(code, message) {
|
|
2131
|
-
const err = new Error(message);
|
|
2132
|
-
err.code = code;
|
|
2133
|
-
return err;
|
|
972
|
+
return best;
|
|
2134
973
|
}
|
|
2135
|
-
function
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
974
|
+
function jaccardSimilarity(a, b) {
|
|
975
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
976
|
+
let intersection = 0;
|
|
977
|
+
for (const item of a) if (b.has(item)) intersection++;
|
|
978
|
+
const union = a.size + b.size - intersection;
|
|
979
|
+
return union === 0 ? 0 : intersection / union;
|
|
980
|
+
}
|
|
981
|
+
function findElementByFingerprint(fp, root) {
|
|
982
|
+
return findByFingerprint(fp, root).element;
|
|
983
|
+
}
|
|
984
|
+
function fingerprintKey(fp) {
|
|
985
|
+
return fp.selector + "|" + fp.textSnippet;
|
|
986
|
+
}
|
|
987
|
+
function fingerprintLabel(fp) {
|
|
988
|
+
const tag = fp.tagName.toLowerCase();
|
|
989
|
+
if (fp.textSnippet) {
|
|
990
|
+
const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
|
|
991
|
+
return tag + ": " + snippet;
|
|
992
|
+
}
|
|
993
|
+
if (fp.attributes.id) return tag + "#" + fp.attributes.id;
|
|
994
|
+
if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
|
|
995
|
+
return tag;
|
|
2142
996
|
}
|
|
2143
|
-
|
|
2144
|
-
// src/index.ts
|
|
2145
|
-
init_fingerprint();
|
|
2146
997
|
|
|
2147
998
|
// src/highlighter.ts
|
|
2148
999
|
var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
|
|
2149
1000
|
var Highlighter = class {
|
|
2150
|
-
constructor(root, extraIgnore, onClick
|
|
1001
|
+
constructor(root, extraIgnore, onClick) {
|
|
2151
1002
|
this.current = null;
|
|
2152
1003
|
this.active = false;
|
|
2153
1004
|
this.paused = false;
|
|
@@ -2182,7 +1033,6 @@ var Highlighter = class {
|
|
|
2182
1033
|
this.root = root;
|
|
2183
1034
|
this.extraIgnore = extraIgnore;
|
|
2184
1035
|
this.onClick = onClick;
|
|
2185
|
-
this.onCurrentChange = onCurrentChange ?? null;
|
|
2186
1036
|
this.overlay = document.createElement("div");
|
|
2187
1037
|
this.overlay.className = "vz-highlight";
|
|
2188
1038
|
this.overlay.style.display = "none";
|
|
@@ -2191,7 +1041,6 @@ var Highlighter = class {
|
|
|
2191
1041
|
setCurrent(el) {
|
|
2192
1042
|
if (this.current === el) return;
|
|
2193
1043
|
this.current = el;
|
|
2194
|
-
this.onCurrentChange?.(el);
|
|
2195
1044
|
}
|
|
2196
1045
|
start() {
|
|
2197
1046
|
if (this.active) return;
|
|
@@ -2252,7 +1101,6 @@ var Highlighter = class {
|
|
|
2252
1101
|
};
|
|
2253
1102
|
|
|
2254
1103
|
// src/popover.ts
|
|
2255
|
-
init_fingerprint();
|
|
2256
1104
|
function cssEscape(s) {
|
|
2257
1105
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
|
|
2258
1106
|
return s.replace(/["\\]/g, "\\$&");
|
|
@@ -2930,7 +1778,6 @@ var Pill = class {
|
|
|
2930
1778
|
};
|
|
2931
1779
|
|
|
2932
1780
|
// src/sidebar.ts
|
|
2933
|
-
init_fingerprint();
|
|
2934
1781
|
var Sidebar = class {
|
|
2935
1782
|
constructor(root, callbacks) {
|
|
2936
1783
|
this.open = false;
|
|
@@ -4179,7 +3026,6 @@ var EventBus = class {
|
|
|
4179
3026
|
};
|
|
4180
3027
|
|
|
4181
3028
|
// src/index.ts
|
|
4182
|
-
init_fingerprint();
|
|
4183
3029
|
var DEFAULTS = {
|
|
4184
3030
|
shortcut: "mod+shift+e",
|
|
4185
3031
|
namespace: "default",
|
|
@@ -4239,8 +3085,6 @@ var Vizu = class {
|
|
|
4239
3085
|
this.selfHealedThisSession = /* @__PURE__ */ new Set();
|
|
4240
3086
|
this.actions = [];
|
|
4241
3087
|
this.user = null;
|
|
4242
|
-
/** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
|
|
4243
|
-
this.liveCollab = null;
|
|
4244
3088
|
this.enabled = false;
|
|
4245
3089
|
this.rafScheduled = false;
|
|
4246
3090
|
this.bus = new EventBus();
|
|
@@ -4312,31 +3156,6 @@ var Vizu = class {
|
|
|
4312
3156
|
if (options.actions) this.actions = [...options.actions];
|
|
4313
3157
|
if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
|
|
4314
3158
|
if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
|
|
4315
|
-
if (options.cloud) {
|
|
4316
|
-
const cloud = options.cloud;
|
|
4317
|
-
const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
|
|
4318
|
-
void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
|
|
4319
|
-
this.liveCollab = new LiveCollab2({
|
|
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
|
-
}
|
|
4336
|
-
});
|
|
4337
|
-
void this.liveCollab.start();
|
|
4338
|
-
});
|
|
4339
|
-
}
|
|
4340
3159
|
if (typeof window !== "undefined") {
|
|
4341
3160
|
window.addEventListener("keydown", this.onKeyDown);
|
|
4342
3161
|
void this.loadComments().then(() => this.subscribeStorage());
|
|
@@ -4394,8 +3213,6 @@ var Vizu = class {
|
|
|
4394
3213
|
this.unsubscribeStorage?.();
|
|
4395
3214
|
this.unsubscribeStorage = null;
|
|
4396
3215
|
this.selfHealedThisSession.clear();
|
|
4397
|
-
this.liveCollab?.destroy();
|
|
4398
|
-
this.liveCollab = null;
|
|
4399
3216
|
this.bus.clear();
|
|
4400
3217
|
}
|
|
4401
3218
|
/* ===== Action API ===== */
|
|
@@ -4717,11 +3534,7 @@ var Vizu = class {
|
|
|
4717
3534
|
this.highlighter = new Highlighter(
|
|
4718
3535
|
this.root,
|
|
4719
3536
|
this.opts.ignoreSelectors ?? [],
|
|
4720
|
-
(el, e) => this.onElementClick(el, e)
|
|
4721
|
-
(el) => {
|
|
4722
|
-
if (!this.liveCollab) return;
|
|
4723
|
-
this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
|
|
4724
|
-
}
|
|
3537
|
+
(el, e) => this.onElementClick(el, e)
|
|
4725
3538
|
);
|
|
4726
3539
|
this.highlighter.start();
|
|
4727
3540
|
this.renderAllMarkers();
|