@unhingged/vizu-core 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auto.cjs 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,1582 +17,164 @@ 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/auto.ts
21
+ var auto_exports = {};
22
+ __export(auto_exports, {
23
+ Vizu: () => Vizu
24
+ });
25
+ module.exports = __toCommonJS(auto_exports);
26
+
27
+ // src/types.ts
28
+ var SCHEMA_VERSION = 2;
29
+
30
+ // src/util.ts
31
+ function uuid() {
32
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
33
+ return crypto.randomUUID();
47
34
  }
48
- return parts.join(" > ");
35
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
49
36
  }
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);
37
+ function isInside(target, selector) {
38
+ return !!target.closest(selector);
39
+ }
40
+ function escapeHtml(s) {
41
+ return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
42
+ }
43
+ function formatTime(ts) {
44
+ const d = new Date(ts);
45
+ const now = Date.now();
46
+ const diff = now - ts;
47
+ if (diff < 6e4) return "just now";
48
+ if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
49
+ if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
50
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
51
+ }
52
+ function initials(name) {
53
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
54
+ }
55
+ function avatarHtml(user, className = "vz-comment-author-avatar") {
56
+ if (!user) return `<span class="${className}">\xB7</span>`;
57
+ if (user.avatarUrl) {
58
+ return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
56
59
  }
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
- };
60
+ return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
73
61
  }
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;
62
+ function refId() {
63
+ return Math.random().toString(36).slice(2, 10);
64
+ }
65
+ var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
66
+ var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
67
+ function renderCommentText(text, commentId) {
68
+ const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
69
+ return parts.map((part) => {
70
+ const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
71
+ if (refMatch) {
72
+ 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
73
  }
84
- steps.push({ tag: current.tagName, nthOfType });
85
- current = parent;
74
+ const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
75
+ if (mentionMatch) {
76
+ return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
77
+ }
78
+ return escapeHtml(part);
79
+ }).join("");
80
+ }
81
+ function extractMentions(text) {
82
+ const out = [];
83
+ for (const m of text.matchAll(MENTION_TOKEN_RE)) {
84
+ if (!out.includes(m[2])) out.push(m[2]);
86
85
  }
87
- return steps.length > 0 ? { steps } : void 0;
86
+ return out;
88
87
  }
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 {
88
+ function renderAttachmentsHtml(attachments) {
89
+ if (!Array.isArray(attachments) || attachments.length === 0) return "";
90
+ const items = attachments.map((a) => {
91
+ const isImage = a.mimeType?.startsWith("image/");
92
+ const filename = a.filename ?? a.url.split("/").pop() ?? "file";
93
+ if (isImage) {
94
+ return `
95
+ <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
96
+ <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
97
+ </a>
98
+ `;
95
99
  }
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 {
100
+ return `
101
+ <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
102
+ <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
103
+ <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
104
+ </a>
105
+ `;
106
+ }).join("");
107
+ return `<div class="vz-attachment-grid">${items}</div>`;
108
+ }
109
+ function migrateComment(raw) {
110
+ if (!raw || typeof raw !== "object") return raw;
111
+ const next = { ...raw };
112
+ if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
113
+ if (next.fingerprint) {
114
+ next.fingerprints = [next.fingerprint];
103
115
  }
104
116
  }
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" });
117
+ delete next.fingerprint;
118
+ if (next.url && !next.pageUrl) {
119
+ next.pageUrl = next.url;
110
120
  }
111
- try {
112
- const bySelector = root.querySelector(fp.selector);
113
- if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
114
- } catch {
121
+ if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
122
+ if (!Array.isArray(next.replies)) next.replies = [];
123
+ if (!Array.isArray(next.attachments)) next.attachments = [];
124
+ if (!Array.isArray(next.mentions)) next.mentions = [];
125
+ if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
126
+ next.schemaVersion = SCHEMA_VERSION;
127
+ return next;
128
+ }
129
+ function isValidStatus(s) {
130
+ return s === "open" || s === "resolved" || s === "wontfix";
131
+ }
132
+ function migrateComments(raws) {
133
+ if (!Array.isArray(raws)) return [];
134
+ const out = [];
135
+ for (const raw of raws) {
136
+ if (!raw || typeof raw !== "object") continue;
137
+ out.push(migrateComment(raw));
115
138
  }
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
- }
139
+ return out;
140
+ }
141
+ function needsPersist(raws) {
142
+ if (!Array.isArray(raws)) return false;
143
+ for (const raw of raws) {
144
+ if (!raw || typeof raw !== "object") continue;
145
+ const c = raw;
146
+ if (c.schemaVersion !== SCHEMA_VERSION) return true;
147
+ if ("fingerprint" in c) return true;
148
+ if (!Array.isArray(c.replies)) return true;
149
+ if (!Array.isArray(c.attachments)) return true;
150
+ if (!Array.isArray(c.mentions)) return true;
130
151
  }
131
- if (fp.textSnippet) {
132
- const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
133
- if (m?.element) candidates.push({ el: m.element, rung: "text" });
152
+ return false;
153
+ }
154
+
155
+ // src/storage.ts
156
+ var key = (ns) => `vizu:comments:${ns}`;
157
+ var LocalStorageAdapter = class {
158
+ async load(namespace) {
159
+ return readArray(namespace);
134
160
  }
135
- if (candidates.length === 0) {
136
- return { element: null, confidence: "orphaned" };
161
+ async addComment(namespace, comment) {
162
+ const list = readArray(namespace);
163
+ list.push(comment);
164
+ writeArray(namespace, list);
137
165
  }
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);
166
+ async updateComment(namespace, id, patch) {
167
+ const list = readArray(namespace);
168
+ const idx = list.findIndex((c) => c.id === id);
169
+ if (idx === -1) return null;
170
+ const next = { ...list[idx], ...patch, id: list[idx].id };
171
+ list[idx] = next;
172
+ writeArray(namespace, list);
173
+ return next;
146
174
  }
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, init2) {
1239
- const headers = new Headers(init2.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
- ...init2,
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/auto.ts
1442
- var auto_exports = {};
1443
- __export(auto_exports, {
1444
- Vizu: () => Vizu
1445
- });
1446
- module.exports = __toCommonJS(auto_exports);
1447
-
1448
- // src/types.ts
1449
- var SCHEMA_VERSION = 2;
1450
-
1451
- // src/util.ts
1452
- function uuid() {
1453
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
1454
- return crypto.randomUUID();
1455
- }
1456
- return Math.random().toString(36).slice(2) + Date.now().toString(36);
1457
- }
1458
- function isInside(target, selector) {
1459
- return !!target.closest(selector);
1460
- }
1461
- function escapeHtml(s) {
1462
- return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
1463
- }
1464
- function formatTime(ts) {
1465
- const d = new Date(ts);
1466
- const now = Date.now();
1467
- const diff = now - ts;
1468
- if (diff < 6e4) return "just now";
1469
- if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
1470
- if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
1471
- return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1472
- }
1473
- function initials(name) {
1474
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
1475
- }
1476
- function avatarHtml(user, className = "vz-comment-author-avatar") {
1477
- if (!user) return `<span class="${className}">\xB7</span>`;
1478
- if (user.avatarUrl) {
1479
- return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
1480
- }
1481
- return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
1482
- }
1483
- function refId() {
1484
- return Math.random().toString(36).slice(2, 10);
1485
- }
1486
- var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
1487
- var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
1488
- function renderCommentText(text, commentId) {
1489
- const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
1490
- return parts.map((part) => {
1491
- const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
1492
- if (refMatch) {
1493
- 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>`;
1494
- }
1495
- const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
1496
- if (mentionMatch) {
1497
- return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
1498
- }
1499
- return escapeHtml(part);
1500
- }).join("");
1501
- }
1502
- function extractMentions(text) {
1503
- const out = [];
1504
- for (const m of text.matchAll(MENTION_TOKEN_RE)) {
1505
- if (!out.includes(m[2])) out.push(m[2]);
1506
- }
1507
- return out;
1508
- }
1509
- function renderAttachmentsHtml(attachments) {
1510
- if (!Array.isArray(attachments) || attachments.length === 0) return "";
1511
- const items = attachments.map((a) => {
1512
- const isImage = a.mimeType?.startsWith("image/");
1513
- const filename = a.filename ?? a.url.split("/").pop() ?? "file";
1514
- if (isImage) {
1515
- return `
1516
- <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
1517
- <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
1518
- </a>
1519
- `;
1520
- }
1521
- return `
1522
- <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
1523
- <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
1524
- <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
1525
- </a>
1526
- `;
1527
- }).join("");
1528
- return `<div class="vz-attachment-grid">${items}</div>`;
1529
- }
1530
- function migrateComment(raw) {
1531
- if (!raw || typeof raw !== "object") return raw;
1532
- const next = { ...raw };
1533
- if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
1534
- if (next.fingerprint) {
1535
- next.fingerprints = [next.fingerprint];
1536
- }
1537
- }
1538
- delete next.fingerprint;
1539
- if (next.url && !next.pageUrl) {
1540
- next.pageUrl = next.url;
1541
- }
1542
- if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
1543
- if (!Array.isArray(next.replies)) next.replies = [];
1544
- if (!Array.isArray(next.attachments)) next.attachments = [];
1545
- if (!Array.isArray(next.mentions)) next.mentions = [];
1546
- if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
1547
- next.schemaVersion = SCHEMA_VERSION;
1548
- return next;
1549
- }
1550
- function isValidStatus(s) {
1551
- return s === "open" || s === "resolved" || s === "wontfix";
1552
- }
1553
- function migrateComments(raws) {
1554
- if (!Array.isArray(raws)) return [];
1555
- const out = [];
1556
- for (const raw of raws) {
1557
- if (!raw || typeof raw !== "object") continue;
1558
- out.push(migrateComment(raw));
1559
- }
1560
- return out;
1561
- }
1562
- function needsPersist(raws) {
1563
- if (!Array.isArray(raws)) return false;
1564
- for (const raw of raws) {
1565
- if (!raw || typeof raw !== "object") continue;
1566
- const c = raw;
1567
- if (c.schemaVersion !== SCHEMA_VERSION) return true;
1568
- if ("fingerprint" in c) return true;
1569
- if (!Array.isArray(c.replies)) return true;
1570
- if (!Array.isArray(c.attachments)) return true;
1571
- if (!Array.isArray(c.mentions)) return true;
1572
- }
1573
- return false;
1574
- }
1575
-
1576
- // src/storage.ts
1577
- var key = (ns) => `vizu:comments:${ns}`;
1578
- var LocalStorageAdapter = class {
1579
- async load(namespace) {
1580
- return readArray(namespace);
1581
- }
1582
- async addComment(namespace, comment) {
1583
- const list = readArray(namespace);
1584
- list.push(comment);
1585
- writeArray(namespace, list);
1586
- }
1587
- async updateComment(namespace, id, patch) {
1588
- const list = readArray(namespace);
1589
- const idx = list.findIndex((c) => c.id === id);
1590
- if (idx === -1) return null;
1591
- const next = { ...list[idx], ...patch, id: list[idx].id };
1592
- list[idx] = next;
1593
- writeArray(namespace, list);
1594
- return next;
1595
- }
1596
- async removeComment(namespace, id) {
1597
- const list = readArray(namespace);
1598
- writeArray(namespace, list.filter((c) => c.id !== id));
175
+ async removeComment(namespace, id) {
176
+ const list = readArray(namespace);
177
+ writeArray(namespace, list.filter((c) => c.id !== id));
1599
178
  }
1600
179
  async setAll(namespace, comments) {
1601
180
  writeArray(namespace, comments);
@@ -1804,18 +383,6 @@ var CloudStorageAdapter = class {
1804
383
  async preflightAuth() {
1805
384
  await this.resolveToken();
1806
385
  }
1807
- /**
1808
- * Synchronous accessor for the currently cached bearer token, used
1809
- * by sibling modules that need to authenticate against the same
1810
- * backend (live-collab cross-origin polling). Returns null when no
1811
- * valid token is cached — callers fall back to credentials:'include'
1812
- * for same-origin or just disable themselves.
1813
- */
1814
- getCachedAuthToken() {
1815
- if (!this.cachedToken) return null;
1816
- if (this.isExpired(this.cachedToken)) return null;
1817
- return this.cachedToken.token;
1818
- }
1819
386
  /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
1820
387
  async load(_namespace) {
1821
388
  const out = [];
@@ -1872,272 +439,556 @@ var CloudStorageAdapter = class {
1872
439
  await this.addComment(namespace, c);
1873
440
  }
1874
441
  }
1875
- async clear(_namespace) {
1876
- const url = new URL(this.apiUrl + "/api/comments");
1877
- url.searchParams.set("workspace", this.workspace);
1878
- url.searchParams.set("all", "true");
1879
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1880
- if (res.status === 404) return;
1881
- if (!res.ok) {
1882
- const json = await this.parseJson(res);
1883
- throw apiErr(res.status, json);
442
+ async clear(_namespace) {
443
+ const url = new URL(this.apiUrl + "/api/comments");
444
+ url.searchParams.set("workspace", this.workspace);
445
+ url.searchParams.set("all", "true");
446
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
447
+ if (res.status === 404) return;
448
+ if (!res.ok) {
449
+ const json = await this.parseJson(res);
450
+ throw apiErr(res.status, json);
451
+ }
452
+ }
453
+ async addReply(_namespace, commentId, reply) {
454
+ const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
455
+ url.searchParams.set("workspace", this.workspace);
456
+ const res = await this.fetchAuthed(url.toString(), {
457
+ method: "POST",
458
+ headers: { "content-type": "application/json" },
459
+ body: JSON.stringify(reply)
460
+ });
461
+ if (res.status === 404) return null;
462
+ const json = await this.parseJson(res);
463
+ if (!res.ok) throw apiErr(res.status, json);
464
+ return json?.comment ? migrateComment(json.comment) : null;
465
+ }
466
+ async removeReply(_namespace, commentId, replyId) {
467
+ const url = new URL(
468
+ this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
469
+ );
470
+ url.searchParams.set("workspace", this.workspace);
471
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
472
+ if (res.status === 404) return null;
473
+ if (!res.ok) {
474
+ const json = await this.parseJson(res);
475
+ throw apiErr(res.status, json);
476
+ }
477
+ return null;
478
+ }
479
+ /**
480
+ * Upload a file as multipart/form-data to the host backend, which
481
+ * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
482
+ * function body cap (our own 5 MB attachment cap will be enforced
483
+ * server-side before that's hit).
484
+ *
485
+ * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
486
+ * - keeps `@vizu/core` free of any `@vercel/blob` dependency
487
+ * (script-tag and local-only consumers don't pull blob code)
488
+ * - the multipart path is one round-trip; the direct-upload flow
489
+ * is two (signed URL + upload). For attachments capped at 5 MB,
490
+ * the savings don't justify the dep cost.
491
+ */
492
+ async uploadAttachment(_namespace, file) {
493
+ const form = new FormData();
494
+ form.append("file", file, file.name);
495
+ const url = new URL(this.apiUrl + "/api/uploads");
496
+ url.searchParams.set("workspace", this.workspace);
497
+ const res = await this.fetchAuthed(url.toString(), {
498
+ method: "POST",
499
+ body: form
500
+ // Don't set content-type; the browser fills in the multipart boundary.
501
+ });
502
+ const json = await this.parseJson(res);
503
+ if (!res.ok) throw apiErr(res.status, json);
504
+ if (!json?.url || typeof json.url !== "string") {
505
+ throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
506
+ }
507
+ return {
508
+ id: uuid(),
509
+ url: json.url,
510
+ mimeType: file.type || "application/octet-stream",
511
+ sizeBytes: file.size,
512
+ uploadedAt: Date.now(),
513
+ filename: file.name
514
+ };
515
+ }
516
+ getAuthContext() {
517
+ if (!this.cachedToken) return null;
518
+ return {
519
+ userId: this.cachedToken.userId,
520
+ token: this.cachedToken.token,
521
+ expiresAt: this.cachedToken.expiresAt
522
+ };
523
+ }
524
+ /* ─── Auth ────────────────────────────────────────────────────────── */
525
+ isExpired(t) {
526
+ if (!t) return true;
527
+ const exp = new Date(t.expiresAt).getTime();
528
+ return !Number.isFinite(exp) || exp - Date.now() < 3e4;
529
+ }
530
+ readStoredToken() {
531
+ if (typeof sessionStorage === "undefined") return null;
532
+ const raw = sessionStorage.getItem(this.sessionKey());
533
+ if (!raw) return null;
534
+ try {
535
+ const parsed = JSON.parse(raw);
536
+ if (this.isExpired(parsed)) {
537
+ sessionStorage.removeItem(this.sessionKey());
538
+ return null;
539
+ }
540
+ return parsed;
541
+ } catch {
542
+ return null;
543
+ }
544
+ }
545
+ writeStoredToken(t) {
546
+ this.cachedToken = t;
547
+ if (typeof sessionStorage !== "undefined") {
548
+ sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
549
+ }
550
+ }
551
+ clearStoredToken() {
552
+ this.cachedToken = null;
553
+ if (typeof sessionStorage !== "undefined") {
554
+ sessionStorage.removeItem(this.sessionKey());
555
+ }
556
+ }
557
+ sessionKey() {
558
+ return `vizu:cloud-token:v2:${this.workspace}`;
559
+ }
560
+ /**
561
+ * Resolve a valid token, opening the popup if needed.
562
+ *
563
+ * Single-flight: simultaneous requests await one popup, not N.
564
+ * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
565
+ * if the user closes the popup; hosts can catch this and present
566
+ * a friendly message instead of a blank failure.
567
+ */
568
+ async resolveToken() {
569
+ if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
570
+ const fresh = this.readStoredToken();
571
+ if (fresh) {
572
+ this.cachedToken = fresh;
573
+ this.fireAuthChanged(fresh);
574
+ return fresh;
575
+ }
576
+ if (!this.autoSignIn) {
577
+ throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
578
+ }
579
+ if (!this.pendingAuth) {
580
+ this.pendingAuth = this.openSignInPopup().then((t) => {
581
+ this.writeStoredToken(t);
582
+ this.fireAuthChanged(t);
583
+ return t;
584
+ }).finally(() => {
585
+ this.pendingAuth = null;
586
+ });
587
+ }
588
+ return this.pendingAuth;
589
+ }
590
+ /**
591
+ * Notify the host that the authenticated identity changed. De-duped
592
+ * via the token string so multiple resolveToken() hits with the same
593
+ * cached token don't re-call setUser on every fetch.
594
+ */
595
+ fireAuthChanged(token) {
596
+ if (this.lastNotifiedTokenId === token.token) return;
597
+ this.lastNotifiedTokenId = token.token;
598
+ try {
599
+ this.onAuthChanged?.({ userId: token.userId, user: token.user });
600
+ } catch (err) {
601
+ if (typeof console !== "undefined") {
602
+ console.warn("[vizu/cloud] onAuthChanged callback threw", err);
603
+ }
604
+ }
605
+ }
606
+ openSignInPopup() {
607
+ return new Promise((resolve, reject) => {
608
+ if (typeof window === "undefined") {
609
+ return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
610
+ }
611
+ const apiOrigin = new URL(this.apiUrl).origin;
612
+ const hostOrigin = window.location.origin;
613
+ const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
614
+ const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
615
+ const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
616
+ const popup = window.open(
617
+ popupUrl,
618
+ "vizu-connect",
619
+ `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
620
+ );
621
+ if (!popup) {
622
+ return reject(
623
+ makeAuthError(
624
+ "popup_blocked",
625
+ "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
626
+ )
627
+ );
628
+ }
629
+ let resolved = false;
630
+ const onMessage = (e) => {
631
+ if (e.origin !== apiOrigin) return;
632
+ const data = e.data;
633
+ if (!data || data.type !== "vizu:auth") return;
634
+ if (data.workspace !== this.workspace) return;
635
+ if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
636
+ resolved = true;
637
+ cleanup();
638
+ const userPayload = data.user;
639
+ const user = userPayload && typeof userPayload.name === "string" ? {
640
+ id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
641
+ name: userPayload.name,
642
+ avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
643
+ } : null;
644
+ resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
645
+ };
646
+ const onPoll = window.setInterval(() => {
647
+ if (popup.closed) {
648
+ if (!resolved) {
649
+ cleanup();
650
+ reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
651
+ }
652
+ }
653
+ }, 500);
654
+ function cleanup() {
655
+ window.clearInterval(onPoll);
656
+ window.removeEventListener("message", onMessage);
657
+ }
658
+ window.addEventListener("message", onMessage);
659
+ });
660
+ }
661
+ /* ─── Fetch wrapper ───────────────────────────────────────────────── */
662
+ /**
663
+ * Perform an authenticated fetch with one-shot retry on 401 (re-opens
664
+ * the popup if the token expired during the request). 4xx other than
665
+ * 401 / 404 throw; 5xx throws; 2xx returns.
666
+ */
667
+ async fetchAuthed(url, init2, retried = false) {
668
+ const token = await this.resolveToken();
669
+ const headers = new Headers(init2.headers);
670
+ headers.set("authorization", `Bearer ${token.token}`);
671
+ const res = await fetch(url, { ...init2, headers, credentials: "omit" });
672
+ if (res.status === 401 && !retried) {
673
+ this.clearStoredToken();
674
+ return this.fetchAuthed(url, init2, true);
675
+ }
676
+ return res;
677
+ }
678
+ async parseJson(res) {
679
+ try {
680
+ const text = await res.text();
681
+ return text ? JSON.parse(text) : null;
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+ };
687
+ function makeAuthError(code, message) {
688
+ const err = new Error(message);
689
+ err.code = code;
690
+ return err;
691
+ }
692
+ function apiErr(status, body) {
693
+ const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
694
+ const err = new Error(body?.message ?? `HTTP ${status}`);
695
+ err.code = code;
696
+ err.status = status;
697
+ err.body = body;
698
+ return err;
699
+ }
700
+
701
+ // src/fingerprint.ts
702
+ var TEXT_SNIPPET_MAX = 80;
703
+ var ANCESTOR_DEPTH = 4;
704
+ var ANCESTOR_MAX_SHIFT = 3;
705
+ var TEXT_RATIO_THRESHOLD = 0.75;
706
+ var CLASS_JACCARD_THRESHOLD = 0.6;
707
+ function buildSelector(el) {
708
+ const parts = [];
709
+ let current = el;
710
+ let depth = 0;
711
+ while (current && current !== document.documentElement && depth < 8) {
712
+ let part = current.tagName.toLowerCase();
713
+ if (current.id) {
714
+ parts.unshift("#" + CSS.escape(current.id));
715
+ break;
716
+ }
717
+ const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
718
+ if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
719
+ const parent = current.parentElement;
720
+ if (parent) {
721
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
722
+ if (sameTag.length > 1) {
723
+ const idx = sameTag.indexOf(current);
724
+ part += ":nth-of-type(" + (idx + 1) + ")";
725
+ }
1884
726
  }
727
+ parts.unshift(part);
728
+ current = parent;
729
+ depth++;
1885
730
  }
1886
- async addReply(_namespace, commentId, reply) {
1887
- const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
1888
- url.searchParams.set("workspace", this.workspace);
1889
- const res = await this.fetchAuthed(url.toString(), {
1890
- method: "POST",
1891
- headers: { "content-type": "application/json" },
1892
- body: JSON.stringify(reply)
1893
- });
1894
- if (res.status === 404) return null;
1895
- const json = await this.parseJson(res);
1896
- if (!res.ok) throw apiErr(res.status, json);
1897
- return json?.comment ? migrateComment(json.comment) : null;
731
+ return parts.join(" > ");
732
+ }
733
+ function fingerprint(el) {
734
+ const text = el.innerText || el.textContent || "";
735
+ const parent = el.parentElement;
736
+ let siblingIndex = 0;
737
+ if (parent) {
738
+ siblingIndex = Array.from(parent.children).indexOf(el);
1898
739
  }
1899
- async removeReply(_namespace, commentId, replyId) {
1900
- const url = new URL(
1901
- this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
1902
- );
1903
- url.searchParams.set("workspace", this.workspace);
1904
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1905
- if (res.status === 404) return null;
1906
- if (!res.ok) {
1907
- const json = await this.parseJson(res);
1908
- throw apiErr(res.status, json);
740
+ return {
741
+ selector: buildSelector(el),
742
+ parentSelector: parent ? buildSelector(parent) : "",
743
+ tagName: el.tagName,
744
+ textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
745
+ siblingIndex,
746
+ attributes: {
747
+ id: el.id || void 0,
748
+ classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
749
+ role: el.getAttribute("role") || void 0,
750
+ ariaLabel: el.getAttribute("aria-label") || void 0,
751
+ dataKey: el.getAttribute("data-vizu-key") || void 0
752
+ },
753
+ ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
754
+ algorithmVersion: 2
755
+ };
756
+ }
757
+ function captureAncestorChain(el, depth) {
758
+ const steps = [];
759
+ let current = el.parentElement;
760
+ while (current && steps.length < depth) {
761
+ const parent = current.parentElement;
762
+ let nthOfType = 1;
763
+ if (parent) {
764
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
765
+ nthOfType = sameTag.indexOf(current) + 1;
1909
766
  }
1910
- return null;
767
+ steps.push({ tag: current.tagName, nthOfType });
768
+ current = parent;
1911
769
  }
1912
- /**
1913
- * Upload a file as multipart/form-data to the host backend, which
1914
- * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
1915
- * function body cap (our own 5 MB attachment cap will be enforced
1916
- * server-side before that's hit).
1917
- *
1918
- * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
1919
- * - keeps `@vizu/core` free of any `@vercel/blob` dependency
1920
- * (script-tag and local-only consumers don't pull blob code)
1921
- * - the multipart path is one round-trip; the direct-upload flow
1922
- * is two (signed URL + upload). For attachments capped at 5 MB,
1923
- * the savings don't justify the dep cost.
1924
- */
1925
- async uploadAttachment(_namespace, file) {
1926
- const form = new FormData();
1927
- form.append("file", file, file.name);
1928
- const url = new URL(this.apiUrl + "/api/uploads");
1929
- url.searchParams.set("workspace", this.workspace);
1930
- const res = await this.fetchAuthed(url.toString(), {
1931
- method: "POST",
1932
- body: form
1933
- // Don't set content-type; the browser fills in the multipart boundary.
1934
- });
1935
- const json = await this.parseJson(res);
1936
- if (!res.ok) throw apiErr(res.status, json);
1937
- if (!json?.url || typeof json.url !== "string") {
1938
- throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
770
+ return steps.length > 0 ? { steps } : void 0;
771
+ }
772
+ function findByFingerprint(fp, root = document) {
773
+ if (fp.attributes.dataKey) {
774
+ try {
775
+ const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
776
+ if (byKey) return { element: byKey, confidence: "exact" };
777
+ } catch {
1939
778
  }
1940
- return {
1941
- id: uuid(),
1942
- url: json.url,
1943
- mimeType: file.type || "application/octet-stream",
1944
- sizeBytes: file.size,
1945
- uploadedAt: Date.now(),
1946
- filename: file.name
1947
- };
779
+ return { element: null, confidence: "orphaned" };
1948
780
  }
1949
- getAuthContext() {
1950
- if (!this.cachedToken) return null;
1951
- return {
1952
- userId: this.cachedToken.userId,
1953
- token: this.cachedToken.token,
1954
- expiresAt: this.cachedToken.expiresAt
1955
- };
781
+ if (fp.attributes.id) {
782
+ try {
783
+ const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
784
+ if (byId) return { element: byId, confidence: "exact" };
785
+ } catch {
786
+ }
1956
787
  }
1957
- /* ─── Auth ────────────────────────────────────────────────────────── */
1958
- isExpired(t) {
1959
- if (!t) return true;
1960
- const exp = new Date(t.expiresAt).getTime();
1961
- return !Number.isFinite(exp) || exp - Date.now() < 3e4;
788
+ const candidates = [];
789
+ const classes = fp.attributes.classList;
790
+ if (classes && classes.length > 0) {
791
+ const m = findByClassSignature(root, fp.tagName, classes);
792
+ if (m?.element) candidates.push({ el: m.element, rung: "class" });
1962
793
  }
1963
- readStoredToken() {
1964
- if (typeof sessionStorage === "undefined") return null;
1965
- const raw = sessionStorage.getItem(this.sessionKey());
1966
- if (!raw) return null;
794
+ try {
795
+ const bySelector = root.querySelector(fp.selector);
796
+ if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
797
+ } catch {
798
+ }
799
+ if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
800
+ const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
801
+ if (m?.element) candidates.push({ el: m.element, rung: "chain" });
802
+ } else {
1967
803
  try {
1968
- const parsed = JSON.parse(raw);
1969
- if (this.isExpired(parsed)) {
1970
- sessionStorage.removeItem(this.sessionKey());
1971
- return null;
804
+ const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
805
+ if (parent) {
806
+ const candidate = parent.children[fp.siblingIndex];
807
+ if (candidate && candidate.tagName === fp.tagName) {
808
+ candidates.push({ el: candidate, rung: "chain" });
809
+ }
1972
810
  }
1973
- return parsed;
1974
811
  } catch {
1975
- return null;
1976
812
  }
1977
813
  }
1978
- writeStoredToken(t) {
1979
- this.cachedToken = t;
1980
- if (typeof sessionStorage !== "undefined") {
1981
- sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
814
+ if (fp.textSnippet) {
815
+ const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
816
+ if (m?.element) candidates.push({ el: m.element, rung: "text" });
817
+ }
818
+ if (candidates.length === 0) {
819
+ return { element: null, confidence: "orphaned" };
820
+ }
821
+ const votes = /* @__PURE__ */ new Map();
822
+ for (const c of candidates) {
823
+ let s = votes.get(c.el);
824
+ if (!s) {
825
+ s = /* @__PURE__ */ new Set();
826
+ votes.set(c.el, s);
827
+ }
828
+ s.add(c.rung);
829
+ }
830
+ let best = null;
831
+ for (const [el, rungs] of votes) {
832
+ if (!best || rungs.size > best.rungs.size) best = { el, rungs };
833
+ }
834
+ if (!best) return { element: null, confidence: "orphaned" };
835
+ if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
836
+ if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
837
+ if (best.rungs.has("class") || best.rungs.has("chain")) {
838
+ return { element: best.el, confidence: "drifted" };
839
+ }
840
+ return { element: null, confidence: "orphaned" };
841
+ }
842
+ function findByClassSignature(root, tagName, needle) {
843
+ const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
844
+ if (needleSet.size === 0) return null;
845
+ const all = root.querySelectorAll(tagName.toLowerCase());
846
+ let best = null;
847
+ let secondScore = 0;
848
+ for (const el of all) {
849
+ const haystack = new Set(
850
+ Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
851
+ );
852
+ if (haystack.size === 0) continue;
853
+ const score = jaccardSimilarity(needleSet, haystack);
854
+ if (score < CLASS_JACCARD_THRESHOLD) continue;
855
+ if (!best || score > best.score) {
856
+ secondScore = best?.score ?? 0;
857
+ best = { el, score };
858
+ } else if (score > secondScore) {
859
+ secondScore = score;
1982
860
  }
1983
861
  }
1984
- clearStoredToken() {
1985
- this.cachedToken = null;
1986
- if (typeof sessionStorage !== "undefined") {
1987
- sessionStorage.removeItem(this.sessionKey());
862
+ if (!best) return null;
863
+ if (best.score - secondScore < 0.1) return null;
864
+ return { element: best.el, confidence: "likely" };
865
+ }
866
+ function findByAncestorChain(root, tagName, steps) {
867
+ const all = root.querySelectorAll(tagName.toLowerCase());
868
+ let best = null;
869
+ for (const el of all) {
870
+ const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
871
+ const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
872
+ if (score < 2) continue;
873
+ if (!best || score > best.score) best = { el, score };
874
+ }
875
+ if (!best) return null;
876
+ return {
877
+ element: best.el,
878
+ confidence: best.score === steps.length ? "likely" : "drifted"
879
+ };
880
+ }
881
+ function collectAncestorSteps(el, limit) {
882
+ const steps = [];
883
+ let current = el.parentElement;
884
+ while (current && steps.length < limit) {
885
+ const parent = current.parentElement;
886
+ let nth = 1;
887
+ if (parent) {
888
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
889
+ nth = sameTag.indexOf(current) + 1;
1988
890
  }
891
+ steps.push({ tag: current.tagName, nthOfType: nth });
892
+ current = parent;
1989
893
  }
1990
- sessionKey() {
1991
- return `vizu:cloud-token:v2:${this.workspace}`;
1992
- }
1993
- /**
1994
- * Resolve a valid token, opening the popup if needed.
1995
- *
1996
- * Single-flight: simultaneous requests await one popup, not N.
1997
- * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
1998
- * if the user closes the popup; hosts can catch this and present
1999
- * a friendly message instead of a blank failure.
2000
- */
2001
- async resolveToken() {
2002
- if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
2003
- const fresh = this.readStoredToken();
2004
- if (fresh) {
2005
- this.cachedToken = fresh;
2006
- this.fireAuthChanged(fresh);
2007
- return fresh;
2008
- }
2009
- if (!this.autoSignIn) {
2010
- throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
2011
- }
2012
- if (!this.pendingAuth) {
2013
- this.pendingAuth = this.openSignInPopup().then((t) => {
2014
- this.writeStoredToken(t);
2015
- this.fireAuthChanged(t);
2016
- return t;
2017
- }).finally(() => {
2018
- this.pendingAuth = null;
2019
- });
894
+ return steps;
895
+ }
896
+ function findByTextSnippet(root, tagName, snippet) {
897
+ const needle = normalizeText(snippet);
898
+ if (!needle) return null;
899
+ const all = root.querySelectorAll(tagName.toLowerCase());
900
+ let best = null;
901
+ for (const el of all) {
902
+ const raw = el.innerText || el.textContent || "";
903
+ const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
904
+ if (!candidate) continue;
905
+ const score = levenshteinRatio(needle, candidate);
906
+ if (score < TEXT_RATIO_THRESHOLD) continue;
907
+ if (!best || score > best.score) {
908
+ best = { el, score };
2020
909
  }
2021
- return this.pendingAuth;
2022
910
  }
2023
- /**
2024
- * Notify the host that the authenticated identity changed. De-duped
2025
- * via the token string so multiple resolveToken() hits with the same
2026
- * cached token don't re-call setUser on every fetch.
2027
- */
2028
- fireAuthChanged(token) {
2029
- if (this.lastNotifiedTokenId === token.token) return;
2030
- this.lastNotifiedTokenId = token.token;
2031
- try {
2032
- this.onAuthChanged?.({ userId: token.userId, user: token.user });
2033
- } catch (err) {
2034
- if (typeof console !== "undefined") {
2035
- console.warn("[vizu/cloud] onAuthChanged callback threw", err);
2036
- }
2037
- }
911
+ return best ? { element: best.el, confidence: "drifted" } : null;
912
+ }
913
+ function normalizeText(input) {
914
+ return input.toLowerCase().replace(/\s+/g, " ").trim();
915
+ }
916
+ function levenshteinRatio(a, b) {
917
+ if (a === b) return 1;
918
+ if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
919
+ const maxLen = Math.max(a.length, b.length);
920
+ const distance = levenshteinDistance(a, b);
921
+ return 1 - distance / maxLen;
922
+ }
923
+ function levenshteinDistance(a, b) {
924
+ if (a.length > b.length) {
925
+ const tmp = a;
926
+ a = b;
927
+ b = tmp;
2038
928
  }
2039
- openSignInPopup() {
2040
- return new Promise((resolve, reject) => {
2041
- if (typeof window === "undefined") {
2042
- return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
2043
- }
2044
- const apiOrigin = new URL(this.apiUrl).origin;
2045
- const hostOrigin = window.location.origin;
2046
- const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
2047
- const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
2048
- const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
2049
- const popup = window.open(
2050
- popupUrl,
2051
- "vizu-connect",
2052
- `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
929
+ const prev = new Array(a.length + 1);
930
+ const curr = new Array(a.length + 1);
931
+ for (let i = 0; i <= a.length; i++) prev[i] = i;
932
+ for (let j = 1; j <= b.length; j++) {
933
+ curr[0] = j;
934
+ for (let i = 1; i <= a.length; i++) {
935
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
936
+ curr[i] = Math.min(
937
+ curr[i - 1] + 1,
938
+ // insert
939
+ prev[i] + 1,
940
+ // delete
941
+ prev[i - 1] + cost
942
+ // substitute
2053
943
  );
2054
- if (!popup) {
2055
- return reject(
2056
- makeAuthError(
2057
- "popup_blocked",
2058
- "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
2059
- )
2060
- );
2061
- }
2062
- let resolved = false;
2063
- const onMessage = (e) => {
2064
- if (e.origin !== apiOrigin) return;
2065
- const data = e.data;
2066
- if (!data || data.type !== "vizu:auth") return;
2067
- if (data.workspace !== this.workspace) return;
2068
- if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
2069
- resolved = true;
2070
- cleanup();
2071
- const userPayload = data.user;
2072
- const user = userPayload && typeof userPayload.name === "string" ? {
2073
- id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
2074
- name: userPayload.name,
2075
- avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
2076
- } : null;
2077
- resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
2078
- };
2079
- const onPoll = window.setInterval(() => {
2080
- if (popup.closed) {
2081
- if (!resolved) {
2082
- cleanup();
2083
- reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
2084
- }
2085
- }
2086
- }, 500);
2087
- function cleanup() {
2088
- window.clearInterval(onPoll);
2089
- window.removeEventListener("message", onMessage);
2090
- }
2091
- window.addEventListener("message", onMessage);
2092
- });
2093
- }
2094
- /* ─── Fetch wrapper ───────────────────────────────────────────────── */
2095
- /**
2096
- * Perform an authenticated fetch with one-shot retry on 401 (re-opens
2097
- * the popup if the token expired during the request). 4xx other than
2098
- * 401 / 404 throw; 5xx throws; 2xx returns.
2099
- */
2100
- async fetchAuthed(url, init2, retried = false) {
2101
- const token = await this.resolveToken();
2102
- const headers = new Headers(init2.headers);
2103
- headers.set("authorization", `Bearer ${token.token}`);
2104
- const res = await fetch(url, { ...init2, headers, credentials: "omit" });
2105
- if (res.status === 401 && !retried) {
2106
- this.clearStoredToken();
2107
- return this.fetchAuthed(url, init2, true);
2108
944
  }
2109
- return res;
945
+ for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
2110
946
  }
2111
- async parseJson(res) {
2112
- try {
2113
- const text = await res.text();
2114
- return text ? JSON.parse(text) : null;
2115
- } catch {
2116
- return null;
947
+ return prev[a.length];
948
+ }
949
+ function scoreChainAgainstAncestors(actual, captured, maxShift) {
950
+ let best = 0;
951
+ for (let offset = 0; offset <= maxShift; offset++) {
952
+ let score = 0;
953
+ for (let i = 0; i < captured.length; i++) {
954
+ const a = actual[i + offset];
955
+ if (!a) break;
956
+ if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
957
+ score++;
958
+ }
2117
959
  }
960
+ if (score > best) best = score;
2118
961
  }
2119
- };
2120
- function makeAuthError(code, message) {
2121
- const err = new Error(message);
2122
- err.code = code;
2123
- return err;
962
+ return best;
2124
963
  }
2125
- function apiErr(status, body) {
2126
- const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
2127
- const err = new Error(body?.message ?? `HTTP ${status}`);
2128
- err.code = code;
2129
- err.status = status;
2130
- err.body = body;
2131
- return err;
964
+ function jaccardSimilarity(a, b) {
965
+ if (a.size === 0 && b.size === 0) return 1;
966
+ let intersection = 0;
967
+ for (const item of a) if (b.has(item)) intersection++;
968
+ const union = a.size + b.size - intersection;
969
+ return union === 0 ? 0 : intersection / union;
970
+ }
971
+ function findElementByFingerprint(fp, root) {
972
+ return findByFingerprint(fp, root).element;
973
+ }
974
+ function fingerprintKey(fp) {
975
+ return fp.selector + "|" + fp.textSnippet;
976
+ }
977
+ function fingerprintLabel(fp) {
978
+ const tag = fp.tagName.toLowerCase();
979
+ if (fp.textSnippet) {
980
+ const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
981
+ return tag + ": " + snippet;
982
+ }
983
+ if (fp.attributes.id) return tag + "#" + fp.attributes.id;
984
+ if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
985
+ return tag;
2132
986
  }
2133
-
2134
- // src/index.ts
2135
- init_fingerprint();
2136
987
 
2137
988
  // src/highlighter.ts
2138
989
  var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
2139
990
  var Highlighter = class {
2140
- constructor(root, extraIgnore, onClick, onCurrentChange) {
991
+ constructor(root, extraIgnore, onClick) {
2141
992
  this.current = null;
2142
993
  this.active = false;
2143
994
  this.paused = false;
@@ -2172,7 +1023,6 @@ var Highlighter = class {
2172
1023
  this.root = root;
2173
1024
  this.extraIgnore = extraIgnore;
2174
1025
  this.onClick = onClick;
2175
- this.onCurrentChange = onCurrentChange ?? null;
2176
1026
  this.overlay = document.createElement("div");
2177
1027
  this.overlay.className = "vz-highlight";
2178
1028
  this.overlay.style.display = "none";
@@ -2181,7 +1031,6 @@ var Highlighter = class {
2181
1031
  setCurrent(el) {
2182
1032
  if (this.current === el) return;
2183
1033
  this.current = el;
2184
- this.onCurrentChange?.(el);
2185
1034
  }
2186
1035
  start() {
2187
1036
  if (this.active) return;
@@ -2242,7 +1091,6 @@ var Highlighter = class {
2242
1091
  };
2243
1092
 
2244
1093
  // src/popover.ts
2245
- init_fingerprint();
2246
1094
  function cssEscape(s) {
2247
1095
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
2248
1096
  return s.replace(/["\\]/g, "\\$&");
@@ -2920,7 +1768,6 @@ var Pill = class {
2920
1768
  };
2921
1769
 
2922
1770
  // src/sidebar.ts
2923
- init_fingerprint();
2924
1771
  var Sidebar = class {
2925
1772
  constructor(root, callbacks) {
2926
1773
  this.open = false;
@@ -4169,7 +3016,6 @@ var EventBus = class {
4169
3016
  };
4170
3017
 
4171
3018
  // src/index.ts
4172
- init_fingerprint();
4173
3019
  var DEFAULTS = {
4174
3020
  shortcut: "mod+shift+e",
4175
3021
  namespace: "default",
@@ -4229,8 +3075,6 @@ var Vizu = class {
4229
3075
  this.selfHealedThisSession = /* @__PURE__ */ new Set();
4230
3076
  this.actions = [];
4231
3077
  this.user = null;
4232
- /** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
4233
- this.liveCollab = null;
4234
3078
  this.enabled = false;
4235
3079
  this.rafScheduled = false;
4236
3080
  this.bus = new EventBus();
@@ -4302,31 +3146,6 @@ var Vizu = class {
4302
3146
  if (options.actions) this.actions = [...options.actions];
4303
3147
  if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
4304
3148
  if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
4305
- if (options.cloud) {
4306
- const cloud = options.cloud;
4307
- const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
4308
- void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
4309
- this.liveCollab = new LiveCollab2({
4310
- workspaceSlug: cloud.workspace,
4311
- apiUrl,
4312
- getAuthHeader: async () => {
4313
- if (typeof window === "undefined") return null;
4314
- try {
4315
- if (new URL(apiUrl).origin === window.location.origin) return null;
4316
- } catch {
4317
- return null;
4318
- }
4319
- const adapter = this.storage;
4320
- if (adapter instanceof CloudStorageAdapter) {
4321
- const token = adapter.getCachedAuthToken();
4322
- if (token) return { Authorization: `Bearer ${token}` };
4323
- }
4324
- return null;
4325
- }
4326
- });
4327
- void this.liveCollab.start();
4328
- });
4329
- }
4330
3149
  if (typeof window !== "undefined") {
4331
3150
  window.addEventListener("keydown", this.onKeyDown);
4332
3151
  void this.loadComments().then(() => this.subscribeStorage());
@@ -4350,6 +3169,23 @@ var Vizu = class {
4350
3169
  getUser() {
4351
3170
  return this.user;
4352
3171
  }
3172
+ /**
3173
+ * Cloud-mode write gate. Every entry point that creates or modifies a
3174
+ * comment first checks that we have a known user. Without one, the
3175
+ * write would land as Anonymous (or 401 on the backend) — neither is
3176
+ * the right UX. So we re-trigger the sign-in popup and refuse the
3177
+ * action. The user signs in, this.user gets set via onAuthChanged,
3178
+ * then they can retry the click themselves.
3179
+ *
3180
+ * Returns true when the caller may proceed.
3181
+ */
3182
+ requireUserOrAuth() {
3183
+ if (!(this.storage instanceof CloudStorageAdapter)) return true;
3184
+ if (this.user) return true;
3185
+ void this.storage.preflightAuth().catch(() => {
3186
+ });
3187
+ return false;
3188
+ }
4353
3189
  /* ===== Lifecycle ===== */
4354
3190
  enable() {
4355
3191
  if (this.enabled) return;
@@ -4384,8 +3220,6 @@ var Vizu = class {
4384
3220
  this.unsubscribeStorage?.();
4385
3221
  this.unsubscribeStorage = null;
4386
3222
  this.selfHealedThisSession.clear();
4387
- this.liveCollab?.destroy();
4388
- this.liveCollab = null;
4389
3223
  this.bus.clear();
4390
3224
  }
4391
3225
  /* ===== Action API ===== */
@@ -4520,6 +3354,7 @@ var Vizu = class {
4520
3354
  * patch for adapters that don't implement reply ops.
4521
3355
  */
4522
3356
  async addReply(commentId, text, mentions) {
3357
+ if (!this.requireUserOrAuth()) return null;
4523
3358
  const idx = this.comments.findIndex((c) => c.id === commentId);
4524
3359
  if (idx === -1) return null;
4525
3360
  const reply = {
@@ -4586,6 +3421,7 @@ var Vizu = class {
4586
3421
  /* ===== Multi-select API ===== */
4587
3422
  /** Toggle multi-select mode. When on, plain clicks accumulate into the selection. */
4588
3423
  toggleMultiSelectMode() {
3424
+ if (!this.requireUserOrAuth()) return;
4589
3425
  this.multiSelectMode = !this.multiSelectMode;
4590
3426
  if (!this.multiSelectMode) this.clearSelection();
4591
3427
  this.pill?.setMultiSelectState(this.multiSelectMode, this.pendingFingerprints.length);
@@ -4707,11 +3543,7 @@ var Vizu = class {
4707
3543
  this.highlighter = new Highlighter(
4708
3544
  this.root,
4709
3545
  this.opts.ignoreSelectors ?? [],
4710
- (el, e) => this.onElementClick(el, e),
4711
- (el) => {
4712
- if (!this.liveCollab) return;
4713
- this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
4714
- }
3546
+ (el, e) => this.onElementClick(el, e)
4715
3547
  );
4716
3548
  this.highlighter.start();
4717
3549
  this.renderAllMarkers();
@@ -4751,6 +3583,7 @@ var Vizu = class {
4751
3583
  p.onSelect(fp2);
4752
3584
  return;
4753
3585
  }
3586
+ if (!this.requireUserOrAuth()) return;
4754
3587
  const fp = fingerprint(el);
4755
3588
  if (this.popover?.isOpen() && e.shiftKey) {
4756
3589
  this.popover.addAnchor(fp, el);
@@ -4798,6 +3631,7 @@ var Vizu = class {
4798
3631
  );
4799
3632
  }
4800
3633
  async addCommentFromPopover(text, fps, references, mentions, attachments) {
3634
+ if (!this.requireUserOrAuth()) return;
4801
3635
  if (fps.length === 0) return;
4802
3636
  const pageUrl = typeof location !== "undefined" ? location.href : void 0;
4803
3637
  const c = {