@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/index.cjs CHANGED
@@ -3,9 +3,6 @@ var __defProp = Object.defineProperty;
3
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __esm = (fn, res) => function __init() {
7
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
- };
9
6
  var __export = (target, all) => {
10
7
  for (var name in all)
11
8
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -20,1512 +17,161 @@ var __copyProps = (to, from, except, desc) => {
20
17
  };
21
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
22
19
 
23
- // src/fingerprint.ts
24
- function buildSelector(el) {
25
- const parts = [];
26
- let current = el;
27
- let depth = 0;
28
- while (current && current !== document.documentElement && depth < 8) {
29
- let part = current.tagName.toLowerCase();
30
- if (current.id) {
31
- parts.unshift("#" + CSS.escape(current.id));
32
- break;
33
- }
34
- const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
35
- if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
36
- const parent = current.parentElement;
37
- if (parent) {
38
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
39
- if (sameTag.length > 1) {
40
- const idx = sameTag.indexOf(current);
41
- part += ":nth-of-type(" + (idx + 1) + ")";
42
- }
43
- }
44
- parts.unshift(part);
45
- current = parent;
46
- depth++;
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ CloudStorageAdapter: () => CloudStorageAdapter,
24
+ InMemoryStorageAdapter: () => InMemoryStorageAdapter,
25
+ LocalStorageAdapter: () => LocalStorageAdapter,
26
+ NullStorageAdapter: () => NullStorageAdapter,
27
+ SCHEMA_VERSION: () => SCHEMA_VERSION,
28
+ Vizu: () => Vizu,
29
+ findByFingerprint: () => findByFingerprint,
30
+ findElementByFingerprint: () => findElementByFingerprint,
31
+ fingerprint: () => fingerprint,
32
+ fingerprintKey: () => fingerprintKey,
33
+ fingerprintLabel: () => fingerprintLabel
34
+ });
35
+ module.exports = __toCommonJS(src_exports);
36
+
37
+ // src/types.ts
38
+ var SCHEMA_VERSION = 2;
39
+
40
+ // src/util.ts
41
+ function uuid() {
42
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
43
+ return crypto.randomUUID();
47
44
  }
48
- return parts.join(" > ");
45
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
49
46
  }
