@unhingged/vizu-core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auto.cjs 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,1515 +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 fnv1a32Hex(input) {
774
- let h = 2166136261;
775
- for (let i = 0; i < input.length; i++) {
776
- h ^= input.charCodeAt(i);
777
- h = Math.imul(h, 16777619);
778
- }
779
- return (h >>> 0).toString(16).padStart(8, "0");
780
- }
781
- function roomIdFor(workspaceSlug, pageUrl) {
782
- return `ws_${workspaceSlug}__pg_${fnv1a32Hex(pageUrl)}`;
783
- }
784
- function createCursorElement(name, color) {
785
- const wrap = document.createElement("div");
786
- wrap.setAttribute("data-vizu-cursor", "");
787
- const transformTransition = prefersReducedMotion() ? "none" : `transform ${CURSOR_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`;
788
- wrap.style.cssText = `
789
- position: absolute; top: 0; left: 0;
790
- pointer-events: none;
791
- opacity: 0;
792
- transform: translate(0px, 0px);
793
- transition: ${transformTransition}, opacity 220ms ease-out;
794
- will-change: transform;
795
- `;
796
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
797
- svg.setAttribute("width", "18");
798
- svg.setAttribute("height", "20");
799
- svg.setAttribute("viewBox", "0 0 16 18");
800
- svg.style.cssText = "display: block; filter: drop-shadow(0 1px 1.5px rgba(0,0,0,0.3));";
801
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
802
- path.setAttribute("d", "M0 0 L0 13 L4 9 L7 16 L9 15 L6 8 L11 8 Z");
803
- path.setAttribute("fill", color);
804
- path.setAttribute("stroke", "#ffffff");
805
- path.setAttribute("stroke-width", "1.2");
806
- path.setAttribute("stroke-linejoin", "round");
807
- svg.appendChild(path);
808
- wrap.appendChild(svg);
809
- const label = document.createElement("div");
810
- label.textContent = name;
811
- label.style.cssText = `
812
- margin-top: 2px;
813
- margin-left: 10px;
814
- padding: 2px 7px;
815
- background: ${color};
816
- color: #ffffff;
817
- font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
818
- border-radius: 4px;
819
- white-space: nowrap;
820
- box-shadow: 0 2px 6px rgba(0,0,0,0.18);
821
- max-width: 160px;
822
- overflow: hidden;
823
- text-overflow: ellipsis;
824
- `;
825
- wrap.appendChild(label);
826
- return wrap;
827
- }
828
- function labelForStatus(status) {
829
- switch (status) {
830
- case "connecting":
831
- return "Connecting\u2026";
832
- case "reconnecting":
833
- return "Reconnecting\u2026";
834
- case "disconnected":
835
- return "Offline";
836
- case "initial":
837
- return "Connecting\u2026";
838
- case "connected":
839
- return "";
840
- }
841
- }
842
- var import_client, MOUSEMOVE_THROTTLE_MS, SCROLL_THROTTLE_MS, INACTIVITY_FADE_MS, CURSOR_TRANSITION_MS, Z_INDEX3, FOLLOW_SCROLL_THRESHOLD_PX, LiveCollab, ConnectionIndicator, FollowPill;
843
- var init_live_collab = __esm({
844
- "src/live-collab.ts"() {
845
- "use strict";
846
- import_client = require("@liveblocks/client");
847
- init_presence_stack();
848
- init_selection_overlay();
849
- init_cursor_colors();
850
- MOUSEMOVE_THROTTLE_MS = 50;
851
- SCROLL_THROTTLE_MS = 100;
852
- INACTIVITY_FADE_MS = 5e3;
853
- CURSOR_TRANSITION_MS = 110;
854
- Z_INDEX3 = 2147483641;
855
- FOLLOW_SCROLL_THRESHOLD_PX = 8;
856
- LiveCollab = class {
857
- constructor(opts) {
858
- this.opts = opts;
859
- this.client = null;
860
- this.room = null;
861
- this.leave = null;
862
- this.container = null;
863
- this.cursors = /* @__PURE__ */ new Map();
864
- // keyed by connectionId
865
- this.presenceStack = null;
866
- this.followPill = null;
867
- this.selectionOverlay = null;
868
- this.mouseListener = null;
869
- this.scrollListener = null;
870
- this.visibilityListener = null;
871
- this.keydownListener = null;
872
- this.statusIndicator = null;
873
- this.throttleTimer = null;
874
- this.scrollThrottleTimer = null;
875
- this.pendingCursor = null;
876
- this.lastBroadcast = 0;
877
- this.lastScrollBroadcast = 0;
878
- this.unsubscribers = [];
879
- this.destroyed = false;
880
- // Follow mode state
881
- /** Clerk user id we're currently following, or null. */
882
- this.followingUserId = null;
883
- /** Last scrollY we received from the followee — for delta gating. */
884
- this.lastFolloweeScrollY = null;
885
- }
886
- async start() {
887
- if (typeof window === "undefined" || typeof document === "undefined") return;
888
- if (this.destroyed) return;
889
- try {
890
- void this.opts.publicKey;
891
- this.client = (0, import_client.createClient)({
892
- authEndpoint: async (room2) => {
893
- const res = await fetch(this.opts.authEndpoint, {
894
- method: "POST",
895
- headers: { "content-type": "application/json" },
896
- credentials: "include",
897
- body: JSON.stringify({ room: room2 })
898
- });
899
- if (!res.ok) {
900
- throw new Error(`vizu-liveblocks-auth: HTTP ${res.status}`);
901
- }
902
- return res.json();
903
- }
904
- });
905
- const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
906
- const room = this.opts.workspaceSlug ? roomIdFor(this.opts.workspaceSlug, pageUrl) : null;
907
- if (!room) return;
908
- const result = this.client.enterRoom(room, {
909
- initialPresence: {
910
- cursor: null,
911
- scrollY: typeof window !== "undefined" ? window.scrollY : 0,
912
- selecting: null
913
- }
914
- });
915
- this.room = result.room;
916
- this.leave = result.leave;
917
- this.mountContainer();
918
- this.presenceStack = new PresenceStack({
919
- onAvatarClick: (userId) => this.setFollowing(userId)
920
- });
921
- this.presenceStack.mount();
922
- this.followPill = new FollowPill({
923
- onStop: () => this.setFollowing(null)
924
- });
925
- this.selectionOverlay = new SelectionOverlay();
926
- this.selectionOverlay.mount();
927
- this.statusIndicator = new ConnectionIndicator();
928
- this.statusIndicator.mount();
929
- this.subscribeOthers();
930
- this.subscribeStatus();
931
- this.attachMouseListener();
932
- this.attachScrollListener();
933
- this.attachVisibilityListener();
934
- this.attachKeyboardListener();
935
- } catch (err) {
936
- if (typeof console !== "undefined") {
937
- console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
938
- }
939
- this.cleanup();
940
- }
941
- }
942
- destroy() {
943
- this.destroyed = true;
944
- this.cleanup();
945
- }
946
- /* ────────────────── private ────────────────── */
947
- cleanup() {
948
- if (this.mouseListener) {
949
- window.removeEventListener("mousemove", this.mouseListener);
950
- this.mouseListener = null;
951
- }
952
- if (this.scrollListener) {
953
- window.removeEventListener("scroll", this.scrollListener);
954
- this.scrollListener = null;
955
- }
956
- if (this.visibilityListener) {
957
- document.removeEventListener("visibilitychange", this.visibilityListener);
958
- this.visibilityListener = null;
959
- }
960
- if (this.keydownListener) {
961
- document.removeEventListener("keydown", this.keydownListener);
962
- this.keydownListener = null;
963
- }
964
- if (this.throttleTimer != null) {
965
- window.clearTimeout(this.throttleTimer);
966
- this.throttleTimer = null;
967
- }
968
- if (this.scrollThrottleTimer != null) {
969
- window.clearTimeout(this.scrollThrottleTimer);
970
- this.scrollThrottleTimer = null;
971
- }
972
- for (const unsub of this.unsubscribers) unsub();
973
- this.unsubscribers = [];
974
- for (const c of this.cursors.values()) {
975
- if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
976
- c.el.remove();
977
- }
978
- this.cursors.clear();
979
- this.presenceStack?.destroy();
980
- this.presenceStack = null;
981
- this.followPill?.destroy();
982
- this.followPill = null;
983
- this.selectionOverlay?.destroy();
984
- this.selectionOverlay = null;
985
- this.statusIndicator?.destroy();
986
- this.statusIndicator = null;
987
- this.followingUserId = null;
988
- this.lastFolloweeScrollY = null;
989
- this.container?.remove();
990
- this.container = null;
991
- this.leave?.();
992
- this.leave = null;
993
- this.room = null;
994
- this.client = null;
995
- }
996
- /**
997
- * Public API: broadcast the local user's currently-hovered element
998
- * (highlighter mode) so other clients can render a ghost outline on
999
- * it. Pass null to clear (highlighter stopped, popover opened, etc.).
1000
- * Idempotent on no-op transitions. Slice 5 of live-collab.md.
1001
- */
1002
- setLocalSelection(fingerprint2) {
1003
- if (!this.room) return;
1004
- this.room.updatePresence({
1005
- selecting: fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null
1006
- });
1007
- }
1008
- /**
1009
- * Public API: start/stop following a user by Clerk id. Null clears.
1010
- * Called by the avatar click handler and by the FollowPill stop button.
1011
- * Idempotent — same id again is a no-op.
1012
- */
1013
- setFollowing(userId) {
1014
- if (this.followingUserId === userId) return;
1015
- this.followingUserId = userId;
1016
- this.lastFolloweeScrollY = null;
1017
- this.presenceStack?.setFollowing(userId);
1018
- this.followPill?.setFollowing(userId ? this.lookupUserForPill(userId) : null);
1019
- if (userId) this.scrollToFolloweeIfKnown();
1020
- }
1021
- lookupUserForPill(userId) {
1022
- if (!this.room) return null;
1023
- const others = this.room.getOthers();
1024
- for (const o of others) {
1025
- const meta = o;
1026
- if ((meta.id ?? `c${o.connectionId}`) === userId) {
1027
- return {
1028
- id: userId,
1029
- name: meta.info?.name ?? "Anonymous",
1030
- color: colorForUser(userId),
1031
- avatar: meta.info?.avatar
1032
- };
1033
- }
1034
- }
1035
- return null;
1036
- }
1037
- scrollToFolloweeIfKnown() {
1038
- if (!this.followingUserId || !this.room) return;
1039
- const others = this.room.getOthers();
1040
- for (const o of others) {
1041
- const meta = o;
1042
- if ((meta.id ?? `c${o.connectionId}`) === this.followingUserId) {
1043
- const presence = o.presence;
1044
- if (typeof presence.scrollY === "number") {
1045
- this.followScrollTo(presence.scrollY);
1046
- }
1047
- return;
1048
- }
1049
- }
1050
- }
1051
- followScrollTo(scrollY) {
1052
- if (this.lastFolloweeScrollY !== null && Math.abs(this.lastFolloweeScrollY - scrollY) < FOLLOW_SCROLL_THRESHOLD_PX) {
1053
- return;
1054
- }
1055
- this.lastFolloweeScrollY = scrollY;
1056
- window.scrollTo({ top: scrollY, behavior: "smooth" });
1057
- }
1058
- mountContainer() {
1059
- this.container = document.createElement("div");
1060
- this.container.setAttribute("data-vizu-live-cursors", "");
1061
- this.container.style.cssText = `
1062
- position: fixed; inset: 0;
1063
- pointer-events: none;
1064
- z-index: ${Z_INDEX3};
1065
- contain: strict;
1066
- `;
1067
- document.body.appendChild(this.container);
1068
- }
1069
- attachMouseListener() {
1070
- this.mouseListener = (e) => {
1071
- const xPct = e.clientX / window.innerWidth * 100;
1072
- const yPct = e.clientY / window.innerHeight * 100;
1073
- this.pendingCursor = { xPct, yPct };
1074
- this.flushThrottled();
1075
- };
1076
- window.addEventListener("mousemove", this.mouseListener, { passive: true });
1077
- }
1078
- attachScrollListener() {
1079
- this.scrollListener = () => {
1080
- const now = performance.now();
1081
- const elapsed = now - this.lastScrollBroadcast;
1082
- if (elapsed >= SCROLL_THROTTLE_MS) {
1083
- this.flushScroll();
1084
- return;
1085
- }
1086
- if (this.scrollThrottleTimer == null) {
1087
- this.scrollThrottleTimer = window.setTimeout(() => {
1088
- this.scrollThrottleTimer = null;
1089
- this.flushScroll();
1090
- }, SCROLL_THROTTLE_MS - elapsed);
1091
- }
1092
- };
1093
- window.addEventListener("scroll", this.scrollListener, { passive: true });
1094
- }
1095
- flushScroll() {
1096
- if (!this.room) return;
1097
- this.room.updatePresence({ scrollY: window.scrollY });
1098
- this.lastScrollBroadcast = performance.now();
1099
- }
1100
- attachVisibilityListener() {
1101
- this.visibilityListener = () => {
1102
- if (document.hidden && this.room) {
1103
- this.room.updatePresence({ cursor: null });
1104
- }
1105
- };
1106
- document.addEventListener("visibilitychange", this.visibilityListener);
1107
- }
1108
- attachKeyboardListener() {
1109
- this.keydownListener = (e) => {
1110
- if (e.key !== "Escape") return;
1111
- if (!this.followingUserId) return;
1112
- this.setFollowing(null);
1113
- };
1114
- document.addEventListener("keydown", this.keydownListener);
1115
- }
1116
- subscribeStatus() {
1117
- if (!this.room) return;
1118
- this.statusIndicator?.setStatus(this.room.getStatus());
1119
- const unsub = this.room.subscribe("status", (status) => {
1120
- this.statusIndicator?.setStatus(status);
1121
- });
1122
- this.unsubscribers.push(unsub);
1123
- }
1124
- flushThrottled() {
1125
- const now = performance.now();
1126
- const elapsed = now - this.lastBroadcast;
1127
- if (elapsed >= MOUSEMOVE_THROTTLE_MS) {
1128
- this.flush();
1129
- return;
1130
- }
1131
- if (this.throttleTimer == null) {
1132
- this.throttleTimer = window.setTimeout(() => {
1133
- this.throttleTimer = null;
1134
- this.flush();
1135
- }, MOUSEMOVE_THROTTLE_MS - elapsed);
1136
- }
1137
- }
1138
- flush() {
1139
- if (!this.pendingCursor || !this.room) return;
1140
- this.room.updatePresence({ cursor: this.pendingCursor });
1141
- this.pendingCursor = null;
1142
- this.lastBroadcast = performance.now();
1143
- }
1144
- subscribeOthers() {
1145
- if (!this.room) return;
1146
- const unsub = this.room.subscribe("others", (others) => {
1147
- const seen = /* @__PURE__ */ new Set();
1148
- const stackUsers = /* @__PURE__ */ new Map();
1149
- const selections = [];
1150
- let followeeScrollY = null;
1151
- let followeeStillPresent = false;
1152
- for (const other of others) {
1153
- seen.add(other.connectionId);
1154
- const meta = other;
1155
- const userId = meta.id ?? `c${other.connectionId}`;
1156
- const name = meta.info?.name ?? "Anonymous";
1157
- const color = colorForUser(userId);
1158
- if (!stackUsers.has(userId)) {
1159
- stackUsers.set(userId, {
1160
- id: userId,
1161
- name,
1162
- color,
1163
- avatar: meta.info?.avatar
1164
- });
1165
- }
1166
- const presence = other.presence;
1167
- if (this.followingUserId === userId && followeeScrollY === null) {
1168
- followeeStillPresent = true;
1169
- if (typeof presence.scrollY === "number") {
1170
- followeeScrollY = presence.scrollY;
1171
- }
1172
- }
1173
- if (presence.selecting && !selections.find((s) => s.id === userId)) {
1174
- try {
1175
- const fp = JSON.parse(presence.selecting.fingerprintJson);
1176
- selections.push({ id: userId, name, color, fingerprint: fp });
1177
- } catch {
1178
- }
1179
- }
1180
- if (!presence.cursor) {
1181
- this.removeCursor(other.connectionId);
1182
- continue;
1183
- }
1184
- this.upsertCursor(other.connectionId, userId, name, presence.cursor);
1185
- }
1186
- for (const id of Array.from(this.cursors.keys())) {
1187
- if (!seen.has(id)) this.removeCursor(id);
1188
- }
1189
- this.presenceStack?.setOthers(Array.from(stackUsers.values()));
1190
- this.selectionOverlay?.setSelections(selections);
1191
- if (this.followingUserId) {
1192
- if (!followeeStillPresent) {
1193
- this.setFollowing(null);
1194
- } else if (followeeScrollY !== null) {
1195
- this.followScrollTo(followeeScrollY);
1196
- }
1197
- }
1198
- });
1199
- this.unsubscribers.push(unsub);
1200
- }
1201
- upsertCursor(connectionId, userId, name, cursor) {
1202
- if (!this.container) return;
1203
- let entry = this.cursors.get(connectionId);
1204
- if (!entry) {
1205
- const color = colorForUser(userId);
1206
- const el = createCursorElement(name, color);
1207
- this.container.appendChild(el);
1208
- entry = { el, fadeTimer: null };
1209
- this.cursors.set(connectionId, entry);
1210
- }
1211
- const inViewport = cursor.xPct >= 0 && cursor.xPct <= 100 && cursor.yPct >= 0 && cursor.yPct <= 100;
1212
- if (!inViewport) {
1213
- entry.el.style.opacity = "0";
1214
- return;
1215
- }
1216
- const x = window.innerWidth * cursor.xPct / 100;
1217
- const y = window.innerHeight * cursor.yPct / 100;
1218
- entry.el.style.transform = `translate(${x}px, ${y}px)`;
1219
- entry.el.style.opacity = "1";
1220
- if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
1221
- entry.fadeTimer = window.setTimeout(() => {
1222
- if (entry) entry.el.style.opacity = "0";
1223
- }, INACTIVITY_FADE_MS);
1224
- }
1225
- removeCursor(connectionId) {
1226
- const entry = this.cursors.get(connectionId);
1227
- if (!entry) return;
1228
- if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
1229
- entry.el.style.opacity = "0";
1230
- window.setTimeout(() => entry.el.remove(), 220);
1231
- this.cursors.delete(connectionId);
1232
- }
1233
- };
1234
- ConnectionIndicator = class {
1235
- constructor() {
1236
- this.el = null;
1237
- this.label = null;
1238
- }
1239
- mount() {
1240
- if (typeof document === "undefined") return;
1241
- if (this.el) return;
1242
- this.el = document.createElement("div");
1243
- this.el.setAttribute("data-vizu-connection-status", "");
1244
- this.el.style.cssText = `
1245
- position: fixed;
1246
- top: 18px;
1247
- right: 200px;
1248
- z-index: ${Z_INDEX3};
1249
- display: none;
1250
- align-items: center;
1251
- gap: 7px;
1252
- padding: 4px 10px 4px 8px;
1253
- background: rgba(10, 10, 10, 0.88);
1254
- color: #fafafa;
1255
- font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1256
- border-radius: 999px;
1257
- box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
1258
- pointer-events: none;
1259
- `;
1260
- const dot = document.createElement("span");
1261
- dot.style.cssText = `
1262
- display: inline-block;
1263
- width: 6px; height: 6px;
1264
- border-radius: 50%;
1265
- background: #F59E0B;
1266
- box-shadow: 0 0 6px #F59E0B;
1267
- `;
1268
- const label = document.createElement("span");
1269
- this.label = label;
1270
- this.el.appendChild(dot);
1271
- this.el.appendChild(label);
1272
- document.body.appendChild(this.el);
1273
- }
1274
- setStatus(status) {
1275
- if (!this.el || !this.label) return;
1276
- if (status === "connected") {
1277
- this.el.style.display = "none";
1278
- return;
1279
- }
1280
- this.label.textContent = labelForStatus(status);
1281
- this.el.style.display = "inline-flex";
1282
- }
1283
- destroy() {
1284
- this.el?.remove();
1285
- this.el = null;
1286
- this.label = null;
1287
- }
1288
- };
1289
- FollowPill = class {
1290
- constructor(opts) {
1291
- this.el = null;
1292
- this.onStop = opts.onStop;
1293
- }
1294
- setFollowing(user) {
1295
- if (typeof document === "undefined") return;
1296
- if (!user) {
1297
- this.el?.remove();
1298
- this.el = null;
1299
- return;
1300
- }
1301
- if (!this.el) this.el = this.mount();
1302
- this.paint(user);
1303
- }
1304
- destroy() {
1305
- this.el?.remove();
1306
- this.el = null;
1307
- }
1308
- mount() {
1309
- const pill = document.createElement("div");
1310
- pill.setAttribute("data-vizu-follow-pill", "");
1311
- pill.style.cssText = `
1312
- position: fixed;
1313
- top: 56px;
1314
- right: 16px;
1315
- z-index: ${Z_INDEX3};
1316
- display: inline-flex;
1317
- align-items: center;
1318
- gap: 8px;
1319
- padding: 6px 6px 6px 12px;
1320
- background: rgba(10, 10, 10, 0.92);
1321
- color: #ffffff;
1322
- border-radius: 999px;
1323
- font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1324
- box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
1325
- pointer-events: auto;
1326
- `;
1327
- document.body.appendChild(pill);
1328
- return pill;
1329
- }
1330
- paint(user) {
1331
- if (!this.el) return;
1332
- this.el.textContent = "";
1333
- const dot = document.createElement("span");
1334
- dot.style.cssText = `
1335
- display: inline-block;
1336
- width: 8px; height: 8px;
1337
- border-radius: 50%;
1338
- background: ${user.color};
1339
- box-shadow: 0 0 8px ${user.color};
1340
- `;
1341
- this.el.appendChild(dot);
1342
- const label = document.createElement("span");
1343
- label.textContent = `Following ${user.name}`;
1344
- label.style.cssText = "max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
1345
- this.el.appendChild(label);
1346
- const stop = document.createElement("button");
1347
- stop.type = "button";
1348
- stop.setAttribute("aria-label", `Stop following ${user.name}`);
1349
- 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>';
1350
- stop.style.cssText = `
1351
- display: inline-flex;
1352
- align-items: center;
1353
- justify-content: center;
1354
- width: 22px; height: 22px;
1355
- border: none;
1356
- background: rgba(255, 255, 255, 0.12);
1357
- color: #ffffff;
1358
- border-radius: 50%;
1359
- cursor: pointer;
1360
- transition: background 120ms ease-out;
1361
- `;
1362
- stop.addEventListener("mouseenter", () => stop.style.background = "rgba(255, 255, 255, 0.22)");
1363
- stop.addEventListener("mouseleave", () => stop.style.background = "rgba(255, 255, 255, 0.12)");
1364
- stop.addEventListener("click", (e) => {
1365
- e.preventDefault();
1366
- this.onStop();
1367
- });
1368
- this.el.appendChild(stop);
1369
- }
1370
- };
1371
- }
1372
- });
1373
-
1374
- // src/auto.ts
1375
- var auto_exports = {};
1376
- __export(auto_exports, {
1377
- Vizu: () => Vizu
1378
- });
1379
- module.exports = __toCommonJS(auto_exports);
1380
-
1381
- // src/types.ts
1382
- var SCHEMA_VERSION = 2;
1383
-
1384
- // src/util.ts
1385
- function uuid() {
1386
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
1387
- return crypto.randomUUID();
1388
- }
1389
- return Math.random().toString(36).slice(2) + Date.now().toString(36);
1390
- }
1391
- function isInside(target, selector) {
1392
- return !!target.closest(selector);
1393
- }
1394
- function escapeHtml(s) {
1395
- return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
1396
- }
1397
- function formatTime(ts) {
1398
- const d = new Date(ts);
1399
- const now = Date.now();
1400
- const diff = now - ts;
1401
- if (diff < 6e4) return "just now";
1402
- if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
1403
- if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
1404
- return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1405
- }
1406
- function initials(name) {
1407
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
1408
- }
1409
- function avatarHtml(user, className = "vz-comment-author-avatar") {
1410
- if (!user) return `<span class="${className}">\xB7</span>`;
1411
- if (user.avatarUrl) {
1412
- return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
1413
- }
1414
- return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
1415
- }
1416
- function refId() {
1417
- return Math.random().toString(36).slice(2, 10);
1418
- }
1419
- var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
1420
- var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
1421
- function renderCommentText(text, commentId) {
1422
- const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
1423
- return parts.map((part) => {
1424
- const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
1425
- if (refMatch) {
1426
- 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>`;
1427
- }
1428
- const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
1429
- if (mentionMatch) {
1430
- return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
1431
- }
1432
- return escapeHtml(part);
1433
- }).join("");
1434
- }
1435
- function extractMentions(text) {
1436
- const out = [];
1437
- for (const m of text.matchAll(MENTION_TOKEN_RE)) {
1438
- if (!out.includes(m[2])) out.push(m[2]);
1439
- }
1440
- return out;
1441
- }
1442
- function renderAttachmentsHtml(attachments) {
1443
- if (!Array.isArray(attachments) || attachments.length === 0) return "";
1444
- const items = attachments.map((a) => {
1445
- const isImage = a.mimeType?.startsWith("image/");
1446
- const filename = a.filename ?? a.url.split("/").pop() ?? "file";
1447
- if (isImage) {
1448
- return `
1449
- <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
1450
- <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
1451
- </a>
1452
- `;
1453
- }
1454
- return `
1455
- <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
1456
- <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
1457
- <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
1458
- </a>
1459
- `;
1460
- }).join("");
1461
- return `<div class="vz-attachment-grid">${items}</div>`;
1462
- }
1463
- function migrateComment(raw) {
1464
- if (!raw || typeof raw !== "object") return raw;
1465
- const next = { ...raw };
1466
- if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
1467
- if (next.fingerprint) {
1468
- next.fingerprints = [next.fingerprint];
1469
- }
1470
- }
1471
- delete next.fingerprint;
1472
- if (next.url && !next.pageUrl) {
1473
- next.pageUrl = next.url;
1474
- }
1475
- if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
1476
- if (!Array.isArray(next.replies)) next.replies = [];
1477
- if (!Array.isArray(next.attachments)) next.attachments = [];
1478
- if (!Array.isArray(next.mentions)) next.mentions = [];
1479
- if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
1480
- next.schemaVersion = SCHEMA_VERSION;
1481
- return next;
1482
- }
1483
- function isValidStatus(s) {
1484
- return s === "open" || s === "resolved" || s === "wontfix";
1485
- }
1486
- function migrateComments(raws) {
1487
- if (!Array.isArray(raws)) return [];
1488
- const out = [];
1489
- for (const raw of raws) {
1490
- if (!raw || typeof raw !== "object") continue;
1491
- out.push(migrateComment(raw));
1492
- }
1493
- return out;
1494
- }
1495
- function needsPersist(raws) {
1496
- if (!Array.isArray(raws)) return false;
1497
- for (const raw of raws) {
1498
- if (!raw || typeof raw !== "object") continue;
1499
- const c = raw;
1500
- if (c.schemaVersion !== SCHEMA_VERSION) return true;
1501
- if ("fingerprint" in c) return true;
1502
- if (!Array.isArray(c.replies)) return true;
1503
- if (!Array.isArray(c.attachments)) return true;
1504
- if (!Array.isArray(c.mentions)) return true;
1505
- }
1506
- return false;
1507
- }
1508
-
1509
- // src/storage.ts
1510
- var key = (ns) => `vizu:comments:${ns}`;
1511
- var LocalStorageAdapter = class {
1512
- async load(namespace) {
1513
- return readArray(namespace);
1514
- }
1515
- async addComment(namespace, comment) {
1516
- const list = readArray(namespace);
1517
- list.push(comment);
1518
- writeArray(namespace, list);
1519
- }
1520
- async updateComment(namespace, id, patch) {
1521
- const list = readArray(namespace);
1522
- const idx = list.findIndex((c) => c.id === id);
1523
- if (idx === -1) return null;
1524
- const next = { ...list[idx], ...patch, id: list[idx].id };
1525
- list[idx] = next;
1526
- writeArray(namespace, list);
1527
- return next;
1528
- }
1529
- async removeComment(namespace, id) {
1530
- const list = readArray(namespace);
1531
- 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));
1532
178
  }
1533
179
  async setAll(namespace, comments) {
1534
180
  writeArray(namespace, comments);
@@ -1787,278 +433,562 @@ var CloudStorageAdapter = class {
1787
433
  throw apiErr(res.status, json);
1788
434
  }
1789
435
  }
1790
- async setAll(namespace, comments) {
1791
- await this.clear(namespace);
1792
- for (const c of comments) {
1793
- await this.addComment(namespace, c);
436
+ async setAll(namespace, comments) {
437
+ await this.clear(namespace);
438
+ for (const c of comments) {
439
+ await this.addComment(namespace, c);
440
+ }
441
+ }
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;
1794
684
  }
1795
685
  }
1796
- async clear(_namespace) {
1797
- const url = new URL(this.apiUrl + "/api/comments");
1798
- url.searchParams.set("workspace", this.workspace);
1799
- url.searchParams.set("all", "true");
1800
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1801
- if (res.status === 404) return;
1802
- if (!res.ok) {
1803
- const json = await this.parseJson(res);
1804
- throw apiErr(res.status, json);
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;
1805
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
+ }
726
+ }
727
+ parts.unshift(part);
728
+ current = parent;
729
+ depth++;
1806
730
  }
1807
- async addReply(_namespace, commentId, reply) {
1808
- const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
1809
- url.searchParams.set("workspace", this.workspace);
1810
- const res = await this.fetchAuthed(url.toString(), {
1811
- method: "POST",
1812
- headers: { "content-type": "application/json" },
1813
- body: JSON.stringify(reply)
1814
- });
1815
- if (res.status === 404) return null;
1816
- const json = await this.parseJson(res);
1817
- if (!res.ok) throw apiErr(res.status, json);
1818
- 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);
1819
739
  }
1820
- async removeReply(_namespace, commentId, replyId) {
1821
- const url = new URL(
1822
- this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
1823
- );
1824
- url.searchParams.set("workspace", this.workspace);
1825
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1826
- if (res.status === 404) return null;
1827
- if (!res.ok) {
1828
- const json = await this.parseJson(res);
1829
- 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;
1830
766
  }
1831
- return null;
767
+ steps.push({ tag: current.tagName, nthOfType });
768
+ current = parent;
1832
769
  }
1833
- /**
1834
- * Upload a file as multipart/form-data to the host backend, which
1835
- * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
1836
- * function body cap (our own 5 MB attachment cap will be enforced
1837
- * server-side before that's hit).
1838
- *
1839
- * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
1840
- * - keeps `@vizu/core` free of any `@vercel/blob` dependency
1841
- * (script-tag and local-only consumers don't pull blob code)
1842
- * - the multipart path is one round-trip; the direct-upload flow
1843
- * is two (signed URL + upload). For attachments capped at 5 MB,
1844
- * the savings don't justify the dep cost.
1845
- */
1846
- async uploadAttachment(_namespace, file) {
1847
- const form = new FormData();
1848
- form.append("file", file, file.name);
1849
- const url = new URL(this.apiUrl + "/api/uploads");
1850
- url.searchParams.set("workspace", this.workspace);
1851
- const res = await this.fetchAuthed(url.toString(), {
1852
- method: "POST",
1853
- body: form
1854
- // Don't set content-type; the browser fills in the multipart boundary.
1855
- });
1856
- const json = await this.parseJson(res);
1857
- if (!res.ok) throw apiErr(res.status, json);
1858
- if (!json?.url || typeof json.url !== "string") {
1859
- 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 {
1860
778
  }
1861
- return {
1862
- id: uuid(),
1863
- url: json.url,
1864
- mimeType: file.type || "application/octet-stream",
1865
- sizeBytes: file.size,
1866
- uploadedAt: Date.now(),
1867
- filename: file.name
1868
- };
779
+ return { element: null, confidence: "orphaned" };
1869
780
  }
1870
- getAuthContext() {
1871
- if (!this.cachedToken) return null;
1872
- return {
1873
- userId: this.cachedToken.userId,
1874
- token: this.cachedToken.token,
1875
- expiresAt: this.cachedToken.expiresAt
1876
- };
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
+ }
1877
787
  }
1878
- /* ─── Auth ────────────────────────────────────────────────────────── */
1879
- isExpired(t) {
1880
- if (!t) return true;
1881
- const exp = new Date(t.expiresAt).getTime();
1882
- 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" });
1883
793
  }
1884
- readStoredToken() {
1885
- if (typeof sessionStorage === "undefined") return null;
1886
- const raw = sessionStorage.getItem(this.sessionKey());
1887
- 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 {
1888
803
  try {
1889
- const parsed = JSON.parse(raw);
1890
- if (this.isExpired(parsed)) {
1891
- sessionStorage.removeItem(this.sessionKey());
1892
- 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
+ }
1893
810
  }
1894
- return parsed;
1895
811
  } catch {
1896
- return null;
1897
812
  }
1898
813
  }
1899
- writeStoredToken(t) {
1900
- this.cachedToken = t;
1901
- if (typeof sessionStorage !== "undefined") {
1902
- 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);
1903
827
  }
828
+ s.add(c.rung);
1904
829
  }
1905
- clearStoredToken() {
1906
- this.cachedToken = null;
1907
- if (typeof sessionStorage !== "undefined") {
1908
- sessionStorage.removeItem(this.sessionKey());
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;
1909
860
  }
1910
861
  }
1911
- sessionKey() {
1912
- return `vizu:cloud-token:${this.workspace}`;
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 };
1913
874
  }
1914
- /**
1915
- * Resolve a valid token, opening the popup if needed.
1916
- *
1917
- * Single-flight: simultaneous requests await one popup, not N.
1918
- * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
1919
- * if the user closes the popup; hosts can catch this and present
1920
- * a friendly message instead of a blank failure.
1921
- */
1922
- async resolveToken() {
1923
- if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
1924
- const fresh = this.readStoredToken();
1925
- if (fresh) {
1926
- this.cachedToken = fresh;
1927
- this.fireAuthChanged(fresh);
1928
- return fresh;
1929
- }
1930
- if (!this.autoSignIn) {
1931
- throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
1932
- }
1933
- if (!this.pendingAuth) {
1934
- this.pendingAuth = this.openSignInPopup().then((t) => {
1935
- this.writeStoredToken(t);
1936
- this.fireAuthChanged(t);
1937
- return t;
1938
- }).finally(() => {
1939
- this.pendingAuth = null;
1940
- });
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;
1941
890
  }
1942
- return this.pendingAuth;
891
+ steps.push({ tag: current.tagName, nthOfType: nth });
892
+ current = parent;
1943
893
  }
1944
- /**
1945
- * Notify the host that the authenticated identity changed. De-duped
1946
- * via the token string so multiple resolveToken() hits with the same
1947
- * cached token don't re-call setUser on every fetch.
1948
- */
1949
- fireAuthChanged(token) {
1950
- if (this.lastNotifiedTokenId === token.token) return;
1951
- this.lastNotifiedTokenId = token.token;
1952
- try {
1953
- this.onAuthChanged?.({ userId: token.userId, user: token.user });
1954
- } catch (err) {
1955
- if (typeof console !== "undefined") {
1956
- console.warn("[vizu/cloud] onAuthChanged callback threw", err);
1957
- }
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 };
1958
909
  }
1959
910
  }
1960
- openSignInPopup() {
1961
- return new Promise((resolve, reject) => {
1962
- if (typeof window === "undefined") {
1963
- return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
1964
- }
1965
- const apiOrigin = new URL(this.apiUrl).origin;
1966
- const hostOrigin = window.location.origin;
1967
- const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
1968
- const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
1969
- const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
1970
- const popup = window.open(
1971
- popupUrl,
1972
- "vizu-connect",
1973
- `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
1974
- );
1975
- if (!popup) {
1976
- return reject(
1977
- makeAuthError(
1978
- "popup_blocked",
1979
- "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
1980
- )
1981
- );
1982
- }
1983
- let resolved = false;
1984
- const onMessage = (e) => {
1985
- if (e.origin !== apiOrigin) return;
1986
- const data = e.data;
1987
- if (!data || data.type !== "vizu:auth") return;
1988
- if (data.workspace !== this.workspace) return;
1989
- if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
1990
- resolved = true;
1991
- cleanup();
1992
- const userPayload = data.user;
1993
- const user = userPayload && typeof userPayload.name === "string" ? {
1994
- id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
1995
- name: userPayload.name,
1996
- avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
1997
- } : null;
1998
- resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
1999
- };
2000
- const onPoll = window.setInterval(() => {
2001
- if (popup.closed) {
2002
- if (!resolved) {
2003
- cleanup();
2004
- reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
2005
- }
2006
- }
2007
- }, 500);
2008
- function cleanup() {
2009
- window.clearInterval(onPoll);
2010
- window.removeEventListener("message", onMessage);
2011
- }
2012
- window.addEventListener("message", onMessage);
2013
- });
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;
2014
928
  }
2015
- /* ─── Fetch wrapper ───────────────────────────────────────────────── */
2016
- /**
2017
- * Perform an authenticated fetch with one-shot retry on 401 (re-opens
2018
- * the popup if the token expired during the request). 4xx other than
2019
- * 401 / 404 throw; 5xx throws; 2xx returns.
2020
- */
2021
- async fetchAuthed(url, init2, retried = false) {
2022
- const token = await this.resolveToken();
2023
- const headers = new Headers(init2.headers);
2024
- headers.set("authorization", `Bearer ${token.token}`);
2025
- const res = await fetch(url, { ...init2, headers, credentials: "omit" });
2026
- if (res.status === 401 && !retried) {
2027
- this.clearStoredToken();
2028
- return this.fetchAuthed(url, init2, true);
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
943
+ );
2029
944
  }
2030
- return res;
945
+ for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
2031
946
  }
2032
- async parseJson(res) {
2033
- try {
2034
- const text = await res.text();
2035
- return text ? JSON.parse(text) : null;
2036
- } catch {
2037
- 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
+ }
2038
959
  }
960
+ if (score > best) best = score;
2039
961
  }
2040
- };
2041
- function makeAuthError(code, message) {
2042
- const err = new Error(message);
2043
- err.code = code;
2044
- return err;
962
+ return best;
2045
963
  }
2046
- function apiErr(status, body) {
2047
- const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
2048
- const err = new Error(body?.message ?? `HTTP ${status}`);
2049
- err.code = code;
2050
- err.status = status;
2051
- err.body = body;
2052
- 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;
2053
986
  }
2054
-
2055
- // src/index.ts
2056
- init_fingerprint();
2057
987
 
2058
988
  // src/highlighter.ts
2059
989
  var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
2060
990
  var Highlighter = class {
2061
- constructor(root, extraIgnore, onClick, onCurrentChange) {
991
+ constructor(root, extraIgnore, onClick) {
2062
992
  this.current = null;
2063
993
  this.active = false;
2064
994
  this.paused = false;
@@ -2093,7 +1023,6 @@ var Highlighter = class {
2093
1023
  this.root = root;
2094
1024
  this.extraIgnore = extraIgnore;
2095
1025
  this.onClick = onClick;
2096
- this.onCurrentChange = onCurrentChange ?? null;
2097
1026
  this.overlay = document.createElement("div");
2098
1027
  this.overlay.className = "vz-highlight";
2099
1028
  this.overlay.style.display = "none";
@@ -2102,7 +1031,6 @@ var Highlighter = class {
2102
1031
  setCurrent(el) {
2103
1032
  if (this.current === el) return;
2104
1033
  this.current = el;
2105
- this.onCurrentChange?.(el);
2106
1034
  }
2107
1035
  start() {
2108
1036
  if (this.active) return;
@@ -2163,7 +1091,6 @@ var Highlighter = class {
2163
1091
  };
2164
1092
 
2165
1093
  // src/popover.ts
2166
- init_fingerprint();
2167
1094
  function cssEscape(s) {
2168
1095
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
2169
1096
  return s.replace(/["\\]/g, "\\$&");
@@ -2841,7 +1768,6 @@ var Pill = class {
2841
1768
  };
2842
1769
 
2843
1770
  // src/sidebar.ts
2844
- init_fingerprint();
2845
1771
  var Sidebar = class {
2846
1772
  constructor(root, callbacks) {
2847
1773
  this.open = false;
@@ -4090,7 +3016,6 @@ var EventBus = class {
4090
3016
  };
4091
3017
 
4092
3018
  // src/index.ts
4093
- init_fingerprint();
4094
3019
  var DEFAULTS = {
4095
3020
  shortcut: "mod+shift+e",
4096
3021
  namespace: "default",
@@ -4150,8 +3075,6 @@ var Vizu = class {
4150
3075
  this.selfHealedThisSession = /* @__PURE__ */ new Set();
4151
3076
  this.actions = [];
4152
3077
  this.user = null;
4153
- /** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
4154
- this.liveCollab = null;
4155
3078
  this.enabled = false;
4156
3079
  this.rafScheduled = false;
4157
3080
  this.bus = new EventBus();
@@ -4223,18 +3146,6 @@ var Vizu = class {
4223
3146
  if (options.actions) this.actions = [...options.actions];
4224
3147
  if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
4225
3148
  if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
4226
- if (options.liveblocks && options.cloud) {
4227
- const live = options.liveblocks;
4228
- const cloud = options.cloud;
4229
- void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
4230
- this.liveCollab = new LiveCollab2({
4231
- publicKey: live.publicKey,
4232
- authEndpoint: live.authEndpoint,
4233
- workspaceSlug: cloud.workspace
4234
- });
4235
- void this.liveCollab.start();
4236
- });
4237
- }
4238
3149
  if (typeof window !== "undefined") {
4239
3150
  window.addEventListener("keydown", this.onKeyDown);
4240
3151
  void this.loadComments().then(() => this.subscribeStorage());
@@ -4292,8 +3203,6 @@ var Vizu = class {
4292
3203
  this.unsubscribeStorage?.();
4293
3204
  this.unsubscribeStorage = null;
4294
3205
  this.selfHealedThisSession.clear();
4295
- this.liveCollab?.destroy();
4296
- this.liveCollab = null;
4297
3206
  this.bus.clear();
4298
3207
  }
4299
3208
  /* ===== Action API ===== */
@@ -4615,11 +3524,7 @@ var Vizu = class {
4615
3524
  this.highlighter = new Highlighter(
4616
3525
  this.root,
4617
3526
  this.opts.ignoreSelectors ?? [],
4618
- (el, e) => this.onElementClick(el, e),
4619
- (el) => {
4620
- if (!this.liveCollab) return;
4621
- this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
4622
- }
3527
+ (el, e) => this.onElementClick(el, e)
4623
3528
  );
4624
3529
  this.highlighter.start();
4625
3530
  this.renderAllMarkers();