@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/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/fingerprint.ts
24
- function buildSelector(el) {
25
- const parts = [];
26
- let current = el;
27
- let depth = 0;
28
- while (current && current !== document.documentElement && depth < 8) {
29
- let part = current.tagName.toLowerCase();
30
- if (current.id) {
31
- parts.unshift("#" + CSS.escape(current.id));
32
- break;
33
- }
34
- const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
35
- if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
36
- const parent = current.parentElement;
37
- if (parent) {
38
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
39
- if (sameTag.length > 1) {
40
- const idx = sameTag.indexOf(current);
41
- part += ":nth-of-type(" + (idx + 1) + ")";
42
- }
43
- }
44
- parts.unshift(part);
45
- current = parent;
46
- depth++;
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 parts.join(" > ");
45
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
49
46
  }
50
- function fingerprint(el) {
51
- const text = el.innerText || el.textContent || "";
52
- const parent = el.parentElement;
53
- let siblingIndex = 0;
54
- if (parent) {
55
- siblingIndex = Array.from(parent.children).indexOf(el);
47
+ function isInside(target, selector) {
48
+ return !!target.closest(selector);
49
+ }
50
+ function escapeHtml(s) {
51
+ return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 captureAncestorChain(el, depth) {
75
- const steps = [];
76
- let current = el.parentElement;
77
- while (current && steps.length < depth) {
78
- const parent = current.parentElement;
79
- let nthOfType = 1;
80
- if (parent) {
81
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
82
- nthOfType = sameTag.indexOf(current) + 1;
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
- steps.push({ tag: current.tagName, nthOfType });
85
- current = parent;
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 steps.length > 0 ? { steps } : void 0;
96
+ return out;
88
97
  }
89
- function findByFingerprint(fp, root = document) {
90
- if (fp.attributes.dataKey) {
91
- try {
92
- const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
93
- if (byKey) return { element: byKey, confidence: "exact" };
94
- } catch {
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 { element: null, confidence: "orphaned" };
97
- }
98
- if (fp.attributes.id) {
99
- try {
100
- const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
101
- if (byId) return { element: byId, confidence: "exact" };
102
- } catch {
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
- const candidates = [];
106
- const classes = fp.attributes.classList;
107
- if (classes && classes.length > 0) {
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 (fp.textSnippet) {
132
- const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
133
- if (m?.element) candidates.push({ el: m.element, rung: "text" });
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
- if (candidates.length === 0) {
136
- return { element: null, confidence: "orphaned" };
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
- const votes = /* @__PURE__ */ new Map();
139
- for (const c of candidates) {
140
- let s = votes.get(c.el);
141
- if (!s) {
142
- s = /* @__PURE__ */ new Set();
143
- votes.set(c.el, s);
144
- }
145
- s.add(c.rung);
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
- let best = null;
148
- for (const [el, rungs] of votes) {
149
- if (!best || rungs.size > best.rungs.size) best = { el, rungs };
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- async clear(_namespace) {
1886
- const url = new URL(this.apiUrl + "/api/comments");
1887
- url.searchParams.set("workspace", this.workspace);
1888
- url.searchParams.set("all", "true");
1889
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1890
- if (res.status === 404) return;
1891
- if (!res.ok) {
1892
- const json = await this.parseJson(res);
1893
- throw apiErr(res.status, json);
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
- async addReply(_namespace, commentId, reply) {
1897
- const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
1898
- url.searchParams.set("workspace", this.workspace);
1899
- const res = await this.fetchAuthed(url.toString(), {
1900
- method: "POST",
1901
- headers: { "content-type": "application/json" },
1902
- body: JSON.stringify(reply)
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
- async removeReply(_namespace, commentId, replyId) {
1910
- const url = new URL(
1911
- this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
1912
- );
1913
- url.searchParams.set("workspace", this.workspace);
1914
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1915
- if (res.status === 404) return null;
1916
- if (!res.ok) {
1917
- const json = await this.parseJson(res);
1918
- throw apiErr(res.status, json);
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
- return null;
777
+ steps.push({ tag: current.tagName, nthOfType });
778
+ current = parent;
1921
779
  }
1922
- /**
1923
- * Upload a file as multipart/form-data to the host backend, which
1924
- * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
1925
- * function body cap (our own 5 MB attachment cap will be enforced
1926
- * server-side before that's hit).
1927
- *
1928
- * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
1929
- * - keeps `@vizu/core` free of any `@vercel/blob` dependency
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
- getAuthContext() {
1960
- if (!this.cachedToken) return null;
1961
- return {
1962
- userId: this.cachedToken.userId,
1963
- token: this.cachedToken.token,
1964
- expiresAt: this.cachedToken.expiresAt
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
- /* ─── Auth ────────────────────────────────────────────────────────── */
1968
- isExpired(t) {
1969
- if (!t) return true;
1970
- const exp = new Date(t.expiresAt).getTime();
1971
- return !Number.isFinite(exp) || exp - Date.now() < 3e4;
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
- readStoredToken() {
1974
- if (typeof sessionStorage === "undefined") return null;
1975
- const raw = sessionStorage.getItem(this.sessionKey());
1976
- if (!raw) return null;
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 parsed = JSON.parse(raw);
1979
- if (this.isExpired(parsed)) {
1980
- sessionStorage.removeItem(this.sessionKey());
1981
- return null;
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
- writeStoredToken(t) {
1989
- this.cachedToken = t;
1990
- if (typeof sessionStorage !== "undefined") {
1991
- sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
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
- clearStoredToken() {
1995
- this.cachedToken = null;
1996
- if (typeof sessionStorage !== "undefined") {
1997
- sessionStorage.removeItem(this.sessionKey());
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
- sessionKey() {
2001
- return `vizu:cloud-token:v2:${this.workspace}`;
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
- * Resolve a valid token, opening the popup if needed.
2005
- *
2006
- * Single-flight: simultaneous requests await one popup, not N.
2007
- * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
2008
- * if the user closes the popup; hosts can catch this and present
2009
- * a friendly message instead of a blank failure.
2010
- */
2011
- async resolveToken() {
2012
- if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
2013
- const fresh = this.readStoredToken();
2014
- if (fresh) {
2015
- this.cachedToken = fresh;
2016
- this.fireAuthChanged(fresh);
2017
- return fresh;
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
- return this.pendingAuth;
901
+ steps.push({ tag: current.tagName, nthOfType: nth });
902
+ current = parent;
2032
903
  }
2033
- /**
2034
- * Notify the host that the authenticated identity changed. De-duped
2035
- * via the token string so multiple resolveToken() hits with the same
2036
- * cached token don't re-call setUser on every fetch.
2037
- */
2038
- fireAuthChanged(token) {
2039
- if (this.lastNotifiedTokenId === token.token) return;
2040
- this.lastNotifiedTokenId = token.token;
2041
- try {
2042
- this.onAuthChanged?.({ userId: token.userId, user: token.user });
2043
- } catch (err) {
2044
- if (typeof console !== "undefined") {
2045
- console.warn("[vizu/cloud] onAuthChanged callback threw", err);
2046
- }
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
- openSignInPopup() {
2050
- return new Promise((resolve, reject) => {
2051
- if (typeof window === "undefined") {
2052
- return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
2053
- }
2054
- const apiOrigin = new URL(this.apiUrl).origin;
2055
- const hostOrigin = window.location.origin;
2056
- const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
2057
- const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
2058
- const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
2059
- const popup = window.open(
2060
- popupUrl,
2061
- "vizu-connect",
2062
- `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
2063
- );
2064
- if (!popup) {
2065
- return reject(
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
- /* ─── Fetch wrapper ───────────────────────────────────────────────── */
2105
- /**
2106
- * Perform an authenticated fetch with one-shot retry on 401 (re-opens
2107
- * the popup if the token expired during the request). 4xx other than
2108
- * 401 / 404 throw; 5xx throws; 2xx returns.
2109
- */
2110
- async fetchAuthed(url, init, retried = false) {
2111
- const token = await this.resolveToken();
2112
- const headers = new Headers(init.headers);
2113
- headers.set("authorization", `Bearer ${token.token}`);
2114
- const res = await fetch(url, { ...init, headers, credentials: "omit" });
2115
- if (res.status === 401 && !retried) {
2116
- this.clearStoredToken();
2117
- return this.fetchAuthed(url, init, true);
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
- return res;
955
+ for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
2120
956
  }
2121
- async parseJson(res) {
2122
- try {
2123
- const text = await res.text();
2124
- return text ? JSON.parse(text) : null;
2125
- } catch {
2126
- return null;
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 apiErr(status, body) {
2136
- const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
2137
- const err = new Error(body?.message ?? `HTTP ${status}`);
2138
- err.code = code;
2139
- err.status = status;
2140
- err.body = body;
2141
- return err;
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, onCurrentChange) {
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();