50
- function fingerprint(el) {
51
- const text = el.innerText || el.textContent || "";
52
- const parent = el.parentElement;
53
- let siblingIndex = 0;
54
- if (parent) {
55
- siblingIndex = Array.from(parent.children).indexOf(el);
47
+ function isInside(target, selector) {
48
+ return !!target.closest(selector);
49
+ }
50
+ function escapeHtml(s) {
51
+ return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
52
+ }
53
+ function formatTime(ts) {
54
+ const d = new Date(ts);
55
+ const now = Date.now();
56
+ const diff = now - ts;
57
+ if (diff < 6e4) return "just now";
58
+ if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
59
+ if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
60
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
61
+ }
62
+ function initials(name) {
63
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
64
+ }
65
+ function avatarHtml(user, className = "vz-comment-author-avatar") {
66
+ if (!user) return `<span class="${className}">\xB7</span>`;
67
+ if (user.avatarUrl) {
68
+ return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
56
69
  }
57
- return {
58
- selector: buildSelector(el),
59
- parentSelector: parent ? buildSelector(parent) : "",
60
- tagName: el.tagName,
61
- textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
62
- siblingIndex,
63
- attributes: {
64
- id: el.id || void 0,
65
- classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
66
- role: el.getAttribute("role") || void 0,
67
- ariaLabel: el.getAttribute("aria-label") || void 0,
68
- dataKey: el.getAttribute("data-vizu-key") || void 0
69
- },
70
- ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
71
- algorithmVersion: 2
72
- };
70
+ return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
73
71
  }
74
- function captureAncestorChain(el, depth) {
75
- const steps = [];
76
- let current = el.parentElement;
77
- while (current && steps.length < depth) {
78
- const parent = current.parentElement;
79
- let nthOfType = 1;
80
- if (parent) {
81
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
82
- nthOfType = sameTag.indexOf(current) + 1;
72
+ function refId() {
73
+ return Math.random().toString(36).slice(2, 10);
74
+ }
75
+ var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
76
+ var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
77
+ function renderCommentText(text, commentId) {
78
+ const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
79
+ return parts.map((part) => {
80
+ const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
81
+ if (refMatch) {
82
+ return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
83
83
  }
84
- steps.push({ tag: current.tagName, nthOfType });
85
- current = parent;
84
+ const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
85
+ if (mentionMatch) {
86
+ return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
87
+ }
88
+ return escapeHtml(part);
89
+ }).join("");
90
+ }
91
+ function extractMentions(text) {
92
+ const out = [];
93
+ for (const m of text.matchAll(MENTION_TOKEN_RE)) {
94
+ if (!out.includes(m[2])) out.push(m[2]);
86
95
  }
87
- return steps.length > 0 ? { steps } : void 0;
96
+ return out;
88
97
  }
89
- function findByFingerprint(fp, root = document) {
90
- if (fp.attributes.dataKey) {
91
- try {
92
- const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
93
- if (byKey) return { element: byKey, confidence: "exact" };
94
- } catch {
98
+ function renderAttachmentsHtml(attachments) {
99
+ if (!Array.isArray(attachments) || attachments.length === 0) return "";
100
+ const items = attachments.map((a) => {
101
+ const isImage = a.mimeType?.startsWith("image/");
102
+ const filename = a.filename ?? a.url.split("/").pop() ?? "file";
103
+ if (isImage) {
104
+ return `
105
+ <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
106
+ <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
107
+ </a>
108
+ `;
95
109
  }
96
- return { element: null, confidence: "orphaned" };
97
- }
98
- if (fp.attributes.id) {
99
- try {
100
- const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
101
- if (byId) return { element: byId, confidence: "exact" };
102
- } catch {
110
+ return `
111
+ <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
112
+ <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
113
+ <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
114
+ </a>
115
+ `;
116
+ }).join("");
117
+ return `<div class="vz-attachment-grid">${items}</div>`;
118
+ }
119
+ function migrateComment(raw) {
120
+ if (!raw || typeof raw !== "object") return raw;
121
+ const next = { ...raw };
122
+ if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
123
+ if (next.fingerprint) {
124
+ next.fingerprints = [next.fingerprint];
103
125
  }
104
126
  }
105
- const candidates = [];
106
- const classes = fp.attributes.classList;
107
- if (classes && classes.length > 0) {
108
- const m = findByClassSignature(root, fp.tagName, classes);
109
- if (m?.element) candidates.push({ el: m.element, rung: "class" });
110
- }
111
- try {
112
- const bySelector = root.querySelector(fp.selector);
113
- if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
114
- } catch {
115
- }
116
- if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
117
- const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
118
- if (m?.element) candidates.push({ el: m.element, rung: "chain" });
119
- } else {
120
- try {
121
- const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
122
- if (parent) {
123
- const candidate = parent.children[fp.siblingIndex];
124
- if (candidate && candidate.tagName === fp.tagName) {
125
- candidates.push({ el: candidate, rung: "chain" });
126
- }
127
- }
128
- } catch {
129
- }
127
+ delete next.fingerprint;
128
+ if (next.url && !next.pageUrl) {
129
+ next.pageUrl = next.url;
130
130
  }
131
- if (fp.textSnippet) {
132
- const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
133
- if (m?.element) candidates.push({ el: m.element, rung: "text" });
131
+ if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
132
+ if (!Array.isArray(next.replies)) next.replies = [];
133
+ if (!Array.isArray(next.attachments)) next.attachments = [];
134
+ if (!Array.isArray(next.mentions)) next.mentions = [];
135
+ if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
136
+ next.schemaVersion = SCHEMA_VERSION;
137
+ return next;
138
+ }
139
+ function isValidStatus(s) {
140
+ return s === "open" || s === "resolved" || s === "wontfix";
141
+ }
142
+ function migrateComments(raws) {
143
+ if (!Array.isArray(raws)) return [];
144
+ const out = [];
145
+ for (const raw of raws) {
146
+ if (!raw || typeof raw !== "object") continue;
147
+ out.push(migrateComment(raw));
134
148
  }
135
- if (candidates.length === 0) {
136
- return { element: null, confidence: "orphaned" };
149
+ return out;
150
+ }
151
+ function needsPersist(raws) {
152
+ if (!Array.isArray(raws)) return false;
153
+ for (const raw of raws) {
154
+ if (!raw || typeof raw !== "object") continue;
155
+ const c = raw;
156
+ if (c.schemaVersion !== SCHEMA_VERSION) return true;
157
+ if ("fingerprint" in c) return true;
158
+ if (!Array.isArray(c.replies)) return true;
159
+ if (!Array.isArray(c.attachments)) return true;
160
+ if (!Array.isArray(c.mentions)) return true;
137
161
  }
138
- const votes = /* @__PURE__ */ new Map();
139
- for (const c of candidates) {
140
- let s = votes.get(c.el);
141
- if (!s) {
142
- s = /* @__PURE__ */ new Set();
143
- votes.set(c.el, s);
144
- }
145
- s.add(c.rung);
162
+ return false;
163
+ }
164
+
165
+ // src/storage.ts
166
+ var key = (ns) => `vizu:comments:${ns}`;
167
+ var LocalStorageAdapter = class {
168
+ async load(namespace) {
169
+ return readArray(namespace);
146
170
  }
147
- let best = null;
148
- for (const [el, rungs] of votes) {
149
- if (!best || rungs.size > best.rungs.size) best = { el, rungs };
150
- }
151
- if (!best) return { element: null, confidence: "orphaned" };
152
- if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
153
- if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
154
- if (best.rungs.has("class") || best.rungs.has("chain")) {
155
- return { element: best.el, confidence: "drifted" };
156
- }
157
- return { element: null, confidence: "orphaned" };
158
- }
159
- function findByClassSignature(root, tagName, needle) {
160
- const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
161
- if (needleSet.size === 0) return null;
162
- const all = root.querySelectorAll(tagName.toLowerCase());
163
- let best = null;
164
- let secondScore = 0;
165
- for (const el of all) {
166
- const haystack = new Set(
167
- Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
168
- );
169
- if (haystack.size === 0) continue;
170
- const score = jaccardSimilarity(needleSet, haystack);
171
- if (score < CLASS_JACCARD_THRESHOLD) continue;
172
- if (!best || score > best.score) {
173
- secondScore = best?.score ?? 0;
174
- best = { el, score };
175
- } else if (score > secondScore) {
176
- secondScore = score;
177
- }
178
- }
179
- if (!best) return null;
180
- if (best.score - secondScore < 0.1) return null;
181
- return { element: best.el, confidence: "likely" };
182
- }
183
- function findByAncestorChain(root, tagName, steps) {
184
- const all = root.querySelectorAll(tagName.toLowerCase());
185
- let best = null;
186
- for (const el of all) {
187
- const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
188
- const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
189
- if (score < 2) continue;
190
- if (!best || score > best.score) best = { el, score };
191
- }
192
- if (!best) return null;
193
- return {
194
- element: best.el,
195
- confidence: best.score === steps.length ? "likely" : "drifted"
196
- };
197
- }
198
- function collectAncestorSteps(el, limit) {
199
- const steps = [];
200
- let current = el.parentElement;
201
- while (current && steps.length < limit) {
202
- const parent = current.parentElement;
203
- let nth = 1;
204
- if (parent) {
205
- const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
206
- nth = sameTag.indexOf(current) + 1;
207
- }
208
- steps.push({ tag: current.tagName, nthOfType: nth });
209
- current = parent;
210
- }
211
- return steps;
212
- }
213
- function findByTextSnippet(root, tagName, snippet) {
214
- const needle = normalizeText(snippet);
215
- if (!needle) return null;
216
- const all = root.querySelectorAll(tagName.toLowerCase());
217
- let best = null;
218
- for (const el of all) {
219
- const raw = el.innerText || el.textContent || "";
220
- const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
221
- if (!candidate) continue;
222
- const score = levenshteinRatio(needle, candidate);
223
- if (score < TEXT_RATIO_THRESHOLD) continue;
224
- if (!best || score > best.score) {
225
- best = { el, score };
226
- }
227
- }
228
- return best ? { element: best.el, confidence: "drifted" } : null;
229
- }
230
- function normalizeText(input) {
231
- return input.toLowerCase().replace(/\s+/g, " ").trim();
232
- }
233
- function levenshteinRatio(a, b) {
234
- if (a === b) return 1;
235
- if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
236
- const maxLen = Math.max(a.length, b.length);
237
- const distance = levenshteinDistance(a, b);
238
- return 1 - distance / maxLen;
239
- }
240
- function levenshteinDistance(a, b) {
241
- if (a.length > b.length) {
242
- const tmp = a;
243
- a = b;
244
- b = tmp;
245
- }
246
- const prev = new Array(a.length + 1);
247
- const curr = new Array(a.length + 1);
248
- for (let i = 0; i <= a.length; i++) prev[i] = i;
249
- for (let j = 1; j <= b.length; j++) {
250
- curr[0] = j;
251
- for (let i = 1; i <= a.length; i++) {
252
- const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
253
- curr[i] = Math.min(
254
- curr[i - 1] + 1,
255
- // insert
256
- prev[i] + 1,
257
- // delete
258
- prev[i - 1] + cost
259
- // substitute
260
- );
261
- }
262
- for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
263
- }
264
- return prev[a.length];
265
- }
266
- function scoreChainAgainstAncestors(actual, captured, maxShift) {
267
- let best = 0;
268
- for (let offset = 0; offset <= maxShift; offset++) {
269
- let score = 0;
270
- for (let i = 0; i < captured.length; i++) {
271
- const a = actual[i + offset];
272
- if (!a) break;
273
- if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
274
- score++;
275
- }
276
- }
277
- if (score > best) best = score;
278
- }
279
- return best;
280
- }
281
- function jaccardSimilarity(a, b) {
282
- if (a.size === 0 && b.size === 0) return 1;
283
- let intersection = 0;
284
- for (const item of a) if (b.has(item)) intersection++;
285
- const union = a.size + b.size - intersection;
286
- return union === 0 ? 0 : intersection / union;
287
- }
288
- function findElementByFingerprint(fp, root) {
289
- return findByFingerprint(fp, root).element;
290
- }
291
- function fingerprintKey(fp) {
292
- return fp.selector + "|" + fp.textSnippet;
293
- }
294
- function fingerprintLabel(fp) {
295
- const tag = fp.tagName.toLowerCase();
296
- if (fp.textSnippet) {
297
- const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
298
- return tag + ": " + snippet;
299
- }
300
- if (fp.attributes.id) return tag + "#" + fp.attributes.id;
301
- if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
302
- return tag;
303
- }
304
- var TEXT_SNIPPET_MAX, ANCESTOR_DEPTH, ANCESTOR_MAX_SHIFT, TEXT_RATIO_THRESHOLD, CLASS_JACCARD_THRESHOLD;
305
- var init_fingerprint = __esm({
306
- "src/fingerprint.ts"() {
307
- "use strict";
308
- TEXT_SNIPPET_MAX = 80;
309
- ANCESTOR_DEPTH = 4;
310
- ANCESTOR_MAX_SHIFT = 3;
311
- TEXT_RATIO_THRESHOLD = 0.75;
312
- CLASS_JACCARD_THRESHOLD = 0.6;
313
- }
314
- });
315
-
316
- // src/cursor-colors.ts
317
- function colorForUser(userId) {
318
- let h = 0;
319
- for (let i = 0; i < userId.length; i++) {
320
- h = h * 31 + userId.charCodeAt(i) | 0;
321
- }
322
- return CURSOR_COLORS[Math.abs(h) % CURSOR_COLORS.length];
323
- }
324
- function prefersReducedMotion() {
325
- if (typeof window === "undefined" || !window.matchMedia) return false;
326
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
327
- }
328
- var CURSOR_COLORS;
329
- var init_cursor_colors = __esm({
330
- "src/cursor-colors.ts"() {
331
- "use strict";
332
- CURSOR_COLORS = [
333
- "#4F46E5",
334
- // indigo
335
- "#15803D",
336
- // forest
337
- "#C2410C",
338
- // orange
339
- "#BE185D",
340
- // rose
341
- "#0E7490",
342
- // cyan
343
- "#7E22CE",
344
- // plum
345
- "#DC2626",
346
- // red
347
- "#0F766E"
348
- // teal
349
- ];
350
- }
351
- });
352
-
353
- // src/presence-stack.ts
354
- function createAvatar(user, onClick) {
355
- const wrap = document.createElement("div");
356
- wrap.setAttribute("data-vizu-presence-avatar", user.id);
357
- wrap.setAttribute("data-vizu-presence-name", user.name);
358
- wrap.setAttribute("data-vizu-presence-color", user.color);
359
- if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
360
- const reduced = prefersReducedMotion();
361
- const hoverTransition = reduced ? "none" : "transform 130ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 130ms ease-out";
362
- wrap.style.cssText = `
363
- position: relative;
364
- width: ${AVATAR_SIZE}px;
365
- height: ${AVATAR_SIZE}px;
366
- margin-left: -${OVERLAP_PX}px;
367
- border-radius: 50%;
368
- background: ${user.color};
369
- padding: 1.5px;
370
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
371
- cursor: pointer;
372
- flex-shrink: 0;
373
- transition: ${hoverTransition};
374
- `;
375
- wrap.addEventListener("click", (e) => {
376
- e.preventDefault();
377
- e.stopPropagation();
378
- onClick();
379
- });
380
- if (!reduced) {
381
- wrap.addEventListener(
382
- "mouseenter",
383
- () => wrap.style.transform = "translateY(-1px) scale(1.06)"
384
- );
385
- wrap.addEventListener("mouseleave", () => wrap.style.transform = "");
386
- }
387
- const inner = document.createElement("div");
388
- inner.setAttribute("data-vizu-presence-inner", "");
389
- inner.style.cssText = `
390
- width: 100%;
391
- height: 100%;
392
- border-radius: 50%;
393
- overflow: hidden;
394
- background: ${user.color};
395
- color: #ffffff;
396
- display: flex;
397
- align-items: center;
398
- justify-content: center;
399
- font-size: 10.5px;
400
- font-weight: 600;
401
- letter-spacing: -0.01em;
402
- line-height: 1;
403
- user-select: none;
404
- `;
405
- paintAvatarContent(inner, user);
406
- wrap.appendChild(inner);
407
- const tip = createTooltip(user.name);
408
- wrap.appendChild(tip);
409
- wrap.addEventListener("mouseenter", () => {
410
- tip.style.opacity = "1";
411
- tip.style.transform = "translateY(0)";
412
- });
413
- wrap.addEventListener("mouseleave", () => {
414
- tip.style.opacity = "0";
415
- tip.style.transform = "translateY(-2px)";
416
- });
417
- return wrap;
418
- }
419
- function updateAvatar(wrap, user) {
420
- const inner = wrap.querySelector("[data-vizu-presence-inner]");
421
- if (inner) paintAvatarContent(inner, user);
422
- const tip = wrap.querySelector("[data-vizu-presence-tip]");
423
- if (tip) tip.textContent = user.name;
424
- wrap.setAttribute("data-vizu-presence-name", user.name);
425
- if (user.avatar) wrap.setAttribute("data-vizu-presence-img", user.avatar);
426
- else wrap.removeAttribute("data-vizu-presence-img");
427
- }
428
- function avatarMeta(wrap) {
429
- const id = wrap.getAttribute("data-vizu-presence-avatar");
430
- const name = wrap.getAttribute("data-vizu-presence-name");
431
- const color = wrap.getAttribute("data-vizu-presence-color");
432
- if (!id || !name || !color) return null;
433
- const avatar = wrap.getAttribute("data-vizu-presence-img") ?? void 0;
434
- return { id, name, color, avatar };
435
- }
436
- function paintAvatarFollowState(wrap, user, isFollowing) {
437
- if (isFollowing) {
438
- wrap.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.95), 0 0 0 4px var(--vizu-coral, #FF6647), 0 4px 12px rgba(255, 102, 71, 0.4)`;
439
- } else {
440
- wrap.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15)`;
441
- }
442
- void user;
443
- }
444
- function paintAvatarContent(inner, user) {
445
- inner.textContent = "";
446
- if (user.avatar) {
447
- const img = document.createElement("img");
448
- img.src = user.avatar;
449
- img.alt = user.name;
450
- img.referrerPolicy = "no-referrer";
451
- img.style.cssText = "width: 100%; height: 100%; object-fit: cover; display: block;";
452
- img.addEventListener(
453
- "error",
454
- () => {
455
- img.remove();
456
- inner.textContent = initialsOf(user.name);
457
- },
458
- { once: true }
459
- );
460
- inner.appendChild(img);
461
- } else {
462
- inner.textContent = initialsOf(user.name);
463
- }
464
- }
465
- function createTooltip(text) {
466
- const tip = document.createElement("div");
467
- tip.setAttribute("data-vizu-presence-tip", "");
468
- tip.textContent = text;
469
- tip.style.cssText = `
470
- position: absolute;
471
- top: calc(100% + 6px);
472
- right: 0;
473
- background: rgba(10, 10, 10, 0.92);
474
- color: #ffffff;
475
- font-size: 11px;
476
- font-weight: 500;
477
- padding: 4px 8px;
478
- border-radius: 4px;
479
- white-space: nowrap;
480
- pointer-events: none;
481
- opacity: 0;
482
- transform: translateY(-2px);
483
- transition: opacity 130ms ease-out, transform 130ms ease-out;
484
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
485
- max-width: 220px;
486
- overflow: hidden;
487
- text-overflow: ellipsis;
488
- `;
489
- return tip;
490
- }
491
- function createOverflowChip() {
492
- const chip = document.createElement("div");
493
- chip.setAttribute("data-vizu-presence-overflow", "");
494
- chip.style.cssText = `
495
- width: ${AVATAR_SIZE}px;
496
- height: ${AVATAR_SIZE}px;
497
- margin-left: -${OVERLAP_PX}px;
498
- border-radius: 50%;
499
- background: rgba(82, 82, 82, 0.95);
500
- color: #ffffff;
501
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 2px 6px rgba(0, 0, 0, 0.15);
502
- display: flex;
503
- align-items: center;
504
- justify-content: center;
505
- font-size: 10.5px;
506
- font-weight: 600;
507
- letter-spacing: -0.02em;
508
- line-height: 1;
509
- user-select: none;
510
- flex-shrink: 0;
511
- `;
512
- return chip;
513
- }
514
- function initialsOf(name) {
515
- const trimmed = name.trim();
516
- if (!trimmed) return "?";
517
- const parts = trimmed.split(/\s+/);
518
- if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
519
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
520
- }
521
- return trimmed.slice(0, 2).toUpperCase();
522
- }
523
- var Z_INDEX, MAX_VISIBLE, AVATAR_SIZE, OVERLAP_PX, PresenceStack;
524
- var init_presence_stack = __esm({
525
- "src/presence-stack.ts"() {
526
- "use strict";
527
- init_cursor_colors();
528
- Z_INDEX = 2147483640;
529
- MAX_VISIBLE = 5;
530
- AVATAR_SIZE = 28;
531
- OVERLAP_PX = 6;
532
- PresenceStack = class {
533
- constructor(opts) {
534
- this.container = null;
535
- this.avatars = /* @__PURE__ */ new Map();
536
- this.overflowEl = null;
537
- this.followingUserId = null;
538
- this.onAvatarClick = null;
539
- this.onAvatarClick = opts?.onAvatarClick ?? null;
540
- }
541
- mount() {
542
- if (typeof document === "undefined") return;
543
- if (this.container) return;
544
- this.container = document.createElement("div");
545
- this.container.setAttribute("data-vizu-presence-stack", "");
546
- this.container.style.cssText = `
547
- position: fixed;
548
- top: 16px;
549
- right: 16px;
550
- z-index: ${Z_INDEX};
551
- display: flex;
552
- align-items: center;
553
- pointer-events: auto;
554
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
555
- `;
556
- document.body.appendChild(this.container);
557
- }
558
- destroy() {
559
- for (const el of this.avatars.values()) el.remove();
560
- this.avatars.clear();
561
- this.overflowEl?.remove();
562
- this.overflowEl = null;
563
- this.container?.remove();
564
- this.container = null;
565
- }
566
- setOthers(others) {
567
- if (!this.container) return;
568
- const visible = others.slice(0, MAX_VISIBLE);
569
- const overflow = Math.max(0, others.length - visible.length);
570
- const nextIds = new Set(visible.map((u) => u.id));
571
- for (const [id, el] of Array.from(this.avatars)) {
572
- if (!nextIds.has(id)) {
573
- el.remove();
574
- this.avatars.delete(id);
575
- }
576
- }
577
- for (const user of visible) {
578
- let el = this.avatars.get(user.id);
579
- if (!el) {
580
- el = createAvatar(user, () => this.onAvatarClick?.(user.id));
581
- this.avatars.set(user.id, el);
582
- } else {
583
- updateAvatar(el, user);
584
- }
585
- paintAvatarFollowState(el, user, this.followingUserId === user.id);
586
- this.container.appendChild(el);
587
- }
588
- this.renderOverflow(overflow);
589
- }
590
- /**
591
- * Mark a user as the active followee so its avatar can render the
592
- * coral ring highlight. Pass null to clear.
593
- */
594
- setFollowing(userId) {
595
- this.followingUserId = userId;
596
- for (const [id, el] of this.avatars) {
597
- const user = avatarMeta(el);
598
- if (user) paintAvatarFollowState(el, user, id === userId);
599
- }
600
- }
601
- /* ────────────────── private ────────────────── */
602
- renderOverflow(count) {
603
- if (!this.container) return;
604
- if (count <= 0) {
605
- this.overflowEl?.remove();
606
- this.overflowEl = null;
607
- return;
608
- }
609
- if (!this.overflowEl) {
610
- this.overflowEl = createOverflowChip();
611
- }
612
- this.overflowEl.textContent = `+${count}`;
613
- this.container.appendChild(this.overflowEl);
614
- }
615
- };
616
- }
617
- });
618
-
619
- // src/selection-overlay.ts
620
- function createOverlay(color, name) {
621
- const el = document.createElement("div");
622
- el.setAttribute("data-vizu-selection-overlay", "");
623
- const positionTransition = prefersReducedMotion() ? "none" : "left 90ms ease-out, top 90ms ease-out, width 90ms ease-out, height 90ms ease-out";
624
- el.style.cssText = `
625
- position: fixed;
626
- pointer-events: none;
627
- display: none;
628
- border: 2px dashed ${color};
629
- border-radius: 4px;
630
- background: ${color}10;
631
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
632
- transition: ${positionTransition};
633
- will-change: left, top, width, height;
634
- `;
635
- const label = document.createElement("div");
636
- label.setAttribute("data-selection-label", "");
637
- label.textContent = name;
638
- label.style.cssText = `
639
- position: absolute;
640
- top: -22px;
641
- left: -2px;
642
- padding: 2px 7px;
643
- background: ${color};
644
- color: #ffffff;
645
- font: 500 10.5px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
646
- border-radius: 3px 3px 3px 0;
647
- white-space: nowrap;
648
- max-width: 180px;
649
- overflow: hidden;
650
- text-overflow: ellipsis;
651
- box-shadow: 0 2px 4px rgba(0,0,0,0.15);
652
- `;
653
- el.appendChild(label);
654
- return el;
655
- }
656
- var Z_INDEX2, SelectionOverlay;
657
- var init_selection_overlay = __esm({
658
- "src/selection-overlay.ts"() {
659
- "use strict";
660
- init_fingerprint();
661
- init_cursor_colors();
662
- Z_INDEX2 = 2147483639;
663
- SelectionOverlay = class {
664
- constructor() {
665
- this.container = null;
666
- this.overlays = /* @__PURE__ */ new Map();
667
- this.scrollListener = null;
668
- this.resizeListener = null;
669
- this.rafScheduled = false;
670
- }
671
- mount() {
672
- if (typeof document === "undefined") return;
673
- if (this.container) return;
674
- this.container = document.createElement("div");
675
- this.container.setAttribute("data-vizu-selections", "");
676
- this.container.style.cssText = `
677
- position: fixed; inset: 0;
678
- pointer-events: none;
679
- z-index: ${Z_INDEX2};
680
- contain: strict;
681
- `;
682
- document.body.appendChild(this.container);
683
- this.scrollListener = () => this.scheduleReposition();
684
- this.resizeListener = () => this.scheduleReposition();
685
- window.addEventListener("scroll", this.scrollListener, { passive: true, capture: true });
686
- window.addEventListener("resize", this.resizeListener);
687
- }
688
- destroy() {
689
- if (this.scrollListener) {
690
- window.removeEventListener("scroll", this.scrollListener, true);
691
- this.scrollListener = null;
692
- }
693
- if (this.resizeListener) {
694
- window.removeEventListener("resize", this.resizeListener);
695
- this.resizeListener = null;
696
- }
697
- for (const e of this.overlays.values()) e.el.remove();
698
- this.overlays.clear();
699
- this.container?.remove();
700
- this.container = null;
701
- }
702
- setSelections(selections) {
703
- if (!this.container) return;
704
- const nextIds = new Set(selections.map((s) => s.id));
705
- for (const [id, entry] of Array.from(this.overlays)) {
706
- if (!nextIds.has(id)) {
707
- entry.el.remove();
708
- this.overlays.delete(id);
709
- }
710
- }
711
- for (const sel of selections) {
712
- let entry = this.overlays.get(sel.id);
713
- if (!entry) {
714
- const el = createOverlay(sel.color, sel.name);
715
- this.container.appendChild(el);
716
- entry = { el, fingerprint: sel.fingerprint, color: sel.color, name: sel.name };
717
- this.overlays.set(sel.id, entry);
718
- } else {
719
- entry.fingerprint = sel.fingerprint;
720
- if (entry.color !== sel.color) {
721
- entry.color = sel.color;
722
- entry.el.style.borderColor = sel.color;
723
- }
724
- if (entry.name !== sel.name) {
725
- entry.name = sel.name;
726
- const label = entry.el.querySelector("[data-selection-label]");
727
- if (label) {
728
- label.textContent = sel.name;
729
- label.style.background = sel.color;
730
- }
731
- }
732
- }
733
- this.positionOverlay(entry);
734
- }
735
- }
736
- /* ────────────────── private ────────────────── */
737
- positionOverlay(entry) {
738
- const match = findByFingerprint(entry.fingerprint);
739
- const target = match?.element ?? null;
740
- if (!target) {
741
- entry.el.style.display = "none";
742
- return;
743
- }
744
- const rect = target.getBoundingClientRect();
745
- if (rect.width === 0 || rect.height === 0) {
746
- entry.el.style.display = "none";
747
- return;
748
- }
749
- entry.el.style.display = "block";
750
- entry.el.style.left = `${rect.left}px`;
751
- entry.el.style.top = `${rect.top}px`;
752
- entry.el.style.width = `${rect.width}px`;
753
- entry.el.style.height = `${rect.height}px`;
754
- }
755
- scheduleReposition() {
756
- if (this.rafScheduled) return;
757
- this.rafScheduled = true;
758
- requestAnimationFrame(() => {
759
- this.rafScheduled = false;
760
- for (const entry of this.overlays.values()) this.positionOverlay(entry);
761
- });
762
- }
763
- };
764
- }
765
- });
766
-
767
- // src/live-collab.ts
768
- var live_collab_exports = {};
769
- __export(live_collab_exports, {
770
- LiveCollab: () => LiveCollab,
771
- roomIdFor: () => roomIdFor
772
- });
773
- function 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/index.ts
1375
- var src_exports = {};
1376
- __export(src_exports, {
1377
- CloudStorageAdapter: () => CloudStorageAdapter,
1378
- InMemoryStorageAdapter: () => InMemoryStorageAdapter,
1379
- LocalStorageAdapter: () => LocalStorageAdapter,
1380
- NullStorageAdapter: () => NullStorageAdapter,
1381
- SCHEMA_VERSION: () => SCHEMA_VERSION,
1382
- Vizu: () => Vizu,
1383
- findByFingerprint: () => findByFingerprint,
1384
- findElementByFingerprint: () => findElementByFingerprint,
1385
- fingerprint: () => fingerprint,
1386
- fingerprintKey: () => fingerprintKey,
1387
- fingerprintLabel: () => fingerprintLabel
1388
- });
1389
- module.exports = __toCommonJS(src_exports);
1390
-
1391
- // src/types.ts
1392
- var SCHEMA_VERSION = 2;
1393
-
1394
- // src/util.ts
1395
- function uuid() {
1396
- if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
1397
- return crypto.randomUUID();
1398
- }
1399
- return Math.random().toString(36).slice(2) + Date.now().toString(36);
1400
- }
1401
- function isInside(target, selector) {
1402
- return !!target.closest(selector);
1403
- }
1404
- function escapeHtml(s) {
1405
- return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
1406
- }
1407
- function formatTime(ts) {
1408
- const d = new Date(ts);
1409
- const now = Date.now();
1410
- const diff = now - ts;
1411
- if (diff < 6e4) return "just now";
1412
- if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
1413
- if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
1414
- return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1415
- }
1416
- function initials(name) {
1417
- return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
1418
- }
1419
- function avatarHtml(user, className = "vz-comment-author-avatar") {
1420
- if (!user) return `<span class="${className}">\xB7</span>`;
1421
- if (user.avatarUrl) {
1422
- return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
1423
- }
1424
- return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
1425
- }
1426
- function refId() {
1427
- return Math.random().toString(36).slice(2, 10);
1428
- }
1429
- var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
1430
- var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
1431
- function renderCommentText(text, commentId) {
1432
- const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
1433
- return parts.map((part) => {
1434
- const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
1435
- if (refMatch) {
1436
- 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>`;
1437
- }
1438
- const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
1439
- if (mentionMatch) {
1440
- return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
1441
- }
1442
- return escapeHtml(part);
1443
- }).join("");
1444
- }
1445
- function extractMentions(text) {
1446
- const out = [];
1447
- for (const m of text.matchAll(MENTION_TOKEN_RE)) {
1448
- if (!out.includes(m[2])) out.push(m[2]);
1449
- }
1450
- return out;
1451
- }
1452
- function renderAttachmentsHtml(attachments) {
1453
- if (!Array.isArray(attachments) || attachments.length === 0) return "";
1454
- const items = attachments.map((a) => {
1455
- const isImage = a.mimeType?.startsWith("image/");
1456
- const filename = a.filename ?? a.url.split("/").pop() ?? "file";
1457
- if (isImage) {
1458
- return `
1459
- <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
1460
- <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
1461
- </a>
1462
- `;
1463
- }
1464
- return `
1465
- <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
1466
- <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
1467
- <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
1468
- </a>
1469
- `;
1470
- }).join("");
1471
- return `<div class="vz-attachment-grid">${items}</div>`;
1472
- }
1473
- function migrateComment(raw) {
1474
- if (!raw || typeof raw !== "object") return raw;
1475
- const next = { ...raw };
1476
- if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
1477
- if (next.fingerprint) {
1478
- next.fingerprints = [next.fingerprint];
1479
- }
1480
- }
1481
- delete next.fingerprint;
1482
- if (next.url && !next.pageUrl) {
1483
- next.pageUrl = next.url;
1484
- }
1485
- if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
1486
- if (!Array.isArray(next.replies)) next.replies = [];
1487
- if (!Array.isArray(next.attachments)) next.attachments = [];
1488
- if (!Array.isArray(next.mentions)) next.mentions = [];
1489
- if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
1490
- next.schemaVersion = SCHEMA_VERSION;
1491
- return next;
1492
- }
1493
- function isValidStatus(s) {
1494
- return s === "open" || s === "resolved" || s === "wontfix";
1495
- }
1496
- function migrateComments(raws) {
1497
- if (!Array.isArray(raws)) return [];
1498
- const out = [];
1499
- for (const raw of raws) {
1500
- if (!raw || typeof raw !== "object") continue;
1501
- out.push(migrateComment(raw));
1502
- }
1503
- return out;
1504
- }
1505
- function needsPersist(raws) {
1506
- if (!Array.isArray(raws)) return false;
1507
- for (const raw of raws) {
1508
- if (!raw || typeof raw !== "object") continue;
1509
- const c = raw;
1510
- if (c.schemaVersion !== SCHEMA_VERSION) return true;
1511
- if ("fingerprint" in c) return true;
1512
- if (!Array.isArray(c.replies)) return true;
1513
- if (!Array.isArray(c.attachments)) return true;
1514
- if (!Array.isArray(c.mentions)) return true;
1515
- }
1516
- return false;
1517
- }
1518
-
1519
- // src/storage.ts
1520
- var key = (ns) => `vizu:comments:${ns}`;
1521
- var LocalStorageAdapter = class {
1522
- async load(namespace) {
1523
- return readArray(namespace);
1524
- }
1525
- async addComment(namespace, comment) {
1526
- const list = readArray(namespace);
1527
- list.push(comment);
1528
- writeArray(namespace, list);
171
+ async addComment(namespace, comment) {
172
+ const list = readArray(namespace);
173
+ list.push(comment);
174
+ writeArray(namespace, list);
1529
175
  }
1530
176
  async updateComment(namespace, id, patch) {
1531
177
  const list = readArray(namespace);
@@ -1797,278 +443,562 @@ var CloudStorageAdapter = class {
1797
443
  throw apiErr(res.status, json);
1798
444
  }
1799
445
  }
1800
- async setAll(namespace, comments) {
1801
- await this.clear(namespace);
1802
- for (const c of comments) {
1803
- await this.addComment(namespace, c);
446
+ async setAll(namespace, comments) {
447
+ await this.clear(namespace);
448
+ for (const c of comments) {
449
+ await this.addComment(namespace, c);
450
+ }
451
+ }
452
+ async clear(_namespace) {
453
+ const url = new URL(this.apiUrl + "/api/comments");
454
+ url.searchParams.set("workspace", this.workspace);
455
+ url.searchParams.set("all", "true");
456
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
457
+ if (res.status === 404) return;
458
+ if (!res.ok) {
459
+ const json = await this.parseJson(res);
460
+ throw apiErr(res.status, json);
461
+ }
462
+ }
463
+ async addReply(_namespace, commentId, reply) {
464
+ const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
465
+ url.searchParams.set("workspace", this.workspace);
466
+ const res = await this.fetchAuthed(url.toString(), {
467
+ method: "POST",
468
+ headers: { "content-type": "application/json" },
469
+ body: JSON.stringify(reply)
470
+ });
471
+ if (res.status === 404) return null;
472
+ const json = await this.parseJson(res);
473
+ if (!res.ok) throw apiErr(res.status, json);
474
+ return json?.comment ? migrateComment(json.comment) : null;
475
+ }
476
+ async removeReply(_namespace, commentId, replyId) {
477
+ const url = new URL(
478
+ this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
479
+ );
480
+ url.searchParams.set("workspace", this.workspace);
481
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
482
+ if (res.status === 404) return null;
483
+ if (!res.ok) {
484
+ const json = await this.parseJson(res);
485
+ throw apiErr(res.status, json);
486
+ }
487
+ return null;
488
+ }
489
+ /**
490
+ * Upload a file as multipart/form-data to the host backend, which
491
+ * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
492
+ * function body cap (our own 5 MB attachment cap will be enforced
493
+ * server-side before that's hit).
494
+ *
495
+ * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
496
+ * - keeps `@vizu/core` free of any `@vercel/blob` dependency
497
+ * (script-tag and local-only consumers don't pull blob code)
498
+ * - the multipart path is one round-trip; the direct-upload flow
499
+ * is two (signed URL + upload). For attachments capped at 5 MB,
500
+ * the savings don't justify the dep cost.
501
+ */
502
+ async uploadAttachment(_namespace, file) {
503
+ const form = new FormData();
504
+ form.append("file", file, file.name);
505
+ const url = new URL(this.apiUrl + "/api/uploads");
506
+ url.searchParams.set("workspace", this.workspace);
507
+ const res = await this.fetchAuthed(url.toString(), {
508
+ method: "POST",
509
+ body: form
510
+ // Don't set content-type; the browser fills in the multipart boundary.
511
+ });
512
+ const json = await this.parseJson(res);
513
+ if (!res.ok) throw apiErr(res.status, json);
514
+ if (!json?.url || typeof json.url !== "string") {
515
+ throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
516
+ }
517
+ return {
518
+ id: uuid(),
519
+ url: json.url,
520
+ mimeType: file.type || "application/octet-stream",
521
+ sizeBytes: file.size,
522
+ uploadedAt: Date.now(),
523
+ filename: file.name
524
+ };
525
+ }
526
+ getAuthContext() {
527
+ if (!this.cachedToken) return null;
528
+ return {
529
+ userId: this.cachedToken.userId,
530
+ token: this.cachedToken.token,
531
+ expiresAt: this.cachedToken.expiresAt
532
+ };
533
+ }
534
+ /* ─── Auth ────────────────────────────────────────────────────────── */
535
+ isExpired(t) {
536
+ if (!t) return true;
537
+ const exp = new Date(t.expiresAt).getTime();
538
+ return !Number.isFinite(exp) || exp - Date.now() < 3e4;
539
+ }
540
+ readStoredToken() {
541
+ if (typeof sessionStorage === "undefined") return null;
542
+ const raw = sessionStorage.getItem(this.sessionKey());
543
+ if (!raw) return null;
544
+ try {
545
+ const parsed = JSON.parse(raw);
546
+ if (this.isExpired(parsed)) {
547
+ sessionStorage.removeItem(this.sessionKey());
548
+ return null;
549
+ }
550
+ return parsed;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+ writeStoredToken(t) {
556
+ this.cachedToken = t;
557
+ if (typeof sessionStorage !== "undefined") {
558
+ sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
559
+ }
560
+ }
561
+ clearStoredToken() {
562
+ this.cachedToken = null;
563
+ if (typeof sessionStorage !== "undefined") {
564
+ sessionStorage.removeItem(this.sessionKey());
565
+ }
566
+ }
567
+ sessionKey() {
568
+ return `vizu:cloud-token:v2:${this.workspace}`;
569
+ }
570
+ /**
571
+ * Resolve a valid token, opening the popup if needed.
572
+ *
573
+ * Single-flight: simultaneous requests await one popup, not N.
574
+ * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
575
+ * if the user closes the popup; hosts can catch this and present
576
+ * a friendly message instead of a blank failure.
577
+ */
578
+ async resolveToken() {
579
+ if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
580
+ const fresh = this.readStoredToken();
581
+ if (fresh) {
582
+ this.cachedToken = fresh;
583
+ this.fireAuthChanged(fresh);
584
+ return fresh;
585
+ }
586
+ if (!this.autoSignIn) {
587
+ throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
588
+ }
589
+ if (!this.pendingAuth) {
590
+ this.pendingAuth = this.openSignInPopup().then((t) => {
591
+ this.writeStoredToken(t);
592
+ this.fireAuthChanged(t);
593
+ return t;
594
+ }).finally(() => {
595
+ this.pendingAuth = null;
596
+ });
597
+ }
598
+ return this.pendingAuth;
599
+ }
600
+ /**
601
+ * Notify the host that the authenticated identity changed. De-duped
602
+ * via the token string so multiple resolveToken() hits with the same
603
+ * cached token don't re-call setUser on every fetch.
604
+ */
605
+ fireAuthChanged(token) {
606
+ if (this.lastNotifiedTokenId === token.token) return;
607
+ this.lastNotifiedTokenId = token.token;
608
+ try {
609
+ this.onAuthChanged?.({ userId: token.userId, user: token.user });
610
+ } catch (err) {
611
+ if (typeof console !== "undefined") {
612
+ console.warn("[vizu/cloud] onAuthChanged callback threw", err);
613
+ }
614
+ }
615
+ }
616
+ openSignInPopup() {
617
+ return new Promise((resolve, reject) => {
618
+ if (typeof window === "undefined") {
619
+ return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
620
+ }
621
+ const apiOrigin = new URL(this.apiUrl).origin;
622
+ const hostOrigin = window.location.origin;
623
+ const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
624
+ const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
625
+ const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
626
+ const popup = window.open(
627
+ popupUrl,
628
+ "vizu-connect",
629
+ `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
630
+ );
631
+ if (!popup) {
632
+ return reject(
633
+ makeAuthError(
634
+ "popup_blocked",
635
+ "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
636
+ )
637
+ );
638
+ }
639
+ let resolved = false;
640
+ const onMessage = (e) => {
641
+ if (e.origin !== apiOrigin) return;
642
+ const data = e.data;
643
+ if (!data || data.type !== "vizu:auth") return;
644
+ if (data.workspace !== this.workspace) return;
645
+ if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
646
+ resolved = true;
647
+ cleanup();
648
+ const userPayload = data.user;
649
+ const user = userPayload && typeof userPayload.name === "string" ? {
650
+ id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
651
+ name: userPayload.name,
652
+ avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
653
+ } : null;
654
+ resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
655
+ };
656
+ const onPoll = window.setInterval(() => {
657
+ if (popup.closed) {
658
+ if (!resolved) {
659
+ cleanup();
660
+ reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
661
+ }
662
+ }
663
+ }, 500);
664
+ function cleanup() {
665
+ window.clearInterval(onPoll);
666
+ window.removeEventListener("message", onMessage);
667
+ }
668
+ window.addEventListener("message", onMessage);
669
+ });
670
+ }
671
+ /* ─── Fetch wrapper ───────────────────────────────────────────────── */
672
+ /**
673
+ * Perform an authenticated fetch with one-shot retry on 401 (re-opens
674
+ * the popup if the token expired during the request). 4xx other than
675
+ * 401 / 404 throw; 5xx throws; 2xx returns.
676
+ */
677
+ async fetchAuthed(url, init, retried = false) {
678
+ const token = await this.resolveToken();
679
+ const headers = new Headers(init.headers);
680
+ headers.set("authorization", `Bearer ${token.token}`);
681
+ const res = await fetch(url, { ...init, headers, credentials: "omit" });
682
+ if (res.status === 401 && !retried) {
683
+ this.clearStoredToken();
684
+ return this.fetchAuthed(url, init, true);
685
+ }
686
+ return res;
687
+ }
688
+ async parseJson(res) {
689
+ try {
690
+ const text = await res.text();
691
+ return text ? JSON.parse(text) : null;
692
+ } catch {
693
+ return null;
1804
694
  }
1805
695
  }
1806
- async clear(_namespace) {
1807
- const url = new URL(this.apiUrl + "/api/comments");
1808
- url.searchParams.set("workspace", this.workspace);
1809
- url.searchParams.set("all", "true");
1810
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1811
- if (res.status === 404) return;
1812
- if (!res.ok) {
1813
- const json = await this.parseJson(res);
1814
- throw apiErr(res.status, json);
696
+ };
697
+ function makeAuthError(code, message) {
698
+ const err = new Error(message);
699
+ err.code = code;
700
+ return err;
701
+ }
702
+ function apiErr(status, body) {
703
+ const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
704
+ const err = new Error(body?.message ?? `HTTP ${status}`);
705
+ err.code = code;
706
+ err.status = status;
707
+ err.body = body;
708
+ return err;
709
+ }
710
+
711
+ // src/fingerprint.ts
712
+ var TEXT_SNIPPET_MAX = 80;
713
+ var ANCESTOR_DEPTH = 4;
714
+ var ANCESTOR_MAX_SHIFT = 3;
715
+ var TEXT_RATIO_THRESHOLD = 0.75;
716
+ var CLASS_JACCARD_THRESHOLD = 0.6;
717
+ function buildSelector(el) {
718
+ const parts = [];
719
+ let current = el;
720
+ let depth = 0;
721
+ while (current && current !== document.documentElement && depth < 8) {
722
+ let part = current.tagName.toLowerCase();
723
+ if (current.id) {
724
+ parts.unshift("#" + CSS.escape(current.id));
725
+ break;
1815
726
  }
727
+ const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
728
+ if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
729
+ const parent = current.parentElement;
730
+ if (parent) {
731
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
732
+ if (sameTag.length > 1) {
733
+ const idx = sameTag.indexOf(current);
734
+ part += ":nth-of-type(" + (idx + 1) + ")";
735
+ }
736
+ }
737
+ parts.unshift(part);
738
+ current = parent;
739
+ depth++;
1816
740
  }
1817
- async addReply(_namespace, commentId, reply) {
1818
- const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
1819
- url.searchParams.set("workspace", this.workspace);
1820
- const res = await this.fetchAuthed(url.toString(), {
1821
- method: "POST",
1822
- headers: { "content-type": "application/json" },
1823
- body: JSON.stringify(reply)
1824
- });
1825
- if (res.status === 404) return null;
1826
- const json = await this.parseJson(res);
1827
- if (!res.ok) throw apiErr(res.status, json);
1828
- return json?.comment ? migrateComment(json.comment) : null;
741
+ return parts.join(" > ");
742
+ }
743
+ function fingerprint(el) {
744
+ const text = el.innerText || el.textContent || "";
745
+ const parent = el.parentElement;
746
+ let siblingIndex = 0;
747
+ if (parent) {
748
+ siblingIndex = Array.from(parent.children).indexOf(el);
1829
749
  }
1830
- async removeReply(_namespace, commentId, replyId) {
1831
- const url = new URL(
1832
- this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
1833
- );
1834
- url.searchParams.set("workspace", this.workspace);
1835
- const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
1836
- if (res.status === 404) return null;
1837
- if (!res.ok) {
1838
- const json = await this.parseJson(res);
1839
- throw apiErr(res.status, json);
750
+ return {
751
+ selector: buildSelector(el),
752
+ parentSelector: parent ? buildSelector(parent) : "",
753
+ tagName: el.tagName,
754
+ textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
755
+ siblingIndex,
756
+ attributes: {
757
+ id: el.id || void 0,
758
+ classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
759
+ role: el.getAttribute("role") || void 0,
760
+ ariaLabel: el.getAttribute("aria-label") || void 0,
761
+ dataKey: el.getAttribute("data-vizu-key") || void 0
762
+ },
763
+ ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
764
+ algorithmVersion: 2
765
+ };
766
+ }
767
+ function captureAncestorChain(el, depth) {
768
+ const steps = [];
769
+ let current = el.parentElement;
770
+ while (current && steps.length < depth) {
771
+ const parent = current.parentElement;
772
+ let nthOfType = 1;
773
+ if (parent) {
774
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
775
+ nthOfType = sameTag.indexOf(current) + 1;
1840
776
  }
1841
- return null;
777
+ steps.push({ tag: current.tagName, nthOfType });
778
+ current = parent;
1842
779
  }
1843
- /**
1844
- * Upload a file as multipart/form-data to the host backend, which
1845
- * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
1846
- * function body cap (our own 5 MB attachment cap will be enforced
1847
- * server-side before that's hit).
1848
- *
1849
- * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
1850
- * - keeps `@vizu/core` free of any `@vercel/blob` dependency
1851
- * (script-tag and local-only consumers don't pull blob code)
1852
- * - the multipart path is one round-trip; the direct-upload flow
1853
- * is two (signed URL + upload). For attachments capped at 5 MB,
1854
- * the savings don't justify the dep cost.
1855
- */
1856
- async uploadAttachment(_namespace, file) {
1857
- const form = new FormData();
1858
- form.append("file", file, file.name);
1859
- const url = new URL(this.apiUrl + "/api/uploads");
1860
- url.searchParams.set("workspace", this.workspace);
1861
- const res = await this.fetchAuthed(url.toString(), {
1862
- method: "POST",
1863
- body: form
1864
- // Don't set content-type; the browser fills in the multipart boundary.
1865
- });
1866
- const json = await this.parseJson(res);
1867
- if (!res.ok) throw apiErr(res.status, json);
1868
- if (!json?.url || typeof json.url !== "string") {
1869
- throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
780
+ return steps.length > 0 ? { steps } : void 0;
781
+ }
782
+ function findByFingerprint(fp, root = document) {
783
+ if (fp.attributes.dataKey) {
784
+ try {
785
+ const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
786
+ if (byKey) return { element: byKey, confidence: "exact" };
787
+ } catch {
1870
788
  }
1871
- return {
1872
- id: uuid(),
1873
- url: json.url,
1874
- mimeType: file.type || "application/octet-stream",
1875
- sizeBytes: file.size,
1876
- uploadedAt: Date.now(),
1877
- filename: file.name
1878
- };
789
+ return { element: null, confidence: "orphaned" };
1879
790
  }
1880
- getAuthContext() {
1881
- if (!this.cachedToken) return null;
1882
- return {
1883
- userId: this.cachedToken.userId,
1884
- token: this.cachedToken.token,
1885
- expiresAt: this.cachedToken.expiresAt
1886
- };
791
+ if (fp.attributes.id) {
792
+ try {
793
+ const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
794
+ if (byId) return { element: byId, confidence: "exact" };
795
+ } catch {
796
+ }
1887
797
  }
1888
- /* ─── Auth ────────────────────────────────────────────────────────── */
1889
- isExpired(t) {
1890
- if (!t) return true;
1891
- const exp = new Date(t.expiresAt).getTime();
1892
- return !Number.isFinite(exp) || exp - Date.now() < 3e4;
798
+ const candidates = [];
799
+ const classes = fp.attributes.classList;
800
+ if (classes && classes.length > 0) {
801
+ const m = findByClassSignature(root, fp.tagName, classes);
802
+ if (m?.element) candidates.push({ el: m.element, rung: "class" });
1893
803
  }
1894
- readStoredToken() {
1895
- if (typeof sessionStorage === "undefined") return null;
1896
- const raw = sessionStorage.getItem(this.sessionKey());
1897
- if (!raw) return null;
804
+ try {
805
+ const bySelector = root.querySelector(fp.selector);
806
+ if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
807
+ } catch {
808
+ }
809
+ if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
810
+ const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
811
+ if (m?.element) candidates.push({ el: m.element, rung: "chain" });
812
+ } else {
1898
813
  try {
1899
- const parsed = JSON.parse(raw);
1900
- if (this.isExpired(parsed)) {
1901
- sessionStorage.removeItem(this.sessionKey());
1902
- return null;
814
+ const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
815
+ if (parent) {
816
+ const candidate = parent.children[fp.siblingIndex];
817
+ if (candidate && candidate.tagName === fp.tagName) {
818
+ candidates.push({ el: candidate, rung: "chain" });
819
+ }
1903
820
  }
1904
- return parsed;
1905
821
  } catch {
1906
- return null;
1907
822
  }
1908
823
  }
1909
- writeStoredToken(t) {
1910
- this.cachedToken = t;
1911
- if (typeof sessionStorage !== "undefined") {
1912
- sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
824
+ if (fp.textSnippet) {
825
+ const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
826
+ if (m?.element) candidates.push({ el: m.element, rung: "text" });
827
+ }
828
+ if (candidates.length === 0) {
829
+ return { element: null, confidence: "orphaned" };
830
+ }
831
+ const votes = /* @__PURE__ */ new Map();
832
+ for (const c of candidates) {
833
+ let s = votes.get(c.el);
834
+ if (!s) {
835
+ s = /* @__PURE__ */ new Set();
836
+ votes.set(c.el, s);
1913
837
  }
838
+ s.add(c.rung);
1914
839
  }
1915
- clearStoredToken() {
1916
- this.cachedToken = null;
1917
- if (typeof sessionStorage !== "undefined") {
1918
- sessionStorage.removeItem(this.sessionKey());
840
+ let best = null;
841
+ for (const [el, rungs] of votes) {
842
+ if (!best || rungs.size > best.rungs.size) best = { el, rungs };
843
+ }
844
+ if (!best) return { element: null, confidence: "orphaned" };
845
+ if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
846
+ if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
847
+ if (best.rungs.has("class") || best.rungs.has("chain")) {
848
+ return { element: best.el, confidence: "drifted" };
849
+ }
850
+ return { element: null, confidence: "orphaned" };
851
+ }
852
+ function findByClassSignature(root, tagName, needle) {
853
+ const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
854
+ if (needleSet.size === 0) return null;
855
+ const all = root.querySelectorAll(tagName.toLowerCase());
856
+ let best = null;
857
+ let secondScore = 0;
858
+ for (const el of all) {
859
+ const haystack = new Set(
860
+ Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
861
+ );
862
+ if (haystack.size === 0) continue;
863
+ const score = jaccardSimilarity(needleSet, haystack);
864
+ if (score < CLASS_JACCARD_THRESHOLD) continue;
865
+ if (!best || score > best.score) {
866
+ secondScore = best?.score ?? 0;
867
+ best = { el, score };
868
+ } else if (score > secondScore) {
869
+ secondScore = score;
1919
870
  }
1920
871
  }
1921
- sessionKey() {
1922
- return `vizu:cloud-token:${this.workspace}`;
872
+ if (!best) return null;
873
+ if (best.score - secondScore < 0.1) return null;
874
+ return { element: best.el, confidence: "likely" };
875
+ }
876
+ function findByAncestorChain(root, tagName, steps) {
877
+ const all = root.querySelectorAll(tagName.toLowerCase());
878
+ let best = null;
879
+ for (const el of all) {
880
+ const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
881
+ const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
882
+ if (score < 2) continue;
883
+ if (!best || score > best.score) best = { el, score };
1923
884
  }
1924
- /**
1925
- * Resolve a valid token, opening the popup if needed.
1926
- *
1927
- * Single-flight: simultaneous requests await one popup, not N.
1928
- * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
1929
- * if the user closes the popup; hosts can catch this and present
1930
- * a friendly message instead of a blank failure.
1931
- */
1932
- async resolveToken() {
1933
- if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
1934
- const fresh = this.readStoredToken();
1935
- if (fresh) {
1936
- this.cachedToken = fresh;
1937
- this.fireAuthChanged(fresh);
1938
- return fresh;
1939
- }
1940
- if (!this.autoSignIn) {
1941
- throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
1942
- }
1943
- if (!this.pendingAuth) {
1944
- this.pendingAuth = this.openSignInPopup().then((t) => {
1945
- this.writeStoredToken(t);
1946
- this.fireAuthChanged(t);
1947
- return t;
1948
- }).finally(() => {
1949
- this.pendingAuth = null;
1950
- });
885
+ if (!best) return null;
886
+ return {
887
+ element: best.el,
888
+ confidence: best.score === steps.length ? "likely" : "drifted"
889
+ };
890
+ }
891
+ function collectAncestorSteps(el, limit) {
892
+ const steps = [];
893
+ let current = el.parentElement;
894
+ while (current && steps.length < limit) {
895
+ const parent = current.parentElement;
896
+ let nth = 1;
897
+ if (parent) {
898
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
899
+ nth = sameTag.indexOf(current) + 1;
1951
900
  }
1952
- return this.pendingAuth;
901
+ steps.push({ tag: current.tagName, nthOfType: nth });
902
+ current = parent;
1953
903
  }
1954
- /**
1955
- * Notify the host that the authenticated identity changed. De-duped
1956
- * via the token string so multiple resolveToken() hits with the same
1957
- * cached token don't re-call setUser on every fetch.
1958
- */
1959
- fireAuthChanged(token) {
1960
- if (this.lastNotifiedTokenId === token.token) return;
1961
- this.lastNotifiedTokenId = token.token;
1962
- try {
1963
- this.onAuthChanged?.({ userId: token.userId, user: token.user });
1964
- } catch (err) {
1965
- if (typeof console !== "undefined") {
1966
- console.warn("[vizu/cloud] onAuthChanged callback threw", err);
1967
- }
904
+ return steps;
905
+ }
906
+ function findByTextSnippet(root, tagName, snippet) {
907
+ const needle = normalizeText(snippet);
908
+ if (!needle) return null;
909
+ const all = root.querySelectorAll(tagName.toLowerCase());
910
+ let best = null;
911
+ for (const el of all) {
912
+ const raw = el.innerText || el.textContent || "";
913
+ const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
914
+ if (!candidate) continue;
915
+ const score = levenshteinRatio(needle, candidate);
916
+ if (score < TEXT_RATIO_THRESHOLD) continue;
917
+ if (!best || score > best.score) {
918
+ best = { el, score };
1968
919
  }
1969
920
  }
1970
- openSignInPopup() {
1971
- return new Promise((resolve, reject) => {
1972
- if (typeof window === "undefined") {
1973
- return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
1974
- }
1975
- const apiOrigin = new URL(this.apiUrl).origin;
1976
- const hostOrigin = window.location.origin;
1977
- const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
1978
- const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
1979
- const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
1980
- const popup = window.open(
1981
- popupUrl,
1982
- "vizu-connect",
1983
- `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
1984
- );
1985
- if (!popup) {
1986
- return reject(
1987
- makeAuthError(
1988
- "popup_blocked",
1989
- "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
1990
- )
1991
- );
1992
- }
1993
- let resolved = false;
1994
- const onMessage = (e) => {
1995
- if (e.origin !== apiOrigin) return;
1996
- const data = e.data;
1997
- if (!data || data.type !== "vizu:auth") return;
1998
- if (data.workspace !== this.workspace) return;
1999
- if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
2000
- resolved = true;
2001
- cleanup();
2002
- const userPayload = data.user;
2003
- const user = userPayload && typeof userPayload.name === "string" ? {
2004
- id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
2005
- name: userPayload.name,
2006
- avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
2007
- } : null;
2008
- resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
2009
- };
2010
- const onPoll = window.setInterval(() => {
2011
- if (popup.closed) {
2012
- if (!resolved) {
2013
- cleanup();
2014
- reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
2015
- }
2016
- }
2017
- }, 500);
2018
- function cleanup() {
2019
- window.clearInterval(onPoll);
2020
- window.removeEventListener("message", onMessage);
2021
- }
2022
- window.addEventListener("message", onMessage);
2023
- });
921
+ return best ? { element: best.el, confidence: "drifted" } : null;
922
+ }
923
+ function normalizeText(input) {
924
+ return input.toLowerCase().replace(/\s+/g, " ").trim();
925
+ }
926
+ function levenshteinRatio(a, b) {
927
+ if (a === b) return 1;
928
+ if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
929
+ const maxLen = Math.max(a.length, b.length);
930
+ const distance = levenshteinDistance(a, b);
931
+ return 1 - distance / maxLen;
932
+ }
933
+ function levenshteinDistance(a, b) {
934
+ if (a.length > b.length) {
935
+ const tmp = a;
936
+ a = b;
937
+ b = tmp;
2024
938
  }
2025
- /* ─── Fetch wrapper ───────────────────────────────────────────────── */
2026
- /**
2027
- * Perform an authenticated fetch with one-shot retry on 401 (re-opens
2028
- * the popup if the token expired during the request). 4xx other than
2029
- * 401 / 404 throw; 5xx throws; 2xx returns.
2030
- */
2031
- async fetchAuthed(url, init, retried = false) {
2032
- const token = await this.resolveToken();
2033
- const headers = new Headers(init.headers);
2034
- headers.set("authorization", `Bearer ${token.token}`);
2035
- const res = await fetch(url, { ...init, headers, credentials: "omit" });
2036
- if (res.status === 401 && !retried) {
2037
- this.clearStoredToken();
2038
- return this.fetchAuthed(url, init, true);
939
+ const prev = new Array(a.length + 1);
940
+ const curr = new Array(a.length + 1);
941
+ for (let i = 0; i <= a.length; i++) prev[i] = i;
942
+ for (let j = 1; j <= b.length; j++) {
943
+ curr[0] = j;
944
+ for (let i = 1; i <= a.length; i++) {
945
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
946
+ curr[i] = Math.min(
947
+ curr[i - 1] + 1,
948
+ // insert
949
+ prev[i] + 1,
950
+ // delete
951
+ prev[i - 1] + cost
952
+ // substitute
953
+ );
2039
954
  }
2040
- return res;
955
+ for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
2041
956
  }
2042
- async parseJson(res) {
2043
- try {
2044
- const text = await res.text();
2045
- return text ? JSON.parse(text) : null;
2046
- } catch {
2047
- return null;
957
+ return prev[a.length];
958
+ }
959
+ function scoreChainAgainstAncestors(actual, captured, maxShift) {
960
+ let best = 0;
961
+ for (let offset = 0; offset <= maxShift; offset++) {
962
+ let score = 0;
963
+ for (let i = 0; i < captured.length; i++) {
964
+ const a = actual[i + offset];
965
+ if (!a) break;
966
+ if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
967
+ score++;
968
+ }
2048
969
  }
970
+ if (score > best) best = score;
2049
971
  }
2050
- };
2051
- function makeAuthError(code, message) {
2052
- const err = new Error(message);
2053
- err.code = code;
2054
- return err;
972
+ return best;
2055
973
  }
2056
- function apiErr(status, body) {
2057
- const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
2058
- const err = new Error(body?.message ?? `HTTP ${status}`);
2059
- err.code = code;
2060
- err.status = status;
2061
- err.body = body;
2062
- return err;
974
+ function jaccardSimilarity(a, b) {
975
+ if (a.size === 0 && b.size === 0) return 1;
976
+ let intersection = 0;
977
+ for (const item of a) if (b.has(item)) intersection++;
978
+ const union = a.size + b.size - intersection;
979
+ return union === 0 ? 0 : intersection / union;
980
+ }
981
+ function findElementByFingerprint(fp, root) {
982
+ return findByFingerprint(fp, root).element;
983
+ }
984
+ function fingerprintKey(fp) {
985
+ return fp.selector + "|" + fp.textSnippet;
986
+ }
987
+ function fingerprintLabel(fp) {
988
+ const tag = fp.tagName.toLowerCase();
989
+ if (fp.textSnippet) {
990
+ const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
991
+ return tag + ": " + snippet;
992
+ }
993
+ if (fp.attributes.id) return tag + "#" + fp.attributes.id;
994
+ if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
995
+ return tag;
2063
996
  }
2064
-
2065
- // src/index.ts
2066
- init_fingerprint();
2067
997
 
2068
998
  // src/highlighter.ts
2069
999
  var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
2070
1000
  var Highlighter = class {
2071
- constructor(root, extraIgnore, onClick, onCurrentChange) {
1001
+ constructor(root, extraIgnore, onClick) {
2072
1002
  this.current = null;
2073
1003
  this.active = false;
2074
1004
  this.paused = false;
@@ -2103,7 +1033,6 @@ var Highlighter = class {
2103
1033
  this.root = root;
2104
1034
  this.extraIgnore = extraIgnore;
2105
1035
  this.onClick = onClick;
2106
- this.onCurrentChange = onCurrentChange ?? null;
2107
1036
  this.overlay = document.createElement("div");
2108
1037
  this.overlay.className = "vz-highlight";
2109
1038
  this.overlay.style.display = "none";
@@ -2112,7 +1041,6 @@ var Highlighter = class {
2112
1041
  setCurrent(el) {
2113
1042
  if (this.current === el) return;
2114
1043
  this.current = el;
2115
- this.onCurrentChange?.(el);
2116
1044
  }
2117
1045
  start() {
2118
1046
  if (this.active) return;
@@ -2173,7 +1101,6 @@ var Highlighter = class {
2173
1101
  };
2174
1102
 
2175
1103
  // src/popover.ts
2176
- init_fingerprint();
2177
1104
  function cssEscape(s) {
2178
1105
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
2179
1106
  return s.replace(/["\\]/g, "\\$&");
@@ -2851,7 +1778,6 @@ var Pill = class {
2851
1778
  };
2852
1779
 
2853
1780
  // src/sidebar.ts
2854
- init_fingerprint();
2855
1781
  var Sidebar = class {
2856
1782
  constructor(root, callbacks) {
2857
1783
  this.open = false;
@@ -4100,7 +3026,6 @@ var EventBus = class {
4100
3026
  };
4101
3027
 
4102
3028
  // src/index.ts
4103
- init_fingerprint();
4104
3029
  var DEFAULTS = {
4105
3030
  shortcut: "mod+shift+e",
4106
3031
  namespace: "default",
@@ -4160,8 +3085,6 @@ var Vizu = class {
4160
3085
  this.selfHealedThisSession = /* @__PURE__ */ new Set();
4161
3086
  this.actions = [];
4162
3087
  this.user = null;
4163
- /** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
4164
- this.liveCollab = null;
4165
3088
  this.enabled = false;
4166
3089
  this.rafScheduled = false;
4167
3090
  this.bus = new EventBus();
@@ -4233,18 +3156,6 @@ var Vizu = class {
4233
3156
  if (options.actions) this.actions = [...options.actions];
4234
3157
  if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
4235
3158
  if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
4236
- if (options.liveblocks && options.cloud) {
4237
- const live = options.liveblocks;
4238
- const cloud = options.cloud;
4239
- void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
4240
- this.liveCollab = new LiveCollab2({
4241
- publicKey: live.publicKey,
4242
- authEndpoint: live.authEndpoint,
4243
- workspaceSlug: cloud.workspace
4244
- });
4245
- void this.liveCollab.start();
4246
- });
4247
- }
4248
3159
  if (typeof window !== "undefined") {
4249
3160
  window.addEventListener("keydown", this.onKeyDown);
4250
3161
  void this.loadComments().then(() => this.subscribeStorage());
@@ -4302,8 +3213,6 @@ var Vizu = class {
4302
3213
  this.unsubscribeStorage?.();
4303
3214
  this.unsubscribeStorage = null;
4304
3215
  this.selfHealedThisSession.clear();
4305
- this.liveCollab?.destroy();
4306
- this.liveCollab = null;
4307
3216
  this.bus.clear();
4308
3217
  }
4309
3218
  /* ===== Action API ===== */
@@ -4625,11 +3534,7 @@ var Vizu = class {
4625
3534
  this.highlighter = new Highlighter(
4626
3535
  this.root,
4627
3536
  this.opts.ignoreSelectors ?? [],
4628
- (el, e) => this.onElementClick(el, e),
4629
- (el) => {
4630
- if (!this.liveCollab) return;
4631
- this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
4632
- }
3537
+ (el, e) => this.onElementClick(el, e)
4633
3538
  );
4634
3539
  this.highlighter.start();
4635
3540
  this.renderAllMarkers();