@unhingged/vizu-core 0.1.0

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.js ADDED
@@ -0,0 +1,3600 @@
1
+ import {
2
+ findByFingerprint,
3
+ findElementByFingerprint,
4
+ fingerprint,
5
+ fingerprintKey,
6
+ fingerprintLabel
7
+ } from "./chunk-OMIFOSQ2.js";
8
+
9
+ // src/types.ts
10
+ var SCHEMA_VERSION = 2;
11
+
12
+ // src/util.ts
13
+ function uuid() {
14
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
15
+ return crypto.randomUUID();
16
+ }
17
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
18
+ }
19
+ function isInside(target, selector) {
20
+ return !!target.closest(selector);
21
+ }
22
+ function escapeHtml(s) {
23
+ return s.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);
24
+ }
25
+ function formatTime(ts) {
26
+ const d = new Date(ts);
27
+ const now = Date.now();
28
+ const diff = now - ts;
29
+ if (diff < 6e4) return "just now";
30
+ if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
31
+ if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
32
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
33
+ }
34
+ function initials(name) {
35
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
36
+ }
37
+ function avatarHtml(user, className = "vz-comment-author-avatar") {
38
+ if (!user) return `<span class="${className}">\xB7</span>`;
39
+ if (user.avatarUrl) {
40
+ return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
41
+ }
42
+ return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
43
+ }
44
+ function refId() {
45
+ return Math.random().toString(36).slice(2, 10);
46
+ }
47
+ var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
48
+ var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
49
+ function renderCommentText(text, commentId) {
50
+ const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
51
+ return parts.map((part) => {
52
+ const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
53
+ if (refMatch) {
54
+ 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>`;
55
+ }
56
+ const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
57
+ if (mentionMatch) {
58
+ return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
59
+ }
60
+ return escapeHtml(part);
61
+ }).join("");
62
+ }
63
+ function extractMentions(text) {
64
+ const out = [];
65
+ for (const m of text.matchAll(MENTION_TOKEN_RE)) {
66
+ if (!out.includes(m[2])) out.push(m[2]);
67
+ }
68
+ return out;
69
+ }
70
+ function renderAttachmentsHtml(attachments) {
71
+ if (!Array.isArray(attachments) || attachments.length === 0) return "";
72
+ const items = attachments.map((a) => {
73
+ const isImage = a.mimeType?.startsWith("image/");
74
+ const filename = a.filename ?? a.url.split("/").pop() ?? "file";
75
+ if (isImage) {
76
+ return `
77
+ <a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
78
+ <img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
79
+ </a>
80
+ `;
81
+ }
82
+ return `
83
+ <a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
84
+ <span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
85
+ <span class="vz-attachment-filename">${escapeHtml(filename)}</span>
86
+ </a>
87
+ `;
88
+ }).join("");
89
+ return `<div class="vz-attachment-grid">${items}</div>`;
90
+ }
91
+ function migrateComment(raw) {
92
+ if (!raw || typeof raw !== "object") return raw;
93
+ const next = { ...raw };
94
+ if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
95
+ if (next.fingerprint) {
96
+ next.fingerprints = [next.fingerprint];
97
+ }
98
+ }
99
+ delete next.fingerprint;
100
+ if (next.url && !next.pageUrl) {
101
+ next.pageUrl = next.url;
102
+ }
103
+ if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
104
+ if (!Array.isArray(next.replies)) next.replies = [];
105
+ if (!Array.isArray(next.attachments)) next.attachments = [];
106
+ if (!Array.isArray(next.mentions)) next.mentions = [];
107
+ if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
108
+ next.schemaVersion = SCHEMA_VERSION;
109
+ return next;
110
+ }
111
+ function isValidStatus(s) {
112
+ return s === "open" || s === "resolved" || s === "wontfix";
113
+ }
114
+ function migrateComments(raws) {
115
+ if (!Array.isArray(raws)) return [];
116
+ const out = [];
117
+ for (const raw of raws) {
118
+ if (!raw || typeof raw !== "object") continue;
119
+ out.push(migrateComment(raw));
120
+ }
121
+ return out;
122
+ }
123
+ function needsPersist(raws) {
124
+ if (!Array.isArray(raws)) return false;
125
+ for (const raw of raws) {
126
+ if (!raw || typeof raw !== "object") continue;
127
+ const c = raw;
128
+ if (c.schemaVersion !== SCHEMA_VERSION) return true;
129
+ if ("fingerprint" in c) return true;
130
+ if (!Array.isArray(c.replies)) return true;
131
+ if (!Array.isArray(c.attachments)) return true;
132
+ if (!Array.isArray(c.mentions)) return true;
133
+ }
134
+ return false;
135
+ }
136
+
137
+ // src/storage.ts
138
+ var key = (ns) => `vizu:comments:${ns}`;
139
+ var LocalStorageAdapter = class {
140
+ async load(namespace) {
141
+ return readArray(namespace);
142
+ }
143
+ async addComment(namespace, comment) {
144
+ const list = readArray(namespace);
145
+ list.push(comment);
146
+ writeArray(namespace, list);
147
+ }
148
+ async updateComment(namespace, id, patch) {
149
+ const list = readArray(namespace);
150
+ const idx = list.findIndex((c) => c.id === id);
151
+ if (idx === -1) return null;
152
+ const next = { ...list[idx], ...patch, id: list[idx].id };
153
+ list[idx] = next;
154
+ writeArray(namespace, list);
155
+ return next;
156
+ }
157
+ async removeComment(namespace, id) {
158
+ const list = readArray(namespace);
159
+ writeArray(namespace, list.filter((c) => c.id !== id));
160
+ }
161
+ async setAll(namespace, comments) {
162
+ writeArray(namespace, comments);
163
+ }
164
+ async clear(namespace) {
165
+ if (typeof localStorage === "undefined") return;
166
+ localStorage.removeItem(key(namespace));
167
+ }
168
+ async addReply(namespace, commentId, reply) {
169
+ const list = readArray(namespace);
170
+ const idx = list.findIndex((c) => c.id === commentId);
171
+ if (idx === -1) return null;
172
+ const next = { ...list[idx], replies: [...list[idx].replies ?? [], reply] };
173
+ list[idx] = next;
174
+ writeArray(namespace, list);
175
+ return next;
176
+ }
177
+ async removeReply(namespace, commentId, replyId) {
178
+ const list = readArray(namespace);
179
+ const idx = list.findIndex((c) => c.id === commentId);
180
+ if (idx === -1) return null;
181
+ const next = {
182
+ ...list[idx],
183
+ replies: (list[idx].replies ?? []).filter((r) => r.id !== replyId)
184
+ };
185
+ list[idx] = next;
186
+ writeArray(namespace, list);
187
+ return next;
188
+ }
189
+ };
190
+ function readArray(namespace) {
191
+ if (typeof localStorage === "undefined") return [];
192
+ const raw = localStorage.getItem(key(namespace));
193
+ if (!raw) return [];
194
+ try {
195
+ const parsed = JSON.parse(raw);
196
+ return Array.isArray(parsed) ? migrateComments(parsed) : [];
197
+ } catch {
198
+ return [];
199
+ }
200
+ }
201
+ function writeArray(namespace, comments) {
202
+ if (typeof localStorage === "undefined") return;
203
+ localStorage.setItem(key(namespace), JSON.stringify(comments));
204
+ }
205
+ var InMemoryStorageAdapter = class {
206
+ constructor() {
207
+ this.store = /* @__PURE__ */ new Map();
208
+ }
209
+ async load(namespace) {
210
+ return [...this.store.get(namespace) || []];
211
+ }
212
+ async addComment(namespace, comment) {
213
+ const list = this.store.get(namespace) ?? [];
214
+ this.store.set(namespace, [...list, comment]);
215
+ }
216
+ async updateComment(namespace, id, patch) {
217
+ const list = this.store.get(namespace) ?? [];
218
+ const idx = list.findIndex((c) => c.id === id);
219
+ if (idx === -1) return null;
220
+ const next = { ...list[idx], ...patch, id: list[idx].id };
221
+ const copy = [...list];
222
+ copy[idx] = next;
223
+ this.store.set(namespace, copy);
224
+ return next;
225
+ }
226
+ async removeComment(namespace, id) {
227
+ const list = this.store.get(namespace) ?? [];
228
+ this.store.set(
229
+ namespace,
230
+ list.filter((c) => c.id !== id)
231
+ );
232
+ }
233
+ async setAll(namespace, comments) {
234
+ this.store.set(namespace, [...comments]);
235
+ }
236
+ async clear(namespace) {
237
+ this.store.delete(namespace);
238
+ }
239
+ async addReply(namespace, commentId, reply) {
240
+ const list = this.store.get(namespace) ?? [];
241
+ const idx = list.findIndex((c) => c.id === commentId);
242
+ if (idx === -1) return null;
243
+ const next = { ...list[idx], replies: [...list[idx].replies ?? [], reply] };
244
+ const copy = [...list];
245
+ copy[idx] = next;
246
+ this.store.set(namespace, copy);
247
+ return next;
248
+ }
249
+ async removeReply(namespace, commentId, replyId) {
250
+ const list = this.store.get(namespace) ?? [];
251
+ const idx = list.findIndex((c) => c.id === commentId);
252
+ if (idx === -1) return null;
253
+ const next = {
254
+ ...list[idx],
255
+ replies: (list[idx].replies ?? []).filter((r) => r.id !== replyId)
256
+ };
257
+ const copy = [...list];
258
+ copy[idx] = next;
259
+ this.store.set(namespace, copy);
260
+ return next;
261
+ }
262
+ };
263
+ var NullStorageAdapter = class {
264
+ async load() {
265
+ return [];
266
+ }
267
+ async addComment() {
268
+ }
269
+ async updateComment() {
270
+ return null;
271
+ }
272
+ async removeComment() {
273
+ }
274
+ async setAll() {
275
+ }
276
+ async clear() {
277
+ }
278
+ };
279
+ function isV1Adapter(x) {
280
+ if (!x || typeof x !== "object") return false;
281
+ const o = x;
282
+ return typeof o.load === "function" && typeof o.save === "function" && typeof o.clear === "function" && typeof o.addComment !== "function";
283
+ }
284
+ function wrapV1Adapter(v1) {
285
+ if (!isV1Adapter(v1)) return v1;
286
+ let warned = false;
287
+ const warnOnce = () => {
288
+ if (warned) return;
289
+ warned = true;
290
+ if (typeof console !== "undefined") {
291
+ console.warn(
292
+ "[vizu] StorageAdapter v1 detected. Per-comment ops are emulated via full-array rewrites \u2014 upgrade your adapter to the v2 interface (addComment / updateComment / removeComment / setAll / load / clear) when you can."
293
+ );
294
+ }
295
+ };
296
+ return {
297
+ async load(namespace) {
298
+ warnOnce();
299
+ return v1.load(namespace);
300
+ },
301
+ async addComment(namespace, comment) {
302
+ warnOnce();
303
+ const list = await v1.load(namespace);
304
+ list.push(comment);
305
+ await v1.save(namespace, list);
306
+ },
307
+ async updateComment(namespace, id, patch) {
308
+ warnOnce();
309
+ const list = await v1.load(namespace);
310
+ const idx = list.findIndex((c) => c.id === id);
311
+ if (idx === -1) return null;
312
+ const next = { ...list[idx], ...patch, id: list[idx].id };
313
+ list[idx] = next;
314
+ await v1.save(namespace, list);
315
+ return next;
316
+ },
317
+ async removeComment(namespace, id) {
318
+ warnOnce();
319
+ const list = await v1.load(namespace);
320
+ await v1.save(
321
+ namespace,
322
+ list.filter((c) => c.id !== id)
323
+ );
324
+ },
325
+ async setAll(namespace, comments) {
326
+ warnOnce();
327
+ await v1.save(namespace, comments);
328
+ },
329
+ async clear(namespace) {
330
+ await v1.clear(namespace);
331
+ }
332
+ };
333
+ }
334
+
335
+ // src/cloud.ts
336
+ var DEFAULT_API_URL = "https://vizu.unhingged.com";
337
+ var POPUP_WIDTH = 480;
338
+ var POPUP_HEIGHT = 640;
339
+ var CloudStorageAdapter = class {
340
+ constructor(opts) {
341
+ /** Cached current-tab token. Mirrored to sessionStorage. */
342
+ this.cachedToken = null;
343
+ /** Single-flight: at most one popup at a time. Subsequent requests await this. */
344
+ this.pendingAuth = null;
345
+ this.workspace = opts.workspace;
346
+ this.apiUrl = (opts.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
347
+ this.autoSignIn = opts.autoSignIn !== false;
348
+ this.cachedToken = this.readStoredToken();
349
+ }
350
+ /* ─── StorageAdapter v2 ───────────────────────────────────────────── */
351
+ async load(_namespace) {
352
+ const out = [];
353
+ let cursor = null;
354
+ do {
355
+ const url = new URL(this.apiUrl + "/api/comments");
356
+ url.searchParams.set("workspace", this.workspace);
357
+ url.searchParams.set("limit", "100");
358
+ if (cursor) url.searchParams.set("cursor", cursor);
359
+ const res = await this.fetchAuthed(url.toString(), { method: "GET" });
360
+ const json = await this.parseJson(res);
361
+ if (!res.ok) throw apiErr(res.status, json);
362
+ const page = Array.isArray(json.comments) ? json.comments : [];
363
+ out.push(...migrateComments(page));
364
+ cursor = typeof json.nextCursor === "string" ? json.nextCursor : null;
365
+ } while (cursor);
366
+ return out;
367
+ }
368
+ async addComment(_namespace, comment) {
369
+ const res = await this.fetchAuthed(this.apiUrl + "/api/comments", {
370
+ method: "POST",
371
+ headers: { "content-type": "application/json" },
372
+ body: JSON.stringify({ workspace: this.workspace, comment })
373
+ });
374
+ const json = await this.parseJson(res);
375
+ if (!res.ok) throw apiErr(res.status, json);
376
+ }
377
+ async updateComment(_namespace, id, patch) {
378
+ const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(id));
379
+ url.searchParams.set("workspace", this.workspace);
380
+ const res = await this.fetchAuthed(url.toString(), {
381
+ method: "PATCH",
382
+ headers: { "content-type": "application/json" },
383
+ body: JSON.stringify(patch)
384
+ });
385
+ if (res.status === 404) return null;
386
+ const json = await this.parseJson(res);
387
+ if (!res.ok) throw apiErr(res.status, json);
388
+ return migrateComment(json.comment);
389
+ }
390
+ async removeComment(_namespace, id) {
391
+ const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(id));
392
+ url.searchParams.set("workspace", this.workspace);
393
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
394
+ if (res.status === 404) return;
395
+ if (!res.ok) {
396
+ const json = await this.parseJson(res);
397
+ throw apiErr(res.status, json);
398
+ }
399
+ }
400
+ async setAll(namespace, comments) {
401
+ await this.clear(namespace);
402
+ for (const c of comments) {
403
+ await this.addComment(namespace, c);
404
+ }
405
+ }
406
+ async clear(_namespace) {
407
+ const url = new URL(this.apiUrl + "/api/comments");
408
+ url.searchParams.set("workspace", this.workspace);
409
+ url.searchParams.set("all", "true");
410
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
411
+ if (res.status === 404) return;
412
+ if (!res.ok) {
413
+ const json = await this.parseJson(res);
414
+ throw apiErr(res.status, json);
415
+ }
416
+ }
417
+ async addReply(_namespace, commentId, reply) {
418
+ const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
419
+ url.searchParams.set("workspace", this.workspace);
420
+ const res = await this.fetchAuthed(url.toString(), {
421
+ method: "POST",
422
+ headers: { "content-type": "application/json" },
423
+ body: JSON.stringify(reply)
424
+ });
425
+ if (res.status === 404) return null;
426
+ const json = await this.parseJson(res);
427
+ if (!res.ok) throw apiErr(res.status, json);
428
+ return json?.comment ? migrateComment(json.comment) : null;
429
+ }
430
+ async removeReply(_namespace, commentId, replyId) {
431
+ const url = new URL(
432
+ this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
433
+ );
434
+ url.searchParams.set("workspace", this.workspace);
435
+ const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
436
+ if (res.status === 404) return null;
437
+ if (!res.ok) {
438
+ const json = await this.parseJson(res);
439
+ throw apiErr(res.status, json);
440
+ }
441
+ return null;
442
+ }
443
+ /**
444
+ * Upload a file as multipart/form-data to the host backend, which
445
+ * forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
446
+ * function body cap (our own 5 MB attachment cap will be enforced
447
+ * server-side before that's hit).
448
+ *
449
+ * Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
450
+ * - keeps `@vizu/core` free of any `@vercel/blob` dependency
451
+ * (script-tag and local-only consumers don't pull blob code)
452
+ * - the multipart path is one round-trip; the direct-upload flow
453
+ * is two (signed URL + upload). For attachments capped at 5 MB,
454
+ * the savings don't justify the dep cost.
455
+ */
456
+ async uploadAttachment(_namespace, file) {
457
+ const form = new FormData();
458
+ form.append("file", file, file.name);
459
+ const url = new URL(this.apiUrl + "/api/uploads");
460
+ url.searchParams.set("workspace", this.workspace);
461
+ const res = await this.fetchAuthed(url.toString(), {
462
+ method: "POST",
463
+ body: form
464
+ // Don't set content-type; the browser fills in the multipart boundary.
465
+ });
466
+ const json = await this.parseJson(res);
467
+ if (!res.ok) throw apiErr(res.status, json);
468
+ if (!json?.url || typeof json.url !== "string") {
469
+ throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
470
+ }
471
+ return {
472
+ id: uuid(),
473
+ url: json.url,
474
+ mimeType: file.type || "application/octet-stream",
475
+ sizeBytes: file.size,
476
+ uploadedAt: Date.now(),
477
+ filename: file.name
478
+ };
479
+ }
480
+ getAuthContext() {
481
+ if (!this.cachedToken) return null;
482
+ return {
483
+ userId: this.cachedToken.userId,
484
+ token: this.cachedToken.token,
485
+ expiresAt: this.cachedToken.expiresAt
486
+ };
487
+ }
488
+ /* ─── Auth ────────────────────────────────────────────────────────── */
489
+ isExpired(t) {
490
+ if (!t) return true;
491
+ const exp = new Date(t.expiresAt).getTime();
492
+ return !Number.isFinite(exp) || exp - Date.now() < 3e4;
493
+ }
494
+ readStoredToken() {
495
+ if (typeof sessionStorage === "undefined") return null;
496
+ const raw = sessionStorage.getItem(this.sessionKey());
497
+ if (!raw) return null;
498
+ try {
499
+ const parsed = JSON.parse(raw);
500
+ if (this.isExpired(parsed)) {
501
+ sessionStorage.removeItem(this.sessionKey());
502
+ return null;
503
+ }
504
+ return parsed;
505
+ } catch {
506
+ return null;
507
+ }
508
+ }
509
+ writeStoredToken(t) {
510
+ this.cachedToken = t;
511
+ if (typeof sessionStorage !== "undefined") {
512
+ sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
513
+ }
514
+ }
515
+ clearStoredToken() {
516
+ this.cachedToken = null;
517
+ if (typeof sessionStorage !== "undefined") {
518
+ sessionStorage.removeItem(this.sessionKey());
519
+ }
520
+ }
521
+ sessionKey() {
522
+ return `vizu:cloud-token:${this.workspace}`;
523
+ }
524
+ /**
525
+ * Resolve a valid token, opening the popup if needed.
526
+ *
527
+ * Single-flight: simultaneous requests await one popup, not N.
528
+ * Caller-friendly: throws a Error with `.code === 'auth_canceled'`
529
+ * if the user closes the popup; hosts can catch this and present
530
+ * a friendly message instead of a blank failure.
531
+ */
532
+ async resolveToken() {
533
+ if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
534
+ const fresh = this.readStoredToken();
535
+ if (fresh) {
536
+ this.cachedToken = fresh;
537
+ return fresh;
538
+ }
539
+ if (!this.autoSignIn) {
540
+ throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
541
+ }
542
+ if (!this.pendingAuth) {
543
+ this.pendingAuth = this.openSignInPopup().then((t) => {
544
+ this.writeStoredToken(t);
545
+ return t;
546
+ }).finally(() => {
547
+ this.pendingAuth = null;
548
+ });
549
+ }
550
+ return this.pendingAuth;
551
+ }
552
+ openSignInPopup() {
553
+ return new Promise((resolve, reject) => {
554
+ if (typeof window === "undefined") {
555
+ return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
556
+ }
557
+ const apiOrigin = new URL(this.apiUrl).origin;
558
+ const hostOrigin = window.location.origin;
559
+ const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
560
+ const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
561
+ const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
562
+ const popup = window.open(
563
+ popupUrl,
564
+ "vizu-connect",
565
+ `width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
566
+ );
567
+ if (!popup) {
568
+ return reject(
569
+ makeAuthError(
570
+ "popup_blocked",
571
+ "Sign-in popup was blocked by the browser. Allow popups for this site and try again."
572
+ )
573
+ );
574
+ }
575
+ let resolved = false;
576
+ const onMessage = (e) => {
577
+ if (e.origin !== apiOrigin) return;
578
+ const data = e.data;
579
+ if (!data || data.type !== "vizu:auth") return;
580
+ if (data.workspace !== this.workspace) return;
581
+ if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
582
+ resolved = true;
583
+ cleanup();
584
+ resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId });
585
+ };
586
+ const onPoll = window.setInterval(() => {
587
+ if (popup.closed) {
588
+ if (!resolved) {
589
+ cleanup();
590
+ reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
591
+ }
592
+ }
593
+ }, 500);
594
+ function cleanup() {
595
+ window.clearInterval(onPoll);
596
+ window.removeEventListener("message", onMessage);
597
+ }
598
+ window.addEventListener("message", onMessage);
599
+ });
600
+ }
601
+ /* ─── Fetch wrapper ───────────────────────────────────────────────── */
602
+ /**
603
+ * Perform an authenticated fetch with one-shot retry on 401 (re-opens
604
+ * the popup if the token expired during the request). 4xx other than
605
+ * 401 / 404 throw; 5xx throws; 2xx returns.
606
+ */
607
+ async fetchAuthed(url, init, retried = false) {
608
+ const token = await this.resolveToken();
609
+ const headers = new Headers(init.headers);
610
+ headers.set("authorization", `Bearer ${token.token}`);
611
+ const res = await fetch(url, { ...init, headers, credentials: "omit" });
612
+ if (res.status === 401 && !retried) {
613
+ this.clearStoredToken();
614
+ return this.fetchAuthed(url, init, true);
615
+ }
616
+ return res;
617
+ }
618
+ async parseJson(res) {
619
+ try {
620
+ const text = await res.text();
621
+ return text ? JSON.parse(text) : null;
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+ };
627
+ function makeAuthError(code, message) {
628
+ const err = new Error(message);
629
+ err.code = code;
630
+ return err;
631
+ }
632
+ function apiErr(status, body) {
633
+ const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
634
+ const err = new Error(body?.message ?? `HTTP ${status}`);
635
+ err.code = code;
636
+ err.status = status;
637
+ err.body = body;
638
+ return err;
639
+ }
640
+
641
+ // src/highlighter.ts
642
+ var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
643
+ var Highlighter = class {
644
+ constructor(root, extraIgnore, onClick, onCurrentChange) {
645
+ this.current = null;
646
+ this.active = false;
647
+ this.paused = false;
648
+ this.handleMove = (e) => {
649
+ if (this.paused) {
650
+ this.setCurrent(null);
651
+ this.overlay.style.display = "none";
652
+ return;
653
+ }
654
+ const target = document.elementFromPoint(e.clientX, e.clientY);
655
+ if (!target || target === this.current) return;
656
+ if (this.shouldIgnore(target)) {
657
+ this.setCurrent(null);
658
+ this.overlay.style.display = "none";
659
+ return;
660
+ }
661
+ this.setCurrent(target);
662
+ this.updateOverlay();
663
+ };
664
+ this.handleClick = (e) => {
665
+ const target = document.elementFromPoint(e.clientX, e.clientY);
666
+ if (!target) return;
667
+ if (this.shouldIgnore(target)) return;
668
+ e.preventDefault();
669
+ e.stopPropagation();
670
+ this.onClick(target, e);
671
+ };
672
+ this.handleScroll = () => {
673
+ if (!this.current) return;
674
+ this.updateOverlay();
675
+ };
676
+ this.root = root;
677
+ this.extraIgnore = extraIgnore;
678
+ this.onClick = onClick;
679
+ this.onCurrentChange = onCurrentChange ?? null;
680
+ this.overlay = document.createElement("div");
681
+ this.overlay.className = "vz-highlight";
682
+ this.overlay.style.display = "none";
683
+ this.root.appendChild(this.overlay);
684
+ }
685
+ setCurrent(el) {
686
+ if (this.current === el) return;
687
+ this.current = el;
688
+ this.onCurrentChange?.(el);
689
+ }
690
+ start() {
691
+ if (this.active) return;
692
+ this.active = true;
693
+ document.addEventListener("mousemove", this.handleMove, true);
694
+ document.addEventListener("click", this.handleClick, true);
695
+ document.addEventListener("scroll", this.handleScroll, true);
696
+ window.addEventListener("resize", this.handleScroll);
697
+ }
698
+ stop() {
699
+ if (!this.active) return;
700
+ this.active = false;
701
+ document.removeEventListener("mousemove", this.handleMove, true);
702
+ document.removeEventListener("click", this.handleClick, true);
703
+ document.removeEventListener("scroll", this.handleScroll, true);
704
+ window.removeEventListener("resize", this.handleScroll);
705
+ this.overlay.style.display = "none";
706
+ this.setCurrent(null);
707
+ }
708
+ shouldIgnore(el) {
709
+ if (el === document.documentElement || el === document.body) return true;
710
+ if (isInside(el, IGNORE_SELECTOR)) return true;
711
+ for (const sel of this.extraIgnore) {
712
+ try {
713
+ if (isInside(el, sel)) return true;
714
+ } catch {
715
+ }
716
+ }
717
+ return false;
718
+ }
719
+ setPaused(paused) {
720
+ this.paused = paused;
721
+ if (paused) {
722
+ this.setCurrent(null);
723
+ this.overlay.style.display = "none";
724
+ }
725
+ }
726
+ isPaused() {
727
+ return this.paused;
728
+ }
729
+ updateOverlay() {
730
+ if (!this.current) return;
731
+ const rect = this.current.getBoundingClientRect();
732
+ if (rect.width === 0 || rect.height === 0) {
733
+ this.overlay.style.display = "none";
734
+ return;
735
+ }
736
+ this.overlay.style.display = "block";
737
+ this.overlay.style.left = rect.left + "px";
738
+ this.overlay.style.top = rect.top + "px";
739
+ this.overlay.style.width = rect.width + "px";
740
+ this.overlay.style.height = rect.height + "px";
741
+ }
742
+ destroy() {
743
+ this.stop();
744
+ this.overlay.remove();
745
+ }
746
+ };
747
+
748
+ // src/popover.ts
749
+ function cssEscape(s) {
750
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
751
+ return s.replace(/["\\]/g, "\\$&");
752
+ }
753
+ function renderRepliesHtml(c) {
754
+ const replies = c.replies ?? [];
755
+ const items = replies.map(
756
+ (r) => `
757
+ <div class="vz-reply" data-reply-id="${escapeHtml(r.id)}">
758
+ ${r.author ? `<div class="vz-comment-author">${avatarHtml(r.author)} <span class="vz-comment-author-name">${escapeHtml(r.author.name)}</span></div>` : ""}
759
+ <div class="vz-reply-text">${escapeHtml(r.text)}</div>
760
+ <div class="vz-comment-meta">
761
+ <span>${escapeHtml(formatTime(typeof r.createdAt === "number" ? r.createdAt : new Date(r.createdAt).getTime()))}</span>
762
+ <button class="vz-comment-delete" data-vz="delete-reply" data-comment-id="${escapeHtml(c.id)}" data-reply-id="${escapeHtml(r.id)}">delete</button>
763
+ </div>
764
+ </div>
765
+ `
766
+ ).join("");
767
+ return `
768
+ <div class="vz-replies" data-comment-id="${escapeHtml(c.id)}" hidden>
769
+ <div class="vz-reply-list">${items}</div>
770
+ <div class="vz-reply-form">
771
+ <textarea class="vz-reply-input" placeholder="Reply\u2026" rows="2" data-comment-id="${escapeHtml(c.id)}"></textarea>
772
+ <button class="vz-btn vz-btn-primary vz-btn-reply" data-vz="send-reply" data-id="${escapeHtml(c.id)}">Send</button>
773
+ </div>
774
+ </div>
775
+ `;
776
+ }
777
+ var Popover = class {
778
+ constructor(root, callbacks) {
779
+ this.currentUser = null;
780
+ /** All elements this in-progress comment is anchored to. First one positions the popover. */
781
+ this.anchorTargets = [];
782
+ this.anchorFingerprints = [];
783
+ /** Refs the user inserted via # picker during this session, keyed by refId. */
784
+ this.pendingRefs = {};
785
+ /** Attachments uploaded during this session, persisted into `comment.attachments` on save. */
786
+ this.pendingAttachments = [];
787
+ this.handleEditorKey = (e) => {
788
+ const editor = e.currentTarget;
789
+ if (e.key === "#") {
790
+ e.preventDefault();
791
+ const savedRange = this.saveRange();
792
+ this.callbacks.onStartReferencePick(
793
+ (fp) => this.insertChip(fp, savedRange),
794
+ () => {
795
+ editor.focus();
796
+ this.restoreRange(savedRange);
797
+ }
798
+ );
799
+ return;
800
+ }
801
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
802
+ e.preventDefault();
803
+ this.save();
804
+ return;
805
+ }
806
+ if (e.key === "Escape") {
807
+ e.preventDefault();
808
+ this.callbacks.onClose();
809
+ }
810
+ };
811
+ this.handleEditorPaste = (e) => {
812
+ if (this.callbacks.canUploadAttachments()) {
813
+ const items = e.clipboardData?.items ?? null;
814
+ if (items) {
815
+ const imageFiles = [];
816
+ for (let i = 0; i < items.length; i++) {
817
+ const it = items[i];
818
+ if (it.kind === "file" && it.type.startsWith("image/")) {
819
+ const f = it.getAsFile();
820
+ if (f) imageFiles.push(f);
821
+ }
822
+ }
823
+ if (imageFiles.length > 0) {
824
+ e.preventDefault();
825
+ for (const f of imageFiles) void this.uploadAndAppend(f);
826
+ return;
827
+ }
828
+ }
829
+ }
830
+ e.preventDefault();
831
+ const text = e.clipboardData?.getData("text/plain") ?? "";
832
+ const sel = window.getSelection();
833
+ if (sel && sel.rangeCount > 0) {
834
+ const r = sel.getRangeAt(0);
835
+ r.deleteContents();
836
+ r.insertNode(document.createTextNode(text));
837
+ r.collapse(false);
838
+ sel.removeAllRanges();
839
+ sel.addRange(r);
840
+ }
841
+ };
842
+ /* ─── Attachment handlers ────────────────────────────────────────── */
843
+ this.handleDragOver = (e) => {
844
+ if (!e.dataTransfer || !Array.from(e.dataTransfer.types).includes("Files")) return;
845
+ e.preventDefault();
846
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
847
+ hint?.removeAttribute("hidden");
848
+ };
849
+ this.handleDragLeave = (e) => {
850
+ const wrap = e.currentTarget;
851
+ if (e.relatedTarget instanceof Node && wrap.contains(e.relatedTarget)) return;
852
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
853
+ hint?.setAttribute("hidden", "");
854
+ };
855
+ this.handleDrop = (e) => {
856
+ e.preventDefault();
857
+ const hint = this.el.querySelector('[data-vz="drop-hint"]');
858
+ hint?.setAttribute("hidden", "");
859
+ if (!e.dataTransfer) return;
860
+ const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
861
+ for (const f of files) void this.uploadAndAppend(f);
862
+ };
863
+ this.handleFileChange = (e) => {
864
+ const input = e.currentTarget;
865
+ const files = input.files ? Array.from(input.files) : [];
866
+ for (const f of files) void this.uploadAndAppend(f);
867
+ input.value = "";
868
+ };
869
+ this.handleClick = (e) => {
870
+ const target = e.target;
871
+ const removeBtn = target.closest('[data-vz="remove-anchor"]');
872
+ if (removeBtn) {
873
+ e.stopPropagation();
874
+ const key2 = removeBtn.getAttribute("data-fp-key");
875
+ if (key2) this.removeAnchor(key2);
876
+ return;
877
+ }
878
+ const refEl = target.closest('[data-vz="ref"]');
879
+ if (refEl) {
880
+ const commentId = refEl.getAttribute("data-comment-id");
881
+ const refIdAttr = refEl.getAttribute("data-ref-id");
882
+ if (commentId && refIdAttr) {
883
+ e.stopPropagation();
884
+ this.callbacks.onJumpToReference(commentId, refIdAttr);
885
+ }
886
+ return;
887
+ }
888
+ const action = target.getAttribute("data-vz");
889
+ if (action === "close") this.callbacks.onClose();
890
+ else if (action === "save") this.save();
891
+ else if (action === "mention") {
892
+ e.preventDefault();
893
+ this.insertMentionChip(this.currentUser);
894
+ } else if (action === "upload") {
895
+ e.preventDefault();
896
+ const input = this.el.querySelector('[data-vz="file-input"]');
897
+ input?.click();
898
+ } else if (action === "remove-attachment") {
899
+ e.preventDefault();
900
+ const id = target.getAttribute("data-attachment-id");
901
+ if (id) {
902
+ this.pendingAttachments = this.pendingAttachments.filter((a) => a.id !== id);
903
+ this.renderAttachmentList();
904
+ }
905
+ } else if (action === "delete") {
906
+ const id = target.getAttribute("data-id");
907
+ if (id) this.callbacks.onDelete(id);
908
+ } else if (action === "toggle-reply") {
909
+ e.preventDefault();
910
+ const id = target.getAttribute("data-id");
911
+ if (!id) return;
912
+ const block = this.el.querySelector(`.vz-replies[data-comment-id="${cssEscape(id)}"]`);
913
+ if (!block) return;
914
+ const isHidden = block.hasAttribute("hidden");
915
+ if (isHidden) {
916
+ block.removeAttribute("hidden");
917
+ const input = block.querySelector(".vz-reply-input");
918
+ input?.focus();
919
+ } else {
920
+ block.setAttribute("hidden", "");
921
+ }
922
+ } else if (action === "send-reply") {
923
+ e.preventDefault();
924
+ const id = target.getAttribute("data-id");
925
+ if (!id) return;
926
+ const input = this.el.querySelector(
927
+ `.vz-reply-input[data-comment-id="${cssEscape(id)}"]`
928
+ );
929
+ const text = input?.value.trim() ?? "";
930
+ if (!text) return;
931
+ this.callbacks.onAddReply(id, text);
932
+ if (input) input.value = "";
933
+ } else if (action === "delete-reply") {
934
+ e.preventDefault();
935
+ const commentId = target.getAttribute("data-comment-id");
936
+ const replyId = target.getAttribute("data-reply-id");
937
+ if (commentId && replyId) this.callbacks.onDeleteReply(commentId, replyId);
938
+ }
939
+ };
940
+ this.root = root;
941
+ this.callbacks = callbacks;
942
+ this.el = document.createElement("div");
943
+ this.el.className = "vz-popover";
944
+ this.el.style.display = "none";
945
+ this.el.addEventListener("click", this.handleClick);
946
+ this.root.appendChild(this.el);
947
+ }
948
+ setUser(user) {
949
+ this.currentUser = user;
950
+ }
951
+ /**
952
+ * Open the popover anchored to one OR more elements. The popover positions
953
+ * itself relative to `targets[0]` but commits with every fingerprint in `fingerprints`.
954
+ */
955
+ open(targets, fingerprints, existingComments) {
956
+ this.anchorTargets = [...targets];
957
+ this.anchorFingerprints = [...fingerprints];
958
+ this.pendingRefs = {};
959
+ this.render(existingComments);
960
+ this.position();
961
+ }
962
+ /** Refresh the rendered comment list (called by host when comments change). */
963
+ update(existingComments) {
964
+ if (!this.anchorTargets.length) return;
965
+ this.render(existingComments);
966
+ this.position();
967
+ }
968
+ /** Add another anchor to the in-progress comment (Shift+Click flow). */
969
+ addAnchor(fp, target) {
970
+ const key2 = fingerprintKey(fp);
971
+ if (this.anchorFingerprints.some((x) => fingerprintKey(x) === key2)) return false;
972
+ this.anchorFingerprints.push(fp);
973
+ if (target) this.anchorTargets.push(target);
974
+ this.callbacks.onAnchorsChanged([...this.anchorFingerprints]);
975
+ this.renderAnchorList();
976
+ this.updateHeader();
977
+ return true;
978
+ }
979
+ /** Remove an anchor by fingerprint key. Never removes the last anchor. */
980
+ removeAnchor(key2) {
981
+ if (this.anchorFingerprints.length <= 1) return false;
982
+ const idx = this.anchorFingerprints.findIndex((x) => fingerprintKey(x) === key2);
983
+ if (idx < 0) return false;
984
+ this.anchorFingerprints.splice(idx, 1);
985
+ this.anchorTargets.splice(idx, 1);
986
+ this.callbacks.onAnchorsChanged([...this.anchorFingerprints]);
987
+ this.renderAnchorList();
988
+ this.updateHeader();
989
+ return true;
990
+ }
991
+ updateHeader() {
992
+ const header = this.el.querySelector(".vz-popover-header span:first-child");
993
+ if (!header) return;
994
+ header.textContent = this.anchorFingerprints.length > 1 ? `Comment on ${this.anchorFingerprints.length} elements` : "Comment";
995
+ }
996
+ /** Recompute viewport position; called by host on scroll/resize. */
997
+ reposition() {
998
+ if (!this.anchorTargets.length) return;
999
+ this.position();
1000
+ }
1001
+ close() {
1002
+ this.el.style.display = "none";
1003
+ this.anchorTargets = [];
1004
+ this.anchorFingerprints = [];
1005
+ this.pendingRefs = {};
1006
+ }
1007
+ isOpen() {
1008
+ return this.anchorTargets.length > 0;
1009
+ }
1010
+ getAnchors() {
1011
+ return [...this.anchorFingerprints];
1012
+ }
1013
+ getAnchorTargets() {
1014
+ return [...this.anchorTargets];
1015
+ }
1016
+ render(comments) {
1017
+ if (!this.anchorTargets.length) return;
1018
+ const primary = this.anchorTargets[0];
1019
+ const cls = Array.from(primary.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
1020
+ const label = primary.tagName.toLowerCase() + (primary.id ? "#" + primary.id : "") + (cls.length ? "." + cls.join(".") : "");
1021
+ const text = (primary.innerText || "").trim();
1022
+ const textPreview = text.slice(0, 60);
1023
+ const authoringLine = this.currentUser ? `<div class="vz-comment-author">${avatarHtml(this.currentUser)} <span class="vz-comment-author-name">${escapeHtml(this.currentUser.name)}</span> <span>\xB7 you</span></div>` : "";
1024
+ this.el.innerHTML = `
1025
+ <div class="vz-popover-header">
1026
+ <span>${this.anchorFingerprints.length > 1 ? `Comment on ${this.anchorFingerprints.length} elements` : "Comment"}</span>
1027
+ <button class="vz-popover-close" data-vz="close" aria-label="Close">\xD7</button>
1028
+ </div>
1029
+ <div class="vz-target-label">${escapeHtml(label)}${textPreview ? " \xB7 &ldquo;" + escapeHtml(textPreview) + (text.length > 60 ? "\u2026" : "") + "&rdquo;" : ""}</div>
1030
+ <div class="vz-anchor-list-wrap"></div>
1031
+ <div class="vz-comment-list">
1032
+ ${comments.length === 0 ? '<div class="vz-empty">No comments yet.</div>' : comments.map(
1033
+ (c) => `
1034
+ <div class="vz-comment" data-comment-id="${escapeHtml(c.id)}">
1035
+ ${c.author ? `<div class="vz-comment-author">${avatarHtml(c.author)} <span class="vz-comment-author-name">${escapeHtml(c.author.name)}</span></div>` : ""}
1036
+ <div>${renderCommentText(c.text, c.id)}</div>
1037
+ ${renderAttachmentsHtml(c.attachments)}
1038
+ <div class="vz-comment-meta">
1039
+ <span>${escapeHtml(formatTime(c.createdAt))}</span>
1040
+ <span class="vz-comment-actions">
1041
+ <button class="vz-comment-reply-toggle" data-vz="toggle-reply" data-id="${escapeHtml(c.id)}">${(c.replies?.length ?? 0) > 0 ? `${c.replies.length} ${c.replies.length === 1 ? "reply" : "replies"}` : "reply"}</button>
1042
+ <button class="vz-comment-delete" data-vz="delete" data-id="${escapeHtml(c.id)}">delete</button>
1043
+ </span>
1044
+ </div>
1045
+ ${renderRepliesHtml(c)}
1046
+ </div>
1047
+ `
1048
+ ).join("")}
1049
+ </div>
1050
+ ${authoringLine}
1051
+ <div class="vz-editor-wrap">
1052
+ <div class="vz-textarea" contenteditable="true" data-placeholder="Leave a comment\u2026 # to reference. \u2318/Ctrl+Enter to save. Shift+Click another element to anchor to it too."></div>
1053
+ <div class="vz-attachment-drop-hint" data-vz="drop-hint" hidden>Drop image to attach</div>
1054
+ </div>
1055
+ <div class="vz-attachment-list" data-vz="attachment-list"></div>
1056
+ <div class="vz-popover-actions">
1057
+ <button class="vz-btn vz-btn-ghost" data-vz="close">Close</button>
1058
+ ${this.callbacks.canUploadAttachments() ? `<button class="vz-btn vz-btn-ghost vz-btn-upload" data-vz="upload" title="Attach an image">+ image</button>` : ""}
1059
+ ${this.currentUser ? `<button class="vz-btn vz-btn-ghost vz-btn-mention" data-vz="mention" title="Mention ${escapeHtml(this.currentUser.name)}">@ mention</button>` : ""}
1060
+ <button class="vz-btn vz-btn-primary" data-vz="save">Save</button>
1061
+ </div>
1062
+ <input type="file" class="vz-file-input" data-vz="file-input" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" hidden />
1063
+ `;
1064
+ this.el.style.display = "flex";
1065
+ this.renderAnchorList();
1066
+ this.renderAttachmentList();
1067
+ const editor = this.el.querySelector(".vz-textarea");
1068
+ editor.focus();
1069
+ editor.addEventListener("keydown", this.handleEditorKey);
1070
+ editor.addEventListener("paste", this.handleEditorPaste);
1071
+ const wrap = this.el.querySelector(".vz-editor-wrap");
1072
+ if (wrap && this.callbacks.canUploadAttachments()) {
1073
+ wrap.addEventListener("dragover", this.handleDragOver);
1074
+ wrap.addEventListener("dragleave", this.handleDragLeave);
1075
+ wrap.addEventListener("drop", this.handleDrop);
1076
+ }
1077
+ const fileInput = this.el.querySelector('[data-vz="file-input"]');
1078
+ if (fileInput) {
1079
+ fileInput.addEventListener("change", this.handleFileChange);
1080
+ }
1081
+ }
1082
+ renderAnchorList() {
1083
+ const wrap = this.el.querySelector(".vz-anchor-list-wrap");
1084
+ if (!wrap) return;
1085
+ if (this.anchorFingerprints.length <= 1) {
1086
+ wrap.innerHTML = "";
1087
+ return;
1088
+ }
1089
+ const chips = this.anchorFingerprints.map((fp) => {
1090
+ const key2 = fingerprintKey(fp);
1091
+ const label = fingerprintLabel(fp);
1092
+ return `<span class="vz-anchor-chip" data-fp-key="${escapeHtml(key2)}">${escapeHtml(label)}<button class="vz-anchor-chip-remove" data-vz="remove-anchor" data-fp-key="${escapeHtml(key2)}" aria-label="Remove anchor">\xD7</button></span>`;
1093
+ }).join("");
1094
+ wrap.innerHTML = `
1095
+ <div class="vz-anchor-hint">Anchored to ${this.anchorFingerprints.length} elements:</div>
1096
+ <div class="vz-anchor-list">${chips}</div>
1097
+ `;
1098
+ }
1099
+ async uploadAndAppend(file) {
1100
+ if (!this.callbacks.canUploadAttachments()) return;
1101
+ const placeholderId = "upload-" + Math.random().toString(36).slice(2, 9);
1102
+ const placeholder = {
1103
+ id: placeholderId,
1104
+ url: "",
1105
+ mimeType: file.type || "application/octet-stream",
1106
+ sizeBytes: file.size,
1107
+ uploadedAt: Date.now(),
1108
+ filename: file.name
1109
+ };
1110
+ this.pendingAttachments.push(placeholder);
1111
+ this.renderAttachmentList(placeholderId);
1112
+ try {
1113
+ const att = await this.callbacks.onUploadAttachment(file);
1114
+ const idx = this.pendingAttachments.findIndex((a) => a.id === placeholderId);
1115
+ if (idx >= 0) this.pendingAttachments[idx] = att;
1116
+ else this.pendingAttachments.push(att);
1117
+ this.renderAttachmentList();
1118
+ } catch (err) {
1119
+ this.pendingAttachments = this.pendingAttachments.filter((a) => a.id !== placeholderId);
1120
+ this.renderAttachmentList();
1121
+ if (typeof console !== "undefined") {
1122
+ console.warn("[vizu] attachment upload failed", err);
1123
+ }
1124
+ }
1125
+ }
1126
+ renderAttachmentList(uploadingId) {
1127
+ const list = this.el.querySelector('[data-vz="attachment-list"]');
1128
+ if (!list) return;
1129
+ if (this.pendingAttachments.length === 0) {
1130
+ list.innerHTML = "";
1131
+ return;
1132
+ }
1133
+ list.innerHTML = this.pendingAttachments.map((a) => {
1134
+ const isUploading = a.id === uploadingId || a.url === "";
1135
+ const preview = isUploading ? `<div class="vz-attachment-thumb vz-attachment-thumb-loading">\u2026</div>` : a.mimeType.startsWith("image/") ? `<img class="vz-attachment-thumb" src="${escapeHtml(a.url)}" alt="${escapeHtml(a.filename ?? "")}" />` : `<div class="vz-attachment-thumb">\u{1F4C4}</div>`;
1136
+ return `
1137
+ <div class="vz-attachment-item" data-attachment-id="${escapeHtml(a.id)}">
1138
+ ${preview}
1139
+ <button class="vz-attachment-remove" data-vz="remove-attachment" data-attachment-id="${escapeHtml(a.id)}" aria-label="Remove attachment">\xD7</button>
1140
+ </div>
1141
+ `;
1142
+ }).join("");
1143
+ }
1144
+ saveRange() {
1145
+ const sel = window.getSelection();
1146
+ if (!sel || sel.rangeCount === 0) return null;
1147
+ return sel.getRangeAt(0).cloneRange();
1148
+ }
1149
+ restoreRange(r) {
1150
+ if (!r) return;
1151
+ const sel = window.getSelection();
1152
+ sel?.removeAllRanges();
1153
+ sel?.addRange(r);
1154
+ }
1155
+ insertChip(fp, range) {
1156
+ const rid = refId();
1157
+ this.pendingRefs[rid] = fp;
1158
+ const label = fingerprintLabel(fp);
1159
+ const chip = document.createElement("span");
1160
+ chip.className = "vz-chip";
1161
+ chip.contentEditable = "false";
1162
+ chip.setAttribute("data-ref-id", rid);
1163
+ chip.textContent = label;
1164
+ const editor = this.el.querySelector(".vz-textarea");
1165
+ editor.focus();
1166
+ this.restoreRange(range);
1167
+ const sel = window.getSelection();
1168
+ if (!sel || sel.rangeCount === 0) {
1169
+ editor.appendChild(chip);
1170
+ editor.appendChild(document.createTextNode(" "));
1171
+ this.moveCursorToEnd(editor);
1172
+ return;
1173
+ }
1174
+ const r = sel.getRangeAt(0);
1175
+ r.deleteContents();
1176
+ r.insertNode(chip);
1177
+ const space = document.createTextNode(" ");
1178
+ chip.after(space);
1179
+ const newRange = document.createRange();
1180
+ newRange.setStartAfter(space);
1181
+ newRange.collapse(true);
1182
+ sel.removeAllRanges();
1183
+ sel.addRange(newRange);
1184
+ }
1185
+ moveCursorToEnd(el) {
1186
+ const range = document.createRange();
1187
+ range.selectNodeContents(el);
1188
+ range.collapse(false);
1189
+ const sel = window.getSelection();
1190
+ sel?.removeAllRanges();
1191
+ sel?.addRange(range);
1192
+ }
1193
+ /** Walk the editor and produce the markdown-ish string with ref tokens. */
1194
+ serializeEditor(editor) {
1195
+ const parts = [];
1196
+ const walk = (node) => {
1197
+ if (node.nodeType === Node.TEXT_NODE) {
1198
+ parts.push(node.textContent || "");
1199
+ return;
1200
+ }
1201
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
1202
+ const el = node;
1203
+ if (el.classList.contains("vz-chip")) {
1204
+ const mid = el.getAttribute("data-mention-id");
1205
+ if (mid) {
1206
+ const raw = el.textContent || "";
1207
+ const name = raw.startsWith("@") ? raw.slice(1) : raw;
1208
+ parts.push(`[@${name}](vizu-mention:${mid})`);
1209
+ return;
1210
+ }
1211
+ const rid = el.getAttribute("data-ref-id");
1212
+ const label = el.textContent || "";
1213
+ if (rid) parts.push(`[#${label}](vizu-ref:${rid})`);
1214
+ return;
1215
+ }
1216
+ if (el.tagName === "BR") {
1217
+ parts.push("\n");
1218
+ return;
1219
+ }
1220
+ if (el.tagName === "DIV" && parts.length && !parts[parts.length - 1].endsWith("\n")) {
1221
+ parts.push("\n");
1222
+ }
1223
+ for (const child of el.childNodes) walk(child);
1224
+ };
1225
+ for (const child of editor.childNodes) walk(child);
1226
+ return parts.join("").trim();
1227
+ }
1228
+ /**
1229
+ * Insert a mention chip for the given user into the editor at the
1230
+ * current caret position. Owner-only ACL in v1: the only callable
1231
+ * "mentionable" user is the current Vizu user (= workspace owner).
1232
+ * Future versions can expand this with an autocomplete dropdown.
1233
+ */
1234
+ insertMentionChip(user) {
1235
+ if (!user || !user.id) return;
1236
+ const editor = this.el.querySelector(".vz-textarea");
1237
+ if (!editor) return;
1238
+ const chip = document.createElement("span");
1239
+ chip.className = "vz-chip vz-chip-mention";
1240
+ chip.contentEditable = "false";
1241
+ chip.setAttribute("data-mention-id", user.id);
1242
+ chip.textContent = "@" + user.name;
1243
+ editor.focus();
1244
+ const sel = window.getSelection();
1245
+ if (!sel || sel.rangeCount === 0) {
1246
+ editor.appendChild(chip);
1247
+ editor.appendChild(document.createTextNode(" "));
1248
+ this.moveCursorToEnd(editor);
1249
+ return;
1250
+ }
1251
+ const r = sel.getRangeAt(0);
1252
+ r.deleteContents();
1253
+ r.insertNode(chip);
1254
+ const space = document.createTextNode(" ");
1255
+ chip.after(space);
1256
+ const newRange = document.createRange();
1257
+ newRange.setStartAfter(space);
1258
+ newRange.collapse(true);
1259
+ sel.removeAllRanges();
1260
+ sel.addRange(newRange);
1261
+ }
1262
+ save() {
1263
+ const editor = this.el.querySelector(".vz-textarea");
1264
+ if (!editor) return;
1265
+ const text = this.serializeEditor(editor);
1266
+ const finishedCount = this.pendingAttachments.filter((a) => a.url).length;
1267
+ if (!text && finishedCount === 0) return;
1268
+ const usedRefs = {};
1269
+ const re = /\[#[^\]]+\]\(vizu-ref:([a-zA-Z0-9_-]+)\)/g;
1270
+ let m;
1271
+ while ((m = re.exec(text)) !== null) {
1272
+ const rid = m[1];
1273
+ if (this.pendingRefs[rid]) usedRefs[rid] = this.pendingRefs[rid];
1274
+ }
1275
+ const mentions = extractMentions(text);
1276
+ const finishedAttachments = this.pendingAttachments.filter((a) => a.url);
1277
+ this.callbacks.onAdd(
1278
+ text,
1279
+ [...this.anchorFingerprints],
1280
+ Object.keys(usedRefs).length ? usedRefs : void 0,
1281
+ mentions.length ? mentions : void 0,
1282
+ finishedAttachments.length ? finishedAttachments : void 0
1283
+ );
1284
+ editor.innerHTML = "";
1285
+ this.pendingRefs = {};
1286
+ this.pendingAttachments = [];
1287
+ this.renderAttachmentList();
1288
+ }
1289
+ position() {
1290
+ const primary = this.anchorTargets[0];
1291
+ if (!primary) return;
1292
+ const rect = primary.getBoundingClientRect();
1293
+ const popRect = this.el.getBoundingClientRect();
1294
+ const pad = 8;
1295
+ let top = rect.bottom + pad;
1296
+ let left = rect.left;
1297
+ if (top + popRect.height > window.innerHeight - pad) {
1298
+ top = rect.top - popRect.height - pad;
1299
+ if (top < pad) top = pad;
1300
+ }
1301
+ if (left + popRect.width > window.innerWidth - pad) {
1302
+ left = window.innerWidth - popRect.width - pad;
1303
+ }
1304
+ if (left < pad) left = pad;
1305
+ this.el.style.top = top + "px";
1306
+ this.el.style.left = left + "px";
1307
+ }
1308
+ destroy() {
1309
+ this.el.removeEventListener("click", this.handleClick);
1310
+ this.el.remove();
1311
+ }
1312
+ };
1313
+
1314
+ // src/pill.ts
1315
+ var Pill = class {
1316
+ constructor(root, callbacks) {
1317
+ this.sidebarOpen = false;
1318
+ this.actions = [];
1319
+ this.commentsCount = 0;
1320
+ this.user = null;
1321
+ this.multiSelectMode = false;
1322
+ this.selectionCount = 0;
1323
+ this.handleClick = (e) => {
1324
+ const btn = e.target.closest("[data-vz]");
1325
+ if (!btn) return;
1326
+ const a = btn.getAttribute("data-vz");
1327
+ if (a === "sidebar") this.callbacks.onToggleSidebar();
1328
+ else if (a === "disable") this.callbacks.onDisable();
1329
+ else if (a === "multi") this.callbacks.onToggleMultiSelect();
1330
+ else if (a === "commit-selection") this.callbacks.onCommitSelection();
1331
+ else if (a === "clear-selection") this.callbacks.onClearSelection();
1332
+ else if (a === "action") {
1333
+ const id = btn.getAttribute("data-id");
1334
+ if (id) this.callbacks.onAction(id);
1335
+ }
1336
+ };
1337
+ this.root = root;
1338
+ this.callbacks = callbacks;
1339
+ this.el = document.createElement("div");
1340
+ this.el.className = "vz-pill";
1341
+ this.el.style.display = "none";
1342
+ this.el.addEventListener("click", this.handleClick);
1343
+ this.root.appendChild(this.el);
1344
+ }
1345
+ setActions(actions) {
1346
+ this.actions = actions;
1347
+ this.render();
1348
+ }
1349
+ setComments(comments) {
1350
+ this.commentsCount = comments.length;
1351
+ this.render();
1352
+ }
1353
+ setUser(user) {
1354
+ this.user = user;
1355
+ this.render();
1356
+ }
1357
+ setSidebarOpen(open) {
1358
+ this.sidebarOpen = open;
1359
+ const btn = this.el.querySelector('[data-vz="sidebar"]');
1360
+ if (btn) btn.classList.toggle("is-active", open);
1361
+ }
1362
+ setMultiSelectState(mode, selectionCount) {
1363
+ this.multiSelectMode = mode;
1364
+ this.selectionCount = selectionCount;
1365
+ this.el.classList.toggle("is-selecting", mode && selectionCount > 0);
1366
+ this.render();
1367
+ }
1368
+ show() {
1369
+ this.el.style.display = "flex";
1370
+ this.render();
1371
+ }
1372
+ hide() {
1373
+ this.el.style.display = "none";
1374
+ }
1375
+ render() {
1376
+ if (this.el.style.display === "none") return;
1377
+ const visibleActions = this.actions.filter(
1378
+ (a) => a.visibleWhen ? a.visibleWhen({ commentsCount: this.commentsCount }) : true
1379
+ );
1380
+ const userHtml = this.user ? `<div class="vz-pill-user" title="${escapeHtml(this.user.name)}">${avatarHtml(this.user, "vz-pill-user-avatar")} ${escapeHtml(this.user.name.split(" ")[0])}</div><span class="vz-pill-divider"></span>` : "";
1381
+ const actionsHtml = visibleActions.length ? visibleActions.map(
1382
+ (a) => `<button class="vz-pill-btn${a.variant === "primary" ? " primary" : ""}" data-vz="action" data-id="${escapeHtml(a.id)}"${a.title ? ` title="${escapeHtml(a.title)}"` : ""}>${escapeHtml(a.label)}</button>`
1383
+ ).join("") + '<span class="vz-pill-divider"></span>' : "";
1384
+ let middle = "";
1385
+ if (this.multiSelectMode && this.selectionCount > 0) {
1386
+ middle = `
1387
+ <span class="vz-pill-count-btn is-active" title="Selected ${this.selectionCount} element${this.selectionCount === 1 ? "" : "s"}">${this.selectionCount} selected</span>
1388
+ <button class="vz-pill-btn primary" data-vz="commit-selection">Comment on ${this.selectionCount} \u2192</button>
1389
+ <button class="vz-pill-btn" data-vz="clear-selection">Cancel</button>
1390
+ <span class="vz-pill-divider"></span>
1391
+ `;
1392
+ } else {
1393
+ const count = this.commentsCount;
1394
+ middle = `
1395
+ <button class="vz-pill-count-btn ${this.sidebarOpen ? "is-active" : ""}" data-vz="sidebar" title="Show all comments">${count} comment${count === 1 ? "" : "s"}</button>
1396
+ <span class="vz-pill-divider"></span>
1397
+ ${userHtml}
1398
+ ${actionsHtml}
1399
+ `;
1400
+ }
1401
+ const toggleTitle = this.multiSelectMode ? "Exit multi-select mode" : "Multi-select: click multiple elements, then comment on the group (or hold Shift while clicking)";
1402
+ this.el.innerHTML = `
1403
+ <span class="vz-pill-dot" aria-hidden="true"></span>
1404
+ <span class="vz-pill-label">Vizu</span>
1405
+ ${middle}
1406
+ <button class="vz-pill-toggle ${this.multiSelectMode ? "is-active" : ""}" data-vz="multi" title="${escapeHtml(toggleTitle)}" aria-label="${escapeHtml(toggleTitle)}">
1407
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="1" y="1" width="5" height="5"/><rect x="8" y="1" width="5" height="5"/><rect x="1" y="8" width="5" height="5"/><rect x="8" y="8" width="5" height="5"/></svg>
1408
+ </button>
1409
+ <button class="vz-pill-icon-btn" data-vz="disable" title="Disable Vizu (toggle with shortcut)" aria-label="Disable">\xD7</button>
1410
+ `;
1411
+ }
1412
+ toast(message) {
1413
+ const t = document.createElement("div");
1414
+ t.className = "vz-toast";
1415
+ t.textContent = message;
1416
+ this.root.appendChild(t);
1417
+ setTimeout(() => t.remove(), 1800);
1418
+ }
1419
+ destroy() {
1420
+ this.el.removeEventListener("click", this.handleClick);
1421
+ this.el.remove();
1422
+ }
1423
+ };
1424
+
1425
+ // src/sidebar.ts
1426
+ var Sidebar = class {
1427
+ constructor(root, callbacks) {
1428
+ this.open = false;
1429
+ this.filter = "open";
1430
+ this.lastComments = [];
1431
+ this.lastConfidence = /* @__PURE__ */ new Map();
1432
+ this.handleClick = (e) => {
1433
+ const target = e.target;
1434
+ const refEl = target.closest('[data-vz="ref"]');
1435
+ if (refEl) {
1436
+ e.stopPropagation();
1437
+ const commentId = refEl.getAttribute("data-comment-id");
1438
+ const refIdAttr = refEl.getAttribute("data-ref-id");
1439
+ if (commentId && refIdAttr) this.callbacks.onJumpToReference(commentId, refIdAttr);
1440
+ return;
1441
+ }
1442
+ const filterEl = target.closest('[data-vz="filter"]');
1443
+ if (filterEl) {
1444
+ e.stopPropagation();
1445
+ const f = filterEl.getAttribute("data-filter");
1446
+ if (f && f !== this.filter) {
1447
+ this.filter = f;
1448
+ this.render(this.lastComments);
1449
+ }
1450
+ return;
1451
+ }
1452
+ const statusEl = target.closest('[data-vz="status"]');
1453
+ if (statusEl) {
1454
+ e.stopPropagation();
1455
+ const id = statusEl.getAttribute("data-id");
1456
+ const status = statusEl.getAttribute("data-status");
1457
+ if (id && status) this.callbacks.onUpdateStatus(id, status);
1458
+ return;
1459
+ }
1460
+ const deleteEl = target.closest('[data-vz="delete"]');
1461
+ if (deleteEl) {
1462
+ e.stopPropagation();
1463
+ const id = deleteEl.getAttribute("data-id");
1464
+ if (id) this.callbacks.onDelete(id);
1465
+ return;
1466
+ }
1467
+ if (target.closest('[data-vz="close"]')) {
1468
+ this.callbacks.onClose();
1469
+ return;
1470
+ }
1471
+ const fpChip = target.closest('[data-vz="jump-fp"]');
1472
+ if (fpChip && fpChip.__fp) {
1473
+ e.stopPropagation();
1474
+ this.callbacks.onJumpTo(fpChip.__fp);
1475
+ return;
1476
+ }
1477
+ const item = target.closest('[data-vz="jump"]');
1478
+ if (item && item.__group) this.callbacks.onJumpTo(item.__group.fp);
1479
+ };
1480
+ this.root = root;
1481
+ this.callbacks = callbacks;
1482
+ this.el = document.createElement("div");
1483
+ this.el.className = "vz-sidebar";
1484
+ this.el.addEventListener("click", this.handleClick);
1485
+ this.root.appendChild(this.el);
1486
+ }
1487
+ toggle(comments, confidence) {
1488
+ if (this.open) this.close();
1489
+ else this.show(comments, confidence);
1490
+ }
1491
+ show(comments, confidence) {
1492
+ this.render(comments, confidence);
1493
+ requestAnimationFrame(() => this.el.classList.add("is-open"));
1494
+ this.open = true;
1495
+ }
1496
+ update(comments, confidence) {
1497
+ if (this.open) this.render(comments, confidence);
1498
+ }
1499
+ close() {
1500
+ this.el.classList.remove("is-open");
1501
+ this.open = false;
1502
+ }
1503
+ isOpen() {
1504
+ return this.open;
1505
+ }
1506
+ render(comments, confidence) {
1507
+ this.lastComments = comments;
1508
+ if (confidence) this.lastConfidence = new Map(confidence);
1509
+ const conf = this.lastConfidence;
1510
+ const orphans = comments.filter((c) => conf.get(c.id) === "orphaned");
1511
+ const anchored = comments.filter((c) => conf.get(c.id) !== "orphaned");
1512
+ const visible = this.filter === "open" ? anchored.filter((c) => (c.status ?? "open") === "open") : anchored;
1513
+ const visibleOrphans = this.filter === "open" ? [] : orphans;
1514
+ const counts = countByStatus(comments);
1515
+ if (visible.length === 0 && visibleOrphans.length === 0) {
1516
+ const emptyCopy = this.filter === "open" && comments.length > 0 ? `<strong>Nothing open.</strong>${counts.resolved + counts.wontfix} closed comment${counts.resolved + counts.wontfix === 1 ? "" : "s"} hidden. Switch to <em>All</em> to see them.` : "<strong>No comments yet</strong>Click any element on the page to leave one.";
1517
+ this.el.innerHTML = `
1518
+ ${this.headerHtml(comments.length, counts)}
1519
+ <div class="vz-sidebar-body">
1520
+ <div class="vz-sidebar-empty">${emptyCopy}</div>
1521
+ </div>
1522
+ `;
1523
+ return;
1524
+ }
1525
+ const groups = /* @__PURE__ */ new Map();
1526
+ for (const c of visible) {
1527
+ const fps = c.fingerprints || [];
1528
+ const primary = fps[0];
1529
+ if (!primary) continue;
1530
+ const key2 = fingerprintKey(primary);
1531
+ const g = groups.get(key2);
1532
+ if (g) g.comments.push(c);
1533
+ else groups.set(key2, { fp: primary, comments: [c] });
1534
+ }
1535
+ const groupHtml = [];
1536
+ let gi = 0;
1537
+ for (const g of groups.values()) {
1538
+ const elLabel = g.fp.tagName.toLowerCase() + (g.fp.attributes.id ? "#" + g.fp.attributes.id : "");
1539
+ const textPreview = g.fp.textSnippet.slice(0, 60);
1540
+ groupHtml.push(`
1541
+ <div class="vz-sidebar-group" data-group="${gi}">
1542
+ <div class="vz-sidebar-group-head">
1543
+ ${escapeHtml(elLabel)}
1544
+ ${textPreview ? `<div class="vz-sidebar-group-text">&ldquo;${escapeHtml(textPreview)}${g.fp.textSnippet.length > 60 ? "\u2026" : ""}&rdquo;</div>` : ""}
1545
+ </div>
1546
+ ${g.comments.map((c) => {
1547
+ const status = c.status ?? "open";
1548
+ const others = (c.fingerprints || []).slice(1);
1549
+ const otherChips = others.length ? `<div class="vz-anchor-list" style="margin-top:6px">${others.map(
1550
+ (fp, i) => `<span class="vz-anchor-chip" data-vz="jump-fp" data-fp-key="${escapeHtml(fingerprintKey(fp))}" title="Jump to this element">+${i + 2}/${(c.fingerprints || []).length} ${escapeHtml(fingerprintLabel(fp))}</span>`
1551
+ ).join("")}</div>` : "";
1552
+ const cConf = conf.get(c.id);
1553
+ const driftedBadge = cConf === "drifted" ? `<span class="vz-status-pill vz-status-drifted" title="Anchored via a fallback match \u2014 the original element may have moved or changed.">drifted</span>` : "";
1554
+ return `
1555
+ <div class="vz-sidebar-item ${status !== "open" ? "vz-sidebar-item-dim" : ""}" data-comment-id="${escapeHtml(c.id)}" data-vz="jump" data-group="${gi}" role="button" tabindex="0">
1556
+ <div class="vz-sidebar-item-row">
1557
+ ${c.author ? `<div class="vz-sidebar-item-author">${avatarHtml(c.author)} <span class="vz-sidebar-item-author-name">${escapeHtml(c.author.name)}</span></div>` : ""}
1558
+ ${driftedBadge}
1559
+ ${status !== "open" ? `<span class="vz-status-pill vz-status-${status}">${status}</span>` : ""}
1560
+ </div>
1561
+ <span class="vz-sidebar-item-text">${renderCommentText(c.text, c.id)}</span>
1562
+ ${renderAttachmentsHtml(c.attachments)}
1563
+ ${otherChips}
1564
+ <div class="vz-sidebar-item-meta">
1565
+ <span>${escapeHtml(formatTime(c.createdAt))}${(c.fingerprints || []).length > 1 ? ` \xB7 grouped (${(c.fingerprints || []).length})` : ""}${(c.replies?.length ?? 0) > 0 ? ` \xB7 ${c.replies.length} ${c.replies.length === 1 ? "reply" : "replies"}` : ""}</span>
1566
+ <span class="vz-sidebar-item-actions">
1567
+ ${statusActionsHtml(c.id, status)}
1568
+ <button class="vz-sidebar-item-delete" data-vz="delete" data-id="${escapeHtml(c.id)}" title="Delete comment">delete</button>
1569
+ </span>
1570
+ </div>
1571
+ </div>
1572
+ `;
1573
+ }).join("")}
1574
+ </div>
1575
+ `);
1576
+ gi++;
1577
+ }
1578
+ const orphanHtml = visibleOrphans.length > 0 ? renderOrphansHtml(visibleOrphans) : "";
1579
+ this.el.innerHTML = `
1580
+ ${this.headerHtml(comments.length, counts)}
1581
+ <div class="vz-sidebar-body">
1582
+ ${groupHtml.join("")}
1583
+ ${orphanHtml}
1584
+ </div>
1585
+ `;
1586
+ const items = this.el.querySelectorAll('[data-vz="jump"]');
1587
+ const groupArr = [...groups.values()];
1588
+ for (const item of items) {
1589
+ const groupIndex = Number(item.dataset.group);
1590
+ item.__group = groupArr[groupIndex];
1591
+ }
1592
+ const chips = this.el.querySelectorAll('[data-vz="jump-fp"]');
1593
+ for (const chip of chips) {
1594
+ const key2 = chip.getAttribute("data-fp-key");
1595
+ const fp = findFingerprintByKey(visible, key2 || "");
1596
+ chip.__fp = fp;
1597
+ }
1598
+ }
1599
+ headerHtml(total, counts) {
1600
+ return `
1601
+ <div class="vz-sidebar-header">
1602
+ <div class="vz-sidebar-title">
1603
+ Comments
1604
+ <span class="vz-sidebar-title-count">${total}</span>
1605
+ </div>
1606
+ <button class="vz-sidebar-close" data-vz="close" aria-label="Close sidebar">\xD7</button>
1607
+ </div>
1608
+ <div class="vz-sidebar-filters" role="tablist" aria-label="Status filter">
1609
+ <button class="vz-sidebar-filter ${this.filter === "open" ? "is-active" : ""}" data-vz="filter" data-filter="open" role="tab" aria-selected="${this.filter === "open"}">
1610
+ Open <span class="vz-sidebar-filter-count">${counts.open}</span>
1611
+ </button>
1612
+ <button class="vz-sidebar-filter ${this.filter === "all" ? "is-active" : ""}" data-vz="filter" data-filter="all" role="tab" aria-selected="${this.filter === "all"}">
1613
+ All <span class="vz-sidebar-filter-count">${total}</span>
1614
+ </button>
1615
+ </div>
1616
+ `;
1617
+ }
1618
+ destroy() {
1619
+ this.el.removeEventListener("click", this.handleClick);
1620
+ this.el.remove();
1621
+ }
1622
+ };
1623
+ function countByStatus(comments) {
1624
+ const c = { open: 0, resolved: 0, wontfix: 0 };
1625
+ for (const x of comments) {
1626
+ const s = x.status ?? "open";
1627
+ c[s] = (c[s] ?? 0) + 1;
1628
+ }
1629
+ return c;
1630
+ }
1631
+ function statusActionsHtml(id, current) {
1632
+ const safe = escapeHtml(id);
1633
+ if (current === "open") {
1634
+ return `
1635
+ <button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="resolved" title="Mark resolved">resolve</button>
1636
+ <button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="wontfix" title="Won't fix">won&apos;t fix</button>
1637
+ `;
1638
+ }
1639
+ return `<button class="vz-sidebar-item-status" data-vz="status" data-id="${safe}" data-status="open" title="Reopen">reopen</button>`;
1640
+ }
1641
+ function renderOrphansHtml(orphans) {
1642
+ const items = orphans.map((c) => {
1643
+ const status = c.status ?? "open";
1644
+ const primary = (c.fingerprints || [])[0];
1645
+ const label = primary ? primary.tagName.toLowerCase() : "comment";
1646
+ const snippet = primary?.textSnippet?.slice(0, 60) ?? "";
1647
+ return `
1648
+ <div class="vz-sidebar-item vz-sidebar-item-orphan" data-comment-id="${escapeHtml(c.id)}">
1649
+ <div class="vz-sidebar-item-row">
1650
+ ${c.author ? `<div class="vz-sidebar-item-author">${avatarHtml(c.author)} <span class="vz-sidebar-item-author-name">${escapeHtml(c.author.name)}</span></div>` : ""}
1651
+ <span class="vz-status-pill vz-status-orphan">orphaned</span>
1652
+ </div>
1653
+ <div class="vz-sidebar-orphan-anchor">
1654
+ was on <code>${escapeHtml(label)}</code>${snippet ? ` &ldquo;${escapeHtml(snippet)}${(primary?.textSnippet?.length ?? 0) > 60 ? "\u2026" : ""}&rdquo;` : ""}
1655
+ </div>
1656
+ <span class="vz-sidebar-item-text">${renderCommentText(c.text, c.id)}</span>
1657
+ ${renderAttachmentsHtml(c.attachments)}
1658
+ <div class="vz-sidebar-item-meta">
1659
+ <span>${escapeHtml(formatTime(c.createdAt))}${status !== "open" ? ` \xB7 ${status}` : ""}</span>
1660
+ <span class="vz-sidebar-item-actions">
1661
+ <button class="vz-sidebar-item-delete" data-vz="delete" data-id="${escapeHtml(c.id)}" title="Delete comment">delete</button>
1662
+ </span>
1663
+ </div>
1664
+ </div>
1665
+ `;
1666
+ }).join("");
1667
+ return `
1668
+ <div class="vz-sidebar-orphans">
1669
+ <div class="vz-sidebar-orphans-head">
1670
+ Orphaned \xB7 ${orphans.length}
1671
+ <span class="vz-sidebar-orphans-sub">couldn't find the original element on this page</span>
1672
+ </div>
1673
+ ${items}
1674
+ </div>
1675
+ `;
1676
+ }
1677
+ function findFingerprintByKey(comments, key2) {
1678
+ for (const c of comments) {
1679
+ for (const fp of c.fingerprints || []) {
1680
+ if (fingerprintKey(fp) === key2) return fp;
1681
+ }
1682
+ }
1683
+ return null;
1684
+ }
1685
+
1686
+ // src/styles.ts
1687
+ var STYLES = `
1688
+ [data-vizu-root] {
1689
+ position: fixed; top: 0; left: 0; width: 0; height: 0;
1690
+ z-index: 2147483647;
1691
+ pointer-events: none;
1692
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1693
+ font-size: 13px; line-height: 1.5; color: #fafafa;
1694
+ --vz-accent: #FF6647;
1695
+ --vz-bg: #0A0A0A;
1696
+ --vz-border: #2A2A2A;
1697
+ --vz-bg-hover: #1A1A1A;
1698
+ --vz-text-muted: #888;
1699
+ --vz-marker-fg: #FFFFFF;
1700
+ }
1701
+ [data-vizu-root] * { box-sizing: border-box; }
1702
+ /* Button reset uses :where() to drop its specificity to 0,0,1 so any single-class
1703
+ selector like .vz-marker / .vz-pill-btn / .vz-btn that sets background or
1704
+ color still wins, without needing to redeclare those everywhere. */
1705
+ :where([data-vizu-root]) button {
1706
+ font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0;
1707
+ }
1708
+
1709
+ /* Hover highlight (transient) */
1710
+ .vz-highlight {
1711
+ position: fixed;
1712
+ border: 2px dashed var(--vz-accent);
1713
+ background: color-mix(in srgb, var(--vz-accent) 8%, transparent);
1714
+ pointer-events: none;
1715
+ transition: all 90ms ease-out;
1716
+ border-radius: 2px;
1717
+ z-index: 1;
1718
+ }
1719
+
1720
+ /* Always-on outline for commented elements */
1721
+ .vz-comment-outline {
1722
+ position: fixed;
1723
+ border: 1px dashed color-mix(in srgb, var(--vz-accent) 55%, transparent);
1724
+ background: color-mix(in srgb, var(--vz-accent) 4%, transparent);
1725
+ pointer-events: none;
1726
+ border-radius: 2px;
1727
+ z-index: 1;
1728
+ }
1729
+ .vz-comment-outline.is-selected-pending {
1730
+ border: 1.5px dashed var(--vz-accent);
1731
+ background: color-mix(in srgb, var(--vz-accent) 10%, transparent);
1732
+ }
1733
+ /* Drifted: the anchor resolved only via a fuzzy fallback (sibling-index
1734
+ or page-wide text snippet). Amber border warns visitors the match
1735
+ might be wrong. */
1736
+ .vz-comment-outline-drifted {
1737
+ border-color: rgb(252, 211, 77);
1738
+ background: rgba(245, 158, 11, 0.06);
1739
+ }
1740
+ .vz-comment-outline.is-pulsing {
1741
+ animation: vz-pulse 1500ms ease-out;
1742
+ }
1743
+ @keyframes vz-pulse {
1744
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--vz-accent) 60%, transparent); border-color: var(--vz-accent); }
1745
+ 60% { box-shadow: 0 0 0 22px color-mix(in srgb, var(--vz-accent) 0%, transparent); border-color: var(--vz-accent); }
1746
+ 100% { box-shadow: 0 0 0 0 transparent; border-color: color-mix(in srgb, var(--vz-accent) 55%, transparent); }
1747
+ }
1748
+
1749
+ /* Strong "spotlight" rendered when the user jumps to an element (sidebar item,
1750
+ anchor chip, or # reference click). Visible even on elements that don't have
1751
+ a saved comment yet. */
1752
+ .vz-jump-spotlight {
1753
+ position: fixed;
1754
+ border: 2.5px solid var(--vz-accent);
1755
+ background: color-mix(in srgb, var(--vz-accent) 16%, transparent);
1756
+ pointer-events: none;
1757
+ border-radius: 4px;
1758
+ z-index: 4;
1759
+ animation: vz-spotlight 1700ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
1760
+ }
1761
+ @keyframes vz-spotlight {
1762
+ 0% { opacity: 0; box-shadow: 0 0 0 0 transparent; }
1763
+ 10% { opacity: 1; box-shadow: 0 0 0 6px color-mix(in srgb, var(--vz-accent) 55%, transparent); }
1764
+ 45% { opacity: 1; box-shadow: 0 0 0 28px color-mix(in srgb, var(--vz-accent) 10%, transparent); }
1765
+ 80% { opacity: 0.9; box-shadow: 0 0 0 36px color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1766
+ 100% { opacity: 0; box-shadow: 0 0 0 36px transparent; background: color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1767
+ }
1768
+
1769
+ /* Figma-style speech-bubble marker \u2014 pinned at top-right of element, tail pointing in */
1770
+ .vz-marker {
1771
+ position: fixed;
1772
+ min-width: 28px; height: 26px;
1773
+ padding: 0 9px;
1774
+ background: var(--vz-accent);
1775
+ color: var(--vz-marker-fg);
1776
+ font-size: 11px; font-weight: 700;
1777
+ letter-spacing: 0.01em;
1778
+ display: inline-flex; align-items: center; justify-content: center;
1779
+ cursor: pointer;
1780
+ pointer-events: auto;
1781
+ /* Asymmetric radius = the "pin" tail: sharp at bottom-left, rounded elsewhere */
1782
+ border-radius: 14px 14px 14px 3px;
1783
+ /* Solid Vizu-colored halo via 2px ring + drop shadow \u2014 visible on any backdrop */
1784
+ box-shadow:
1785
+ 0 4px 12px rgba(0,0,0,0.28),
1786
+ 0 0 0 2px var(--vz-bg);
1787
+ /* Center the marker on the element's top-right corner, half outside the element */
1788
+ transform: translate(-50%, -50%);
1789
+ transition: transform 120ms cubic-bezier(0.34, 1.56, 0.64, 1);
1790
+ z-index: 2;
1791
+ }
1792
+ .vz-marker.is-group-member { font-size: 10px; padding: 0 7px; }
1793
+ .vz-marker.is-sibling-pulsing { animation: vz-marker-pulse 600ms ease-out; }
1794
+
1795
+ /* Drifted marker: amber-tinted fill + dashed ring instead of solid halo
1796
+ so visitors can see at a glance "this might not be the right element". */
1797
+ .vz-marker-drifted {
1798
+ background: rgb(252, 211, 77);
1799
+ color: rgba(0, 0, 0, 0.78);
1800
+ box-shadow:
1801
+ 0 4px 12px rgba(0,0,0,0.28),
1802
+ 0 0 0 2px var(--vz-bg),
1803
+ 0 0 0 3px rgb(252, 211, 77);
1804
+ }
1805
+ @keyframes vz-marker-pulse {
1806
+ 0% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 0 var(--vz-accent); }
1807
+ 60% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 10px color-mix(in srgb, var(--vz-accent) 0%, transparent); }
1808
+ 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.28), 0 0 0 2px var(--vz-bg), 0 0 0 0 transparent; }
1809
+ }
1810
+ .vz-marker:hover {
1811
+ transform: translate(-50%, -50%) scale(1.1);
1812
+ }
1813
+ .vz-marker-avatar {
1814
+ width: 100%; height: 100%;
1815
+ border-radius: 50% 50% 50% 3px;
1816
+ object-fit: cover;
1817
+ display: block;
1818
+ }
1819
+ .vz-marker.has-avatar {
1820
+ padding: 0;
1821
+ width: 28px; min-width: 28px; height: 28px;
1822
+ background: var(--vz-bg);
1823
+ border-radius: 50% 50% 50% 3px;
1824
+ overflow: hidden;
1825
+ }
1826
+ .vz-marker.has-avatar .vz-marker-count {
1827
+ position: absolute;
1828
+ bottom: -4px; right: -4px;
1829
+ background: var(--vz-accent);
1830
+ color: var(--vz-marker-fg);
1831
+ min-width: 16px; height: 16px;
1832
+ border-radius: 8px;
1833
+ font-size: 9px; font-weight: 700;
1834
+ display: inline-flex; align-items: center; justify-content: center;
1835
+ padding: 0 4px;
1836
+ box-shadow: 0 0 0 2px var(--vz-bg);
1837
+ }
1838
+ .vz-marker-initials {
1839
+ font-size: 11px; font-weight: 700;
1840
+ color: #fafafa;
1841
+ background: var(--vz-bg-hover);
1842
+ width: 100%; height: 100%;
1843
+ display: inline-flex; align-items: center; justify-content: center;
1844
+ border-radius: 50% 50% 50% 3px;
1845
+ }
1846
+
1847
+ /* Floating pill */
1848
+ .vz-pill {
1849
+ position: fixed;
1850
+ bottom: 24px; left: 50%; transform: translateX(-50%);
1851
+ background: var(--vz-bg);
1852
+ border: 1px solid var(--vz-border);
1853
+ border-radius: 999px;
1854
+ padding: 7px 8px 7px 18px;
1855
+ display: flex; align-items: center; gap: 10px;
1856
+ pointer-events: auto;
1857
+ box-shadow: 0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04);
1858
+ user-select: none;
1859
+ max-width: calc(100vw - 32px);
1860
+ z-index: 5;
1861
+ }
1862
+ .vz-pill.is-selecting { border-color: var(--vz-accent); }
1863
+ .vz-pill-toggle {
1864
+ width: 28px; height: 28px;
1865
+ border-radius: 6px;
1866
+ display: inline-flex; align-items: center; justify-content: center;
1867
+ color: var(--vz-text-muted);
1868
+ transition: background 100ms, color 100ms;
1869
+ }
1870
+ .vz-pill-toggle:hover { background: var(--vz-bg-hover); color: #fafafa; }
1871
+ .vz-pill-toggle.is-active { background: color-mix(in srgb, var(--vz-accent) 22%, var(--vz-bg-hover)); color: var(--vz-accent); }
1872
+ .vz-pill-toggle svg { width: 14px; height: 14px; }
1873
+ .vz-pill-dot {
1874
+ width: 8px; height: 8px; border-radius: 4px;
1875
+ background: var(--vz-accent);
1876
+ box-shadow: 0 0 8px var(--vz-accent);
1877
+ flex-shrink: 0;
1878
+ }
1879
+ .vz-pill-label { font-size: 12px; font-weight: 600; letter-spacing: 0.02em; }
1880
+ .vz-pill-count-btn {
1881
+ font-size: 11px; color: var(--vz-text-muted);
1882
+ padding: 4px 10px;
1883
+ border-radius: 999px;
1884
+ transition: background 100ms, color 100ms;
1885
+ }
1886
+ .vz-pill-count-btn:hover { background: var(--vz-bg-hover); color: #fafafa; }
1887
+ .vz-pill-count-btn.is-active { background: var(--vz-bg-hover); color: #fafafa; }
1888
+ .vz-pill-btn {
1889
+ padding: 7px 14px;
1890
+ border-radius: 999px;
1891
+ font-size: 12px; font-weight: 500;
1892
+ color: #fafafa;
1893
+ transition: background 100ms;
1894
+ white-space: nowrap;
1895
+ }
1896
+ .vz-pill-btn:hover { background: var(--vz-bg-hover); }
1897
+ .vz-pill-btn.primary {
1898
+ background: var(--vz-accent);
1899
+ color: var(--vz-marker-fg);
1900
+ font-weight: 600;
1901
+ padding: 7px 16px;
1902
+ }
1903
+ .vz-pill-btn.primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
1904
+ .vz-pill-divider { width: 1px; height: 18px; background: var(--vz-border); flex-shrink: 0; }
1905
+ .vz-pill-icon-btn {
1906
+ width: 28px; height: 28px;
1907
+ border-radius: 50%;
1908
+ display: inline-flex; align-items: center; justify-content: center;
1909
+ color: #fafafa;
1910
+ transition: background 100ms;
1911
+ }
1912
+ .vz-pill-icon-btn:hover { background: var(--vz-bg-hover); }
1913
+ .vz-pill-user {
1914
+ display: inline-flex; align-items: center; gap: 6px;
1915
+ padding: 3px 10px 3px 4px;
1916
+ background: var(--vz-bg-hover);
1917
+ border-radius: 999px;
1918
+ font-size: 11px;
1919
+ color: #fafafa;
1920
+ }
1921
+ .vz-pill-user-avatar {
1922
+ width: 22px; height: 22px;
1923
+ border-radius: 50%;
1924
+ object-fit: cover;
1925
+ background: var(--vz-border);
1926
+ font-size: 10px; font-weight: 700;
1927
+ color: #fafafa;
1928
+ display: inline-flex; align-items: center; justify-content: center;
1929
+ }
1930
+
1931
+ /* Popover */
1932
+ .vz-popover {
1933
+ position: fixed;
1934
+ background: var(--vz-bg);
1935
+ border: 1px solid var(--vz-border);
1936
+ border-radius: 8px;
1937
+ padding: 12px;
1938
+ min-width: 280px; max-width: 360px;
1939
+ pointer-events: auto;
1940
+ box-shadow: 0 12px 32px rgba(0,0,0,.5);
1941
+ display: flex; flex-direction: column; gap: 8px;
1942
+ z-index: 6;
1943
+ }
1944
+ .vz-popover-header {
1945
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
1946
+ color: var(--vz-text-muted);
1947
+ display: flex; justify-content: space-between; align-items: center;
1948
+ }
1949
+ .vz-popover-close {
1950
+ font-size: 18px; line-height: 1;
1951
+ color: #666;
1952
+ width: 22px; height: 22px;
1953
+ display: inline-flex; align-items: center; justify-content: center;
1954
+ border-radius: 4px;
1955
+ }
1956
+ .vz-popover-close:hover { color: #fafafa; background: var(--vz-bg-hover); }
1957
+ .vz-target-label {
1958
+ font-size: 11px; color: var(--vz-text-muted);
1959
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1960
+ word-break: break-all;
1961
+ padding: 6px 8px;
1962
+ background: var(--vz-bg-hover);
1963
+ border-radius: 4px;
1964
+ }
1965
+ .vz-comment-list {
1966
+ display: flex; flex-direction: column; gap: 6px;
1967
+ max-height: 200px; overflow-y: auto;
1968
+ }
1969
+ .vz-comment {
1970
+ background: var(--vz-bg-hover);
1971
+ border: 1px solid var(--vz-border);
1972
+ border-radius: 6px;
1973
+ padding: 8px 10px;
1974
+ font-size: 13px;
1975
+ display: flex; flex-direction: column; gap: 6px;
1976
+ }
1977
+ .vz-comment-author {
1978
+ display: flex; align-items: center; gap: 6px;
1979
+ font-size: 11px;
1980
+ color: var(--vz-text-muted);
1981
+ }
1982
+ .vz-comment-author-avatar {
1983
+ width: 18px; height: 18px;
1984
+ border-radius: 50%;
1985
+ background: var(--vz-border);
1986
+ display: inline-flex; align-items: center; justify-content: center;
1987
+ font-size: 9px; font-weight: 700;
1988
+ color: #fafafa;
1989
+ overflow: hidden;
1990
+ }
1991
+ .vz-comment-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
1992
+ .vz-comment-author-name { color: #fafafa; font-weight: 500; }
1993
+ .vz-comment-meta {
1994
+ font-size: 10px; color: #666;
1995
+ display: flex; justify-content: space-between;
1996
+ }
1997
+ .vz-comment-delete { color: #666; font-size: 10px; text-decoration: underline; }
1998
+ .vz-comment-delete:hover { color: #ff7676; }
1999
+ .vz-empty { font-size: 12px; color: var(--vz-text-muted); padding: 4px 0; }
2000
+ .vz-textarea {
2001
+ background: var(--vz-bg-hover);
2002
+ border: 1px solid var(--vz-border);
2003
+ color: #fafafa;
2004
+ border-radius: 6px;
2005
+ padding: 8px 10px;
2006
+ font: inherit;
2007
+ font-size: 13px;
2008
+ resize: vertical;
2009
+ min-height: 60px;
2010
+ max-height: 160px;
2011
+ overflow-y: auto;
2012
+ outline: none;
2013
+ width: 100%;
2014
+ line-height: 1.5;
2015
+ word-break: break-word;
2016
+ white-space: pre-wrap;
2017
+ }
2018
+ .vz-textarea:focus { border-color: var(--vz-accent); }
2019
+ .vz-textarea:empty::before {
2020
+ content: attr(data-placeholder);
2021
+ color: var(--vz-text-muted);
2022
+ pointer-events: none;
2023
+ }
2024
+ /* Inline chip rendered live inside the contenteditable comment editor */
2025
+ .vz-chip {
2026
+ display: inline-flex; align-items: center; gap: 4px;
2027
+ padding: 1px 8px 1px 6px;
2028
+ border-radius: 999px;
2029
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2030
+ color: var(--vz-accent);
2031
+ font-size: 12px; font-weight: 500;
2032
+ cursor: default;
2033
+ user-select: none;
2034
+ margin: 0 2px;
2035
+ vertical-align: baseline;
2036
+ white-space: nowrap;
2037
+ }
2038
+ .vz-chip::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--vz-accent); flex-shrink: 0; }
2039
+
2040
+ /* Popover anchor list (multi-anchor comment header) */
2041
+ .vz-anchor-list {
2042
+ display: flex; flex-wrap: wrap; gap: 4px;
2043
+ padding: 6px 0 0;
2044
+ }
2045
+ .vz-anchor-chip {
2046
+ display: inline-flex; align-items: center; gap: 4px;
2047
+ padding: 3px 8px 3px 6px;
2048
+ border-radius: 999px;
2049
+ background: var(--vz-bg-hover);
2050
+ border: 1px solid var(--vz-border);
2051
+ font-size: 11px;
2052
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2053
+ color: #fafafa;
2054
+ cursor: pointer;
2055
+ }
2056
+ .vz-anchor-chip::before {
2057
+ content: '';
2058
+ width: 5px; height: 5px;
2059
+ border-radius: 50%;
2060
+ background: var(--vz-accent);
2061
+ }
2062
+ .vz-anchor-chip:hover { border-color: var(--vz-accent); }
2063
+ .vz-anchor-chip-remove {
2064
+ font-size: 12px; color: var(--vz-text-muted);
2065
+ margin-left: 2px;
2066
+ background: none; border: 0; cursor: pointer;
2067
+ padding: 0 2px;
2068
+ }
2069
+ .vz-anchor-chip-remove:hover { color: #ff7676; }
2070
+ .vz-anchor-hint { font-size: 11px; color: var(--vz-text-muted); padding: 4px 0; }
2071
+ .vz-popover-actions { display: flex; justify-content: flex-end; gap: 6px; }
2072
+ .vz-btn {
2073
+ padding: 6px 12px;
2074
+ border-radius: 6px;
2075
+ font-size: 12px; font-weight: 500;
2076
+ transition: background 100ms;
2077
+ }
2078
+ .vz-btn-ghost { color: var(--vz-text-muted); }
2079
+ .vz-btn-ghost:hover { background: var(--vz-bg-hover); color: #fafafa; }
2080
+ .vz-btn-primary {
2081
+ background: var(--vz-accent);
2082
+ color: var(--vz-marker-fg);
2083
+ font-weight: 600;
2084
+ }
2085
+ .vz-btn-primary:hover { background: color-mix(in srgb, var(--vz-accent) 88%, white); }
2086
+
2087
+ /* Sidebar */
2088
+ .vz-sidebar {
2089
+ position: fixed;
2090
+ top: 0; right: 0;
2091
+ width: 360px;
2092
+ height: 100vh;
2093
+ background: var(--vz-bg);
2094
+ border-left: 1px solid var(--vz-border);
2095
+ pointer-events: auto;
2096
+ display: flex; flex-direction: column;
2097
+ transform: translateX(100%);
2098
+ transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
2099
+ box-shadow: -16px 0 48px rgba(0,0,0,0.35);
2100
+ z-index: 7;
2101
+ }
2102
+ .vz-sidebar.is-open { transform: translateX(0); }
2103
+ .vz-sidebar-header {
2104
+ padding: 16px 18px;
2105
+ border-bottom: 1px solid var(--vz-border);
2106
+ display: flex; align-items: center; gap: 10px;
2107
+ flex-shrink: 0;
2108
+ }
2109
+ .vz-sidebar-title {
2110
+ font-size: 13px; font-weight: 600;
2111
+ display: flex; align-items: center; gap: 8px;
2112
+ }
2113
+ .vz-sidebar-title-count {
2114
+ font-size: 11px;
2115
+ color: var(--vz-text-muted);
2116
+ font-weight: 500;
2117
+ padding: 2px 8px;
2118
+ background: var(--vz-bg-hover);
2119
+ border-radius: 999px;
2120
+ }
2121
+ .vz-sidebar-close {
2122
+ margin-left: auto;
2123
+ width: 28px; height: 28px;
2124
+ border-radius: 6px;
2125
+ display: inline-flex; align-items: center; justify-content: center;
2126
+ color: var(--vz-text-muted);
2127
+ font-size: 18px; line-height: 1;
2128
+ }
2129
+ .vz-sidebar-close:hover { background: var(--vz-bg-hover); color: #fafafa; }
2130
+ .vz-sidebar-body {
2131
+ flex: 1 1 auto;
2132
+ overflow-y: auto;
2133
+ padding: 12px;
2134
+ }
2135
+ .vz-sidebar-empty {
2136
+ padding: 32px 18px;
2137
+ text-align: center;
2138
+ color: var(--vz-text-muted);
2139
+ font-size: 13px;
2140
+ }
2141
+ .vz-sidebar-empty strong { color: #fafafa; display: block; margin-bottom: 4px; font-weight: 600; }
2142
+ .vz-sidebar-group {
2143
+ margin-bottom: 16px;
2144
+ }
2145
+ .vz-sidebar-group-head {
2146
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2147
+ font-size: 10px;
2148
+ text-transform: lowercase;
2149
+ color: var(--vz-text-muted);
2150
+ padding: 4px 6px;
2151
+ margin-bottom: 4px;
2152
+ word-break: break-all;
2153
+ }
2154
+ .vz-sidebar-group-text {
2155
+ font-family: inherit;
2156
+ text-transform: none;
2157
+ font-size: 11px;
2158
+ color: #c4c4c4;
2159
+ margin-top: 2px;
2160
+ }
2161
+ .vz-sidebar-item {
2162
+ display: block;
2163
+ width: 100%;
2164
+ text-align: left;
2165
+ background: var(--vz-bg-hover);
2166
+ border: 1px solid var(--vz-border);
2167
+ border-radius: 8px;
2168
+ padding: 10px 12px;
2169
+ margin-bottom: 6px;
2170
+ font-size: 13px;
2171
+ color: #fafafa;
2172
+ transition: border-color 100ms, transform 100ms;
2173
+ }
2174
+ .vz-sidebar-item:hover {
2175
+ border-color: color-mix(in srgb, var(--vz-accent) 50%, var(--vz-border));
2176
+ transform: translateX(-2px);
2177
+ }
2178
+ .vz-sidebar-item-author {
2179
+ display: flex; align-items: center; gap: 6px;
2180
+ font-size: 11px;
2181
+ color: var(--vz-text-muted);
2182
+ margin-bottom: 4px;
2183
+ }
2184
+ .vz-sidebar-item-author .vz-comment-author-avatar { width: 16px; height: 16px; font-size: 8px; }
2185
+ .vz-sidebar-item-author-name { color: #fafafa; font-weight: 500; }
2186
+ .vz-sidebar-item-text {
2187
+ display: block;
2188
+ line-height: 1.45;
2189
+ white-space: pre-wrap;
2190
+ word-break: break-word;
2191
+ }
2192
+ .vz-sidebar-item-meta {
2193
+ display: flex; justify-content: space-between; align-items: center;
2194
+ margin-top: 6px;
2195
+ font-size: 10px;
2196
+ color: #666;
2197
+ }
2198
+ .vz-sidebar-item-delete {
2199
+ color: #666;
2200
+ background: transparent;
2201
+ border: 0;
2202
+ padding: 0;
2203
+ cursor: pointer;
2204
+ text-decoration: underline;
2205
+ font-size: 10px;
2206
+ font-family: inherit;
2207
+ }
2208
+ .vz-sidebar-item-delete:hover { color: #ff7676; }
2209
+
2210
+ /* Status filter tabs in the sidebar header */
2211
+ .vz-sidebar-filters {
2212
+ display: flex;
2213
+ gap: 4px;
2214
+ padding: 6px 16px 12px;
2215
+ border-bottom: 1px solid var(--vz-border);
2216
+ }
2217
+ .vz-sidebar-filter {
2218
+ background: transparent;
2219
+ border: 1px solid transparent;
2220
+ color: var(--vz-text-soft);
2221
+ font: inherit;
2222
+ font-size: 10.5px;
2223
+ letter-spacing: 0.06em;
2224
+ text-transform: uppercase;
2225
+ padding: 4px 10px;
2226
+ border-radius: 999px;
2227
+ cursor: pointer;
2228
+ display: inline-flex;
2229
+ align-items: center;
2230
+ gap: 6px;
2231
+ transition: color 100ms, background 100ms, border-color 100ms;
2232
+ }
2233
+ .vz-sidebar-filter:hover { color: var(--vz-text); }
2234
+ .vz-sidebar-filter.is-active {
2235
+ background: color-mix(in srgb, var(--vz-accent) 15%, transparent);
2236
+ color: var(--vz-accent);
2237
+ border-color: color-mix(in srgb, var(--vz-accent) 30%, transparent);
2238
+ }
2239
+ .vz-sidebar-filter-count {
2240
+ color: var(--vz-text-faint);
2241
+ font-size: 9.5px;
2242
+ }
2243
+ .vz-sidebar-filter.is-active .vz-sidebar-filter-count {
2244
+ color: inherit;
2245
+ opacity: 0.7;
2246
+ }
2247
+
2248
+ /* Per-item status pill (resolved / wontfix) shown at top-right of dim items */
2249
+ .vz-sidebar-item-row {
2250
+ display: flex;
2251
+ align-items: center;
2252
+ justify-content: space-between;
2253
+ gap: 8px;
2254
+ margin-bottom: 6px;
2255
+ }
2256
+ .vz-status-pill {
2257
+ display: inline-block;
2258
+ padding: 1px 7px;
2259
+ border-radius: 999px;
2260
+ font-size: 9.5px;
2261
+ letter-spacing: 0.06em;
2262
+ text-transform: uppercase;
2263
+ font-weight: 600;
2264
+ }
2265
+ .vz-status-resolved {
2266
+ background: rgba(34, 197, 94, 0.18);
2267
+ color: rgb(74, 222, 128);
2268
+ }
2269
+ .vz-status-wontfix {
2270
+ background: rgba(113, 113, 122, 0.22);
2271
+ color: rgb(161, 161, 170);
2272
+ }
2273
+ /* Drifted anchor \u2014 fell through to a fuzzy fallback. Render amber. */
2274
+ .vz-status-drifted {
2275
+ background: rgba(245, 158, 11, 0.18);
2276
+ color: rgb(252, 211, 77);
2277
+ }
2278
+ /* Orphaned \u2014 anchor element no longer on the page. Quiet zinc, dim text. */
2279
+ .vz-status-orphan {
2280
+ background: rgba(113, 113, 122, 0.24);
2281
+ color: rgb(212, 212, 216);
2282
+ }
2283
+
2284
+ /* Dim non-open items in 'All' view */
2285
+ .vz-sidebar-item-dim { opacity: 0.55; }
2286
+ .vz-sidebar-item-dim:hover { opacity: 0.85; }
2287
+
2288
+ /* Orphaned-comments section \u2014 separate from the active comment list. */
2289
+ .vz-sidebar-orphans {
2290
+ margin-top: 18px;
2291
+ padding-top: 14px;
2292
+ border-top: 1px dashed var(--vz-border);
2293
+ }
2294
+ .vz-sidebar-orphans-head {
2295
+ padding: 0 16px;
2296
+ font-family: var(--vz-mono, ui-monospace, monospace);
2297
+ font-size: 10.5px;
2298
+ letter-spacing: 0.1em;
2299
+ text-transform: uppercase;
2300
+ color: rgb(212, 212, 216);
2301
+ display: flex;
2302
+ flex-direction: column;
2303
+ gap: 2px;
2304
+ margin-bottom: 10px;
2305
+ }
2306
+ .vz-sidebar-orphans-sub {
2307
+ font-size: 9.5px;
2308
+ letter-spacing: 0.06em;
2309
+ text-transform: none;
2310
+ color: var(--vz-text-faint);
2311
+ }
2312
+ .vz-sidebar-item-orphan {
2313
+ opacity: 0.78;
2314
+ }
2315
+ .vz-sidebar-item-orphan:hover { opacity: 1; }
2316
+ .vz-sidebar-orphan-anchor {
2317
+ margin-top: 4px;
2318
+ margin-bottom: 6px;
2319
+ font-size: 11px;
2320
+ color: var(--vz-text-faint);
2321
+ line-height: 1.5;
2322
+ }
2323
+ .vz-sidebar-orphan-anchor code {
2324
+ background: var(--vz-bg-hover);
2325
+ padding: 1px 5px;
2326
+ border-radius: 3px;
2327
+ font-size: 10.5px;
2328
+ }
2329
+
2330
+ /* Per-item action buttons (resolve / reopen / wontfix) */
2331
+ .vz-sidebar-item-actions {
2332
+ display: inline-flex;
2333
+ align-items: center;
2334
+ gap: 10px;
2335
+ }
2336
+ .vz-sidebar-item-status {
2337
+ background: transparent;
2338
+ border: 0;
2339
+ padding: 0;
2340
+ cursor: pointer;
2341
+ color: var(--vz-text-soft);
2342
+ text-decoration: underline;
2343
+ font-size: 10px;
2344
+ font-family: inherit;
2345
+ }
2346
+ .vz-sidebar-item-status:hover { color: var(--vz-accent); }
2347
+
2348
+ /* # element-reference chip (rendered inside comments) */
2349
+ .vz-ref {
2350
+ display: inline-flex; align-items: center; gap: 4px;
2351
+ padding: 1px 8px 1px 6px;
2352
+ border-radius: 999px;
2353
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2354
+ color: var(--vz-accent);
2355
+ font-size: 12px; font-weight: 500;
2356
+ text-decoration: none;
2357
+ cursor: pointer;
2358
+ transition: background 100ms, transform 100ms;
2359
+ vertical-align: baseline;
2360
+ margin: 0 1px;
2361
+ }
2362
+ .vz-ref:hover { background: color-mix(in srgb, var(--vz-accent) 30%, var(--vz-bg-hover)); transform: translateY(-1px); }
2363
+ .vz-ref::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--vz-accent); flex-shrink: 0; }
2364
+
2365
+ /* Inline @mention chip (rendered inside displayed comments) */
2366
+ .vz-mention {
2367
+ display: inline-flex; align-items: center;
2368
+ padding: 1px 8px 1px 8px;
2369
+ border-radius: 999px;
2370
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2371
+ color: var(--vz-accent);
2372
+ font-size: 12px; font-weight: 600;
2373
+ vertical-align: baseline;
2374
+ margin: 0 1px;
2375
+ }
2376
+
2377
+ /* Mention chip inside the editor (contenteditable=false token) */
2378
+ .vz-chip-mention {
2379
+ background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
2380
+ color: var(--vz-accent);
2381
+ font-weight: 600;
2382
+ }
2383
+ .vz-chip-mention::before { display: none; }
2384
+
2385
+ /* @-mention button in the popover action row */
2386
+ .vz-btn-mention { color: var(--vz-accent); }
2387
+
2388
+ /* Reply count + reply toggle in a comment's action row */
2389
+ .vz-comment-actions { display: inline-flex; align-items: center; gap: 10px; }
2390
+ .vz-comment-reply-toggle {
2391
+ background: transparent;
2392
+ border: 0;
2393
+ padding: 0;
2394
+ cursor: pointer;
2395
+ color: var(--vz-text-soft);
2396
+ text-decoration: underline;
2397
+ font-size: 10px;
2398
+ font-family: inherit;
2399
+ }
2400
+ .vz-comment-reply-toggle:hover { color: var(--vz-accent); }
2401
+
2402
+ /* Inline reply list + form (shown when toggle-reply opens it) */
2403
+ .vz-replies {
2404
+ margin-top: 8px;
2405
+ padding-top: 8px;
2406
+ border-top: 1px dashed var(--vz-border);
2407
+ }
2408
+ .vz-reply-list { display: flex; flex-direction: column; gap: 8px; }
2409
+ .vz-reply {
2410
+ padding: 6px 10px;
2411
+ border-radius: 8px;
2412
+ background: var(--vz-bg-hover);
2413
+ }
2414
+ .vz-reply-text {
2415
+ font-size: 13px;
2416
+ color: var(--vz-text);
2417
+ line-height: 1.45;
2418
+ white-space: pre-wrap;
2419
+ }
2420
+ .vz-reply-form {
2421
+ display: flex;
2422
+ gap: 6px;
2423
+ margin-top: 8px;
2424
+ }
2425
+ .vz-reply-input {
2426
+ flex: 1;
2427
+ resize: vertical;
2428
+ min-height: 36px;
2429
+ padding: 6px 8px;
2430
+ font-family: inherit;
2431
+ font-size: 12.5px;
2432
+ background: var(--vz-bg);
2433
+ color: var(--vz-text);
2434
+ border: 1px solid var(--vz-border);
2435
+ border-radius: 6px;
2436
+ outline: none;
2437
+ }
2438
+ .vz-reply-input:focus { border-color: var(--vz-accent); }
2439
+ .vz-btn-reply { padding: 4px 12px; font-size: 12px; }
2440
+
2441
+ /* \u2500\u2500\u2500 Attachment upload UI (popover) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2442
+
2443
+ .vz-editor-wrap {
2444
+ position: relative;
2445
+ }
2446
+ .vz-attachment-drop-hint {
2447
+ position: absolute;
2448
+ inset: 0;
2449
+ display: flex;
2450
+ align-items: center;
2451
+ justify-content: center;
2452
+ background: color-mix(in srgb, var(--vz-accent) 18%, var(--vz-bg));
2453
+ border: 2px dashed var(--vz-accent);
2454
+ border-radius: 8px;
2455
+ color: var(--vz-accent);
2456
+ font-size: 13px;
2457
+ font-weight: 600;
2458
+ pointer-events: none;
2459
+ z-index: 1;
2460
+ }
2461
+
2462
+ /* Pending attachments (uploaded but not yet saved) */
2463
+ .vz-attachment-list {
2464
+ display: flex;
2465
+ flex-wrap: wrap;
2466
+ gap: 6px;
2467
+ }
2468
+ .vz-attachment-list:empty { display: none; }
2469
+ .vz-attachment-item {
2470
+ position: relative;
2471
+ width: 64px;
2472
+ height: 64px;
2473
+ border-radius: 6px;
2474
+ overflow: hidden;
2475
+ background: var(--vz-bg-hover);
2476
+ border: 1px solid var(--vz-border);
2477
+ }
2478
+ .vz-attachment-thumb {
2479
+ display: block;
2480
+ width: 100%;
2481
+ height: 100%;
2482
+ object-fit: cover;
2483
+ color: var(--vz-text-soft);
2484
+ font-size: 11px;
2485
+ text-align: center;
2486
+ line-height: 64px;
2487
+ }
2488
+ .vz-attachment-thumb-loading {
2489
+ background: var(--vz-bg-hover);
2490
+ animation: vz-attachment-pulse 1.4s ease-in-out infinite;
2491
+ }
2492
+ @keyframes vz-attachment-pulse {
2493
+ 0%, 100% { opacity: 0.4; }
2494
+ 50% { opacity: 1; }
2495
+ }
2496
+ .vz-attachment-remove {
2497
+ position: absolute;
2498
+ top: 2px;
2499
+ right: 2px;
2500
+ width: 16px;
2501
+ height: 16px;
2502
+ border-radius: 50%;
2503
+ background: rgba(0, 0, 0, 0.72);
2504
+ color: #fafafa;
2505
+ border: 0;
2506
+ cursor: pointer;
2507
+ font-size: 12px;
2508
+ line-height: 14px;
2509
+ padding: 0;
2510
+ }
2511
+ .vz-attachment-remove:hover { background: rgba(0, 0, 0, 0.9); }
2512
+
2513
+ /* Display-side attachment grid (rendered inside comments) */
2514
+ .vz-attachment-grid {
2515
+ display: grid;
2516
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
2517
+ gap: 4px;
2518
+ margin-top: 6px;
2519
+ }
2520
+ .vz-attachment-display {
2521
+ display: block;
2522
+ border-radius: 4px;
2523
+ overflow: hidden;
2524
+ background: var(--vz-bg-hover);
2525
+ border: 1px solid var(--vz-border);
2526
+ text-decoration: none;
2527
+ color: var(--vz-text-soft);
2528
+ }
2529
+ .vz-attachment-display img {
2530
+ display: block;
2531
+ width: 100%;
2532
+ height: 60px;
2533
+ object-fit: cover;
2534
+ }
2535
+ .vz-attachment-display:hover { border-color: var(--vz-accent); }
2536
+ .vz-attachment-file {
2537
+ display: flex;
2538
+ align-items: center;
2539
+ gap: 6px;
2540
+ padding: 6px 8px;
2541
+ font-size: 11px;
2542
+ }
2543
+ .vz-attachment-icon { font-size: 14px; }
2544
+ .vz-attachment-filename {
2545
+ overflow: hidden;
2546
+ white-space: nowrap;
2547
+ text-overflow: ellipsis;
2548
+ }
2549
+
2550
+ /* Banner shown while user is picking an element to reference */
2551
+ .vz-picking-banner {
2552
+ position: fixed;
2553
+ top: 24px; left: 50%; transform: translateX(-50%);
2554
+ background: var(--vz-bg);
2555
+ border: 1px solid var(--vz-accent);
2556
+ border-radius: 999px;
2557
+ padding: 8px 8px 8px 18px;
2558
+ pointer-events: auto;
2559
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
2560
+ display: flex; align-items: center; gap: 12px;
2561
+ font-size: 13px;
2562
+ color: #fafafa;
2563
+ z-index: 8;
2564
+ }
2565
+ .vz-picking-banner-hint {
2566
+ color: var(--vz-text-muted); font-size: 11px;
2567
+ display: inline-flex; align-items: center; gap: 6px;
2568
+ }
2569
+ .vz-picking-banner-kbd {
2570
+ background: var(--vz-bg-hover);
2571
+ border: 1px solid var(--vz-border);
2572
+ border-radius: 4px;
2573
+ padding: 1px 6px;
2574
+ font-family: ui-monospace, monospace;
2575
+ font-size: 10px;
2576
+ color: #fafafa;
2577
+ }
2578
+ .vz-picking-banner-cancel {
2579
+ padding: 4px 12px;
2580
+ font-size: 12px;
2581
+ background: var(--vz-bg-hover);
2582
+ border-radius: 999px;
2583
+ color: var(--vz-text-muted);
2584
+ }
2585
+ .vz-picking-banner-cancel:hover { color: #fafafa; }
2586
+
2587
+ .vz-toast {
2588
+ position: fixed;
2589
+ bottom: 80px; left: 50%; transform: translateX(-50%);
2590
+ background: var(--vz-bg);
2591
+ border: 1px solid var(--vz-border);
2592
+ border-radius: 6px;
2593
+ padding: 10px 14px;
2594
+ font-size: 12px;
2595
+ color: #fafafa;
2596
+ pointer-events: none;
2597
+ animation: vz-toast-in 200ms ease-out;
2598
+ z-index: 9;
2599
+ }
2600
+ @keyframes vz-toast-in {
2601
+ from { opacity: 0; transform: translateX(-50%) translateY(8px); }
2602
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
2603
+ }
2604
+ `;
2605
+ function injectStyles() {
2606
+ if (typeof document === "undefined") return;
2607
+ if (document.getElementById("vizu-styles")) return;
2608
+ const style = document.createElement("style");
2609
+ style.id = "vizu-styles";
2610
+ style.textContent = STYLES;
2611
+ (document.head || document.documentElement).appendChild(style);
2612
+ }
2613
+
2614
+ // src/shortcut.ts
2615
+ function parseShortcut(spec) {
2616
+ const parts = spec.toLowerCase().split("+").map((s) => s.trim());
2617
+ const last = parts.pop() || "";
2618
+ return {
2619
+ key: last,
2620
+ mod: parts.includes("mod") || parts.includes("cmdorctrl"),
2621
+ shift: parts.includes("shift"),
2622
+ alt: parts.includes("alt") || parts.includes("option"),
2623
+ ctrl: parts.includes("ctrl") || parts.includes("control")
2624
+ };
2625
+ }
2626
+ function matches(e, p) {
2627
+ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
2628
+ if (e.key.toLowerCase() !== p.key) return false;
2629
+ if (p.mod) {
2630
+ const modPressed = isMac ? e.metaKey : e.ctrlKey;
2631
+ if (!modPressed) return false;
2632
+ } else if (p.ctrl && !e.ctrlKey) {
2633
+ return false;
2634
+ }
2635
+ if (p.shift !== e.shiftKey) return false;
2636
+ if (p.alt !== e.altKey) return false;
2637
+ return true;
2638
+ }
2639
+
2640
+ // src/events.ts
2641
+ var EventBus = class {
2642
+ constructor() {
2643
+ this.listeners = /* @__PURE__ */ new Map();
2644
+ }
2645
+ on(event, handler) {
2646
+ let set = this.listeners.get(event);
2647
+ if (!set) {
2648
+ set = /* @__PURE__ */ new Set();
2649
+ this.listeners.set(event, set);
2650
+ }
2651
+ set.add(handler);
2652
+ return () => this.off(event, handler);
2653
+ }
2654
+ off(event, handler) {
2655
+ this.listeners.get(event)?.delete(handler);
2656
+ }
2657
+ emit(event, payload) {
2658
+ const set = this.listeners.get(event);
2659
+ if (!set) return;
2660
+ for (const handler of [...set]) {
2661
+ try {
2662
+ handler(payload);
2663
+ } catch (err) {
2664
+ console.error("[vizu] event handler error for", event, err);
2665
+ }
2666
+ }
2667
+ }
2668
+ clear() {
2669
+ this.listeners.clear();
2670
+ }
2671
+ };
2672
+
2673
+ // src/index.ts
2674
+ var DEFAULTS = {
2675
+ shortcut: "mod+shift+e",
2676
+ namespace: "default",
2677
+ accent: "#FF6647"
2678
+ };
2679
+ function resolveStorage(storage, cloud, defaultMode) {
2680
+ if (cloud) {
2681
+ if (storage && typeof console !== "undefined") {
2682
+ console.warn("[vizu] Both `cloud` and `storage` set; `cloud` wins. Drop `storage` to silence this.");
2683
+ }
2684
+ return new CloudStorageAdapter({
2685
+ workspace: cloud.workspace,
2686
+ apiUrl: cloud.apiUrl,
2687
+ autoSignIn: cloud.autoSignIn
2688
+ });
2689
+ }
2690
+ if (!storage) return defaultMode === "local" ? new LocalStorageAdapter() : new InMemoryStorageAdapter();
2691
+ if (storage === "local") return new LocalStorageAdapter();
2692
+ if (storage === "memory") return new InMemoryStorageAdapter();
2693
+ if (storage === "none") return new NullStorageAdapter();
2694
+ if (isV1Adapter(storage)) return wrapV1Adapter(storage);
2695
+ return storage;
2696
+ }
2697
+ var Vizu = class {
2698
+ constructor(options = {}, _defaultStorage = "memory") {
2699
+ this.root = null;
2700
+ this.highlighter = null;
2701
+ this.popover = null;
2702
+ this.pill = null;
2703
+ this.sidebar = null;
2704
+ /** Saved-comment markers: keyed by `${commentId}:${anchorIndex}` */
2705
+ this.markers = /* @__PURE__ */ new Map();
2706
+ /** Persistent outlines for commented elements: keyed by fingerprintKey */
2707
+ this.outlines = /* @__PURE__ */ new Map();
2708
+ /** Temporary "active anchor" outlines shown while popover is open OR
2709
+ * while a multi-select session is pending. Stores the fp so we can
2710
+ * reposition without needing to consult popover/pending state. */
2711
+ this.activeAnchorOutlines = /* @__PURE__ */ new Map();
2712
+ /** Transient spotlights rendered on jump (click sidebar item / anchor chip / # ref). */
2713
+ this.spotlights = /* @__PURE__ */ new Set();
2714
+ this.comments = [];
2715
+ /**
2716
+ * Best confidence each comment was resolved at on the most recent
2717
+ * marker render. Used by the sidebar to surface orphaned + drifted
2718
+ * comments separately, and exposed publicly via {@link getCommentConfidence}.
2719
+ */
2720
+ this.resolvedConfidence = /* @__PURE__ */ new Map();
2721
+ /**
2722
+ * Comment ids the matcher has already re-fingerprinted in this session.
2723
+ * Phase 11.4.4.3 self-healing: when a comment lands as `drifted` and
2724
+ * we have a live element to re-fingerprint, we rewrite the stored
2725
+ * fingerprint so the next load resolves as exact/likely. Tracked
2726
+ * here so the same render loop doesn't re-heal on every redraw.
2727
+ * Cleared on `destroy()`; survives `disable()`/`enable()`.
2728
+ */
2729
+ this.selfHealedThisSession = /* @__PURE__ */ new Set();
2730
+ this.actions = [];
2731
+ this.user = null;
2732
+ /** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
2733
+ this.liveCollab = null;
2734
+ this.enabled = false;
2735
+ this.rafScheduled = false;
2736
+ this.bus = new EventBus();
2737
+ /** Picker: next element click is consumed as a reference instead of opening a popover. */
2738
+ this.picking = null;
2739
+ this.pickingBanner = null;
2740
+ /** Multi-select state — pending selection NOT yet attached to a popover. */
2741
+ this.multiSelectMode = false;
2742
+ this.pendingFingerprints = [];
2743
+ this.pendingTargets = [];
2744
+ /**
2745
+ * Subscribe to remote-change notifications from the active storage
2746
+ * adapter (cloud adapters only). Local adapters never emit. The
2747
+ * subscription is torn down on `destroy()`.
2748
+ */
2749
+ this.unsubscribeStorage = null;
2750
+ this.onKeyDown = (e) => {
2751
+ if (this.picking && e.key === "Escape") {
2752
+ e.preventDefault();
2753
+ this.cancelPicking();
2754
+ return;
2755
+ }
2756
+ if (this.pendingFingerprints.length > 0 && e.key === "Escape" && !this.popover?.isOpen()) {
2757
+ e.preventDefault();
2758
+ this.clearSelection();
2759
+ return;
2760
+ }
2761
+ if (matches(e, this.parsedShortcut)) {
2762
+ e.preventDefault();
2763
+ this.toggle();
2764
+ }
2765
+ };
2766
+ /* ===== Reposition on scroll/resize ===== */
2767
+ this.scheduleReposition = () => {
2768
+ if (this.rafScheduled) return;
2769
+ this.rafScheduled = true;
2770
+ requestAnimationFrame(() => {
2771
+ this.rafScheduled = false;
2772
+ for (const [, info] of this.outlines) {
2773
+ if (info.target) this.positionOutline(info.outline, info.target);
2774
+ }
2775
+ for (const [, entry] of this.markers) {
2776
+ if (entry.target) this.positionMarker(entry.marker, entry.target);
2777
+ }
2778
+ for (const [, info] of this.activeAnchorOutlines) {
2779
+ const target = findElementByFingerprint(info.fp);
2780
+ if (target) this.positionOutline(info.el, target);
2781
+ }
2782
+ for (const sp of this.spotlights) {
2783
+ if (sp.target) this.positionOutline(sp.el, sp.target);
2784
+ }
2785
+ this.popover?.reposition();
2786
+ });
2787
+ };
2788
+ this.opts = { ...options };
2789
+ this.opts.shortcut = options.shortcut ?? DEFAULTS.shortcut;
2790
+ this.opts.namespace = options.namespace ?? DEFAULTS.namespace;
2791
+ this.opts.accent = options.accent ?? DEFAULTS.accent;
2792
+ this.parsedShortcut = parseShortcut(this.opts.shortcut);
2793
+ this.storage = resolveStorage(options.storage, options.cloud, _defaultStorage);
2794
+ this.user = options.user ?? null;
2795
+ if (options.actions) this.actions = [...options.actions];
2796
+ if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
2797
+ if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
2798
+ if (options.liveblocks && options.cloud) {
2799
+ const live = options.liveblocks;
2800
+ const cloud = options.cloud;
2801
+ void import("./live-collab-IRUNFAPE.js").then(({ LiveCollab }) => {
2802
+ this.liveCollab = new LiveCollab({
2803
+ publicKey: live.publicKey,
2804
+ authEndpoint: live.authEndpoint,
2805
+ workspaceSlug: cloud.workspace
2806
+ });
2807
+ void this.liveCollab.start();
2808
+ });
2809
+ }
2810
+ if (typeof window !== "undefined") {
2811
+ window.addEventListener("keydown", this.onKeyDown);
2812
+ void this.loadComments().then(() => this.subscribeStorage());
2813
+ if (options.startEnabled) this.deferred(() => this.enable());
2814
+ }
2815
+ }
2816
+ /* ===== Event API ===== */
2817
+ on(event, handler) {
2818
+ return this.bus.on(event, handler);
2819
+ }
2820
+ off(event, handler) {
2821
+ this.bus.off(event, handler);
2822
+ }
2823
+ /* ===== User API ===== */
2824
+ setUser(user) {
2825
+ this.user = user;
2826
+ this.popover?.setUser(user);
2827
+ this.pill?.setUser(user);
2828
+ this.bus.emit("user:changed", { user });
2829
+ }
2830
+ getUser() {
2831
+ return this.user;
2832
+ }
2833
+ /* ===== Lifecycle ===== */
2834
+ enable() {
2835
+ if (this.enabled) return;
2836
+ this.enabled = true;
2837
+ this.bus.emit("enabled", {});
2838
+ this.deferred(() => this.mount());
2839
+ }
2840
+ disable() {
2841
+ if (!this.enabled) return;
2842
+ this.enabled = false;
2843
+ this.unmount();
2844
+ this.bus.emit("disabled", {});
2845
+ }
2846
+ toggle() {
2847
+ if (this.enabled) this.disable();
2848
+ else this.enable();
2849
+ }
2850
+ isEnabled() {
2851
+ return this.enabled;
2852
+ }
2853
+ destroy() {
2854
+ this.disable();
2855
+ if (typeof window !== "undefined") window.removeEventListener("keydown", this.onKeyDown);
2856
+ this.unsubscribeStorage?.();
2857
+ this.unsubscribeStorage = null;
2858
+ this.selfHealedThisSession.clear();
2859
+ this.liveCollab?.destroy();
2860
+ this.liveCollab = null;
2861
+ this.bus.clear();
2862
+ }
2863
+ /* ===== Action API ===== */
2864
+ addAction(action) {
2865
+ const existing = this.actions.findIndex((a) => a.id === action.id);
2866
+ if (existing >= 0) this.actions[existing] = action;
2867
+ else this.actions.push(action);
2868
+ this.pill?.setActions(this.actions);
2869
+ }
2870
+ removeAction(id) {
2871
+ this.actions = this.actions.filter((a) => a.id !== id);
2872
+ this.pill?.setActions(this.actions);
2873
+ }
2874
+ getActions() {
2875
+ return [...this.actions];
2876
+ }
2877
+ invokeAction(id) {
2878
+ const a = this.actions.find((x) => x.id === id);
2879
+ if (!a) return;
2880
+ const ctx = this.actionContext();
2881
+ this.bus.emit("action:invoked", { id });
2882
+ void a.onClick(ctx);
2883
+ }
2884
+ /* ===== Comment API ===== */
2885
+ getComments() {
2886
+ return [...this.comments];
2887
+ }
2888
+ async setComments(comments, opts) {
2889
+ this.comments = migrateComments(comments);
2890
+ if (opts?.persist !== false) await this.storage.setAll(this.opts.namespace, this.comments);
2891
+ this.bus.emit("comments:set", { comments: this.getComments() });
2892
+ this.refreshUi();
2893
+ }
2894
+ /**
2895
+ * Patch a single comment. Used by status changes (resolve / reopen),
2896
+ * future threading mutations, etc. Returns the updated comment or
2897
+ * `null` if no comment with that id exists.
2898
+ */
2899
+ async updateComment(id, patch) {
2900
+ const idx = this.comments.findIndex((c) => c.id === id);
2901
+ if (idx === -1) return null;
2902
+ const { id: _i, schemaVersion: _s, ...safe } = patch;
2903
+ const next = { ...this.comments[idx], ...safe };
2904
+ this.comments[idx] = next;
2905
+ const persisted = await this.storage.updateComment(this.opts.namespace, id, safe);
2906
+ this.bus.emit("comment:updated", { comment: persisted ?? next });
2907
+ this.refreshUi();
2908
+ return next;
2909
+ }
2910
+ /** Auth context surfaced by the active storage adapter, if any (cloud adapters set this). */
2911
+ getAuthContext() {
2912
+ return this.storage.getAuthContext?.() ?? null;
2913
+ }
2914
+ /**
2915
+ * Re-fingerprint a comment's anchors against the live DOM and persist.
2916
+ * Called from the render loop when a comment lands `drifted` with a
2917
+ * matched element — rewriting the fingerprint here makes the next
2918
+ * load resolve via a cleaner rung (data-vizu-key / id / class) instead
2919
+ * of falling back to fuzzy text.
2920
+ *
2921
+ * Marks the comment as healed for this session so the same render
2922
+ * doesn't kick the same write twice. Failures are logged; the next
2923
+ * page load gets another chance.
2924
+ */
2925
+ async selfHealComment(c, byElement) {
2926
+ this.selfHealedThisSession.add(c.id);
2927
+ const next = [];
2928
+ let changed = false;
2929
+ for (const fp of c.fingerprints || []) {
2930
+ const bucket = byElement.get(fingerprintKey(fp));
2931
+ if (bucket?.target) {
2932
+ const refreshed = fingerprint(bucket.target);
2933
+ next.push(refreshed);
2934
+ if (!changed && fingerprintKey(refreshed) !== fingerprintKey(fp)) changed = true;
2935
+ } else {
2936
+ next.push(fp);
2937
+ }
2938
+ }
2939
+ if (!changed) return;
2940
+ const patch = {
2941
+ fingerprints: next,
2942
+ fingerprintsRefreshedAt: Date.now()
2943
+ };
2944
+ const idx = this.comments.findIndex((x) => x.id === c.id);
2945
+ if (idx !== -1) this.comments[idx] = { ...this.comments[idx], ...patch };
2946
+ try {
2947
+ await this.storage.updateComment(this.opts.namespace, c.id, patch);
2948
+ } catch (err) {
2949
+ if (typeof console !== "undefined") {
2950
+ console.warn("[vizu] self-heal write failed for", c.id, err);
2951
+ }
2952
+ }
2953
+ }
2954
+ /**
2955
+ * Resolution confidence for a comment as of the last marker render.
2956
+ * `'orphaned'` means the comment's fingerprints didn't resolve to any
2957
+ * live DOM element — the sidebar surfaces it in the orphaned section.
2958
+ * Returns `undefined` for unknown comment ids.
2959
+ */
2960
+ getCommentConfidence(commentId) {
2961
+ return this.resolvedConfidence.get(commentId);
2962
+ }
2963
+ /** Returns the full Map of comment id → confidence. Useful for the dashboard. */
2964
+ getResolvedConfidence() {
2965
+ return new Map(this.resolvedConfidence);
2966
+ }
2967
+ /**
2968
+ * True iff the active storage adapter implements `uploadAttachment`.
2969
+ * The popover hides its upload UI when this returns false (local /
2970
+ * memory modes don't support attachments today).
2971
+ */
2972
+ canUploadAttachments() {
2973
+ return typeof this.storage.uploadAttachment === "function";
2974
+ }
2975
+ /**
2976
+ * Upload an attachment via the active storage adapter. Throws if the
2977
+ * adapter doesn't implement uploads. Caller is responsible for adding
2978
+ * the returned Attachment to a comment (popover does this automatically).
2979
+ */
2980
+ async uploadAttachment(file) {
2981
+ if (!this.storage.uploadAttachment) {
2982
+ throw new Error(
2983
+ "[vizu] active storage adapter does not support uploadAttachment; only the CloudStorageAdapter does in v1"
2984
+ );
2985
+ }
2986
+ return this.storage.uploadAttachment(this.opts.namespace, file);
2987
+ }
2988
+ /**
2989
+ * Append a reply to a comment. Returns the parent comment or null if
2990
+ * the comment id doesn't exist. Routes through the storage adapter's
2991
+ * native `addReply` when available, falling back to an updateComment
2992
+ * patch for adapters that don't implement reply ops.
2993
+ */
2994
+ async addReply(commentId, text, mentions) {
2995
+ const idx = this.comments.findIndex((c) => c.id === commentId);
2996
+ if (idx === -1) return null;
2997
+ const reply = {
2998
+ id: uuid(),
2999
+ text: text.trim(),
3000
+ createdAt: Date.now(),
3001
+ author: this.user ?? void 0,
3002
+ authorId: this.user?.id,
3003
+ mentions: mentions && mentions.length ? mentions : void 0
3004
+ };
3005
+ if (!reply.text) return null;
3006
+ const updated = {
3007
+ ...this.comments[idx],
3008
+ replies: [...this.comments[idx].replies ?? [], reply]
3009
+ };
3010
+ this.comments[idx] = updated;
3011
+ if (this.storage.addReply) {
3012
+ await this.storage.addReply(this.opts.namespace, commentId, reply);
3013
+ } else {
3014
+ await this.storage.updateComment(this.opts.namespace, commentId, { replies: updated.replies });
3015
+ }
3016
+ this.bus.emit("comment:updated", { comment: updated });
3017
+ this.refreshUi();
3018
+ if (this.popover?.isOpen()) {
3019
+ const anchors = this.popover.getAnchors();
3020
+ if (anchors[0]) this.popover.update(this.commentsForElement(anchors[0]));
3021
+ }
3022
+ return updated;
3023
+ }
3024
+ /**
3025
+ * Remove a reply from a comment. Returns the parent comment or null
3026
+ * if either id is unknown.
3027
+ */
3028
+ async removeReply(commentId, replyId) {
3029
+ const idx = this.comments.findIndex((c) => c.id === commentId);
3030
+ if (idx === -1) return null;
3031
+ const filtered = (this.comments[idx].replies ?? []).filter((r) => r.id !== replyId);
3032
+ if (filtered.length === (this.comments[idx].replies ?? []).length) return null;
3033
+ const updated = { ...this.comments[idx], replies: filtered };
3034
+ this.comments[idx] = updated;
3035
+ if (this.storage.removeReply) {
3036
+ await this.storage.removeReply(this.opts.namespace, commentId, replyId);
3037
+ } else {
3038
+ await this.storage.updateComment(this.opts.namespace, commentId, { replies: filtered });
3039
+ }
3040
+ this.bus.emit("comment:updated", { comment: updated });
3041
+ this.refreshUi();
3042
+ if (this.popover?.isOpen()) {
3043
+ const anchors = this.popover.getAnchors();
3044
+ if (anchors[0]) this.popover.update(this.commentsForElement(anchors[0]));
3045
+ }
3046
+ return updated;
3047
+ }
3048
+ async clearAll() {
3049
+ const previous = this.comments;
3050
+ this.comments = [];
3051
+ await this.storage.clear(this.opts.namespace);
3052
+ this.bus.emit("comments:cleared", { previous });
3053
+ this.refreshUi();
3054
+ this.popover?.close();
3055
+ this.clearActiveAnchorOutlines();
3056
+ this.highlighter?.setPaused(false);
3057
+ }
3058
+ /* ===== Multi-select API ===== */
3059
+ /** Toggle multi-select mode. When on, plain clicks accumulate into the selection. */
3060
+ toggleMultiSelectMode() {
3061
+ this.multiSelectMode = !this.multiSelectMode;
3062
+ if (!this.multiSelectMode) this.clearSelection();
3063
+ this.pill?.setMultiSelectState(this.multiSelectMode, this.pendingFingerprints.length);
3064
+ }
3065
+ /** Discard the in-progress selection AND exit multi-select mode. */
3066
+ clearSelection() {
3067
+ this.pendingFingerprints = [];
3068
+ this.pendingTargets = [];
3069
+ this.multiSelectMode = false;
3070
+ this.clearActiveAnchorOutlines();
3071
+ this.pill?.setMultiSelectState(false, 0);
3072
+ }
3073
+ /** Open the popover anchored to the current pending selection so the user can write the comment. */
3074
+ commitSelection() {
3075
+ if (this.pendingFingerprints.length === 0) return;
3076
+ const targets = [...this.pendingTargets];
3077
+ const fps = [...this.pendingFingerprints];
3078
+ this.pendingFingerprints = [];
3079
+ this.pendingTargets = [];
3080
+ this.multiSelectMode = false;
3081
+ this.pill?.setMultiSelectState(false, 0);
3082
+ const target = targets.find((t) => !!t) ?? findElementByFingerprint(fps[0]);
3083
+ if (!target) return;
3084
+ this.openPopoverFor(targets.filter(Boolean), fps);
3085
+ }
3086
+ getSelection() {
3087
+ return [...this.pendingFingerprints];
3088
+ }
3089
+ /* ===== Internal ===== */
3090
+ async loadComments() {
3091
+ const raw = await this.storage.load(this.opts.namespace);
3092
+ this.comments = migrateComments(raw);
3093
+ if (needsPersist(raw)) {
3094
+ void this.storage.setAll(this.opts.namespace, this.comments).catch(() => {
3095
+ });
3096
+ }
3097
+ this.bus.emit("comments:loaded", { comments: this.getComments() });
3098
+ if (this.enabled) this.refreshUi();
3099
+ }
3100
+ subscribeStorage() {
3101
+ if (this.unsubscribeStorage) return;
3102
+ if (!this.storage.subscribe) return;
3103
+ this.unsubscribeStorage = this.storage.subscribe(this.opts.namespace, (e) => {
3104
+ if (e.type === "added") {
3105
+ const fresh = migrateComment(e.comment);
3106
+ if (!this.comments.some((c) => c.id === fresh.id)) {
3107
+ this.comments.push(fresh);
3108
+ this.bus.emit("comment:added", { comment: fresh });
3109
+ this.refreshUi();
3110
+ }
3111
+ } else if (e.type === "updated") {
3112
+ const idx = this.comments.findIndex((c) => c.id === e.comment.id);
3113
+ const fresh = migrateComment(e.comment);
3114
+ if (idx >= 0) {
3115
+ this.comments[idx] = fresh;
3116
+ this.bus.emit("comment:updated", { comment: fresh });
3117
+ this.refreshUi();
3118
+ }
3119
+ } else if (e.type === "removed") {
3120
+ const idx = this.comments.findIndex((c) => c.id === e.id);
3121
+ if (idx >= 0) {
3122
+ const [removed] = this.comments.splice(idx, 1);
3123
+ this.bus.emit("comment:removed", { id: e.id, comment: removed });
3124
+ this.refreshUi();
3125
+ }
3126
+ } else if (e.type === "cleared") {
3127
+ const previous = this.comments;
3128
+ this.comments = [];
3129
+ this.bus.emit("comments:cleared", { previous });
3130
+ this.refreshUi();
3131
+ }
3132
+ });
3133
+ }
3134
+ deferred(fn) {
3135
+ if (typeof document === "undefined") return;
3136
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn, { once: true });
3137
+ else fn();
3138
+ }
3139
+ mount() {
3140
+ if (this.root) return;
3141
+ injectStyles();
3142
+ this.root = document.createElement("div");
3143
+ this.root.setAttribute("data-vizu-root", "");
3144
+ this.root.style.setProperty("--vz-accent", this.opts.accent);
3145
+ document.body.appendChild(this.root);
3146
+ this.popover = new Popover(this.root, {
3147
+ onAdd: (text, fps, refs, mentions, atts) => void this.addCommentFromPopover(text, fps, refs, mentions, atts),
3148
+ onUploadAttachment: (file) => this.uploadAttachment(file),
3149
+ canUploadAttachments: () => this.canUploadAttachments(),
3150
+ onDelete: (id) => void this.removeComment(id),
3151
+ onAddReply: (commentId, text) => void this.addReply(commentId, text),
3152
+ onDeleteReply: (commentId, replyId) => void this.removeReply(commentId, replyId),
3153
+ onClose: () => this.closePopover(),
3154
+ onStartReferencePick: (onPicked, onCancel) => this.startPicking(onPicked, onCancel),
3155
+ onJumpToReference: (commentId, refId2) => this.jumpToReference(commentId, refId2),
3156
+ onAnchorsChanged: (fps) => this.syncActiveAnchorOutlines(fps)
3157
+ });
3158
+ this.popover.setUser(this.user);
3159
+ this.sidebar = new Sidebar(this.root, {
3160
+ onJumpTo: (fp) => this.jumpToFingerprint(fp),
3161
+ onJumpToReference: (commentId, refId2) => this.jumpToReference(commentId, refId2),
3162
+ onDelete: (id) => void this.removeComment(id),
3163
+ onUpdateStatus: (id, status) => void this.updateComment(id, { status }),
3164
+ onClose: () => this.closeSidebar()
3165
+ });
3166
+ this.pill = new Pill(this.root, {
3167
+ onToggleSidebar: () => this.toggleSidebar(),
3168
+ onDisable: () => this.disable(),
3169
+ onAction: (id) => this.invokeAction(id),
3170
+ onToggleMultiSelect: () => this.toggleMultiSelectMode(),
3171
+ onCommitSelection: () => this.commitSelection(),
3172
+ onClearSelection: () => this.clearSelection()
3173
+ });
3174
+ this.pill.setActions(this.actions);
3175
+ this.pill.setUser(this.user);
3176
+ this.pill.setComments(this.comments);
3177
+ this.pill.setMultiSelectState(this.multiSelectMode, this.pendingFingerprints.length);
3178
+ this.pill.show();
3179
+ this.highlighter = new Highlighter(
3180
+ this.root,
3181
+ this.opts.ignoreSelectors ?? [],
3182
+ (el, e) => this.onElementClick(el, e),
3183
+ (el) => {
3184
+ if (!this.liveCollab) return;
3185
+ this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
3186
+ }
3187
+ );
3188
+ this.highlighter.start();
3189
+ this.renderAllMarkers();
3190
+ window.addEventListener("scroll", this.scheduleReposition, true);
3191
+ window.addEventListener("resize", this.scheduleReposition);
3192
+ this.bus.emit("mounted", {});
3193
+ }
3194
+ unmount() {
3195
+ window.removeEventListener("scroll", this.scheduleReposition, true);
3196
+ window.removeEventListener("resize", this.scheduleReposition);
3197
+ this.highlighter?.destroy();
3198
+ this.popover?.destroy();
3199
+ this.pill?.destroy();
3200
+ this.sidebar?.destroy();
3201
+ this.root?.remove();
3202
+ this.root = null;
3203
+ this.highlighter = null;
3204
+ this.popover = null;
3205
+ this.pill = null;
3206
+ this.sidebar = null;
3207
+ this.markers.clear();
3208
+ this.outlines.clear();
3209
+ this.activeAnchorOutlines.clear();
3210
+ for (const sp of this.spotlights) sp.el.remove();
3211
+ this.spotlights.clear();
3212
+ this.pendingFingerprints = [];
3213
+ this.pendingTargets = [];
3214
+ this.bus.emit("unmounted", {});
3215
+ }
3216
+ onElementClick(el, e) {
3217
+ if (this.picking) {
3218
+ const fp2 = fingerprint(el);
3219
+ const p = this.picking;
3220
+ this.picking = null;
3221
+ this.hidePickingBanner();
3222
+ this.highlighter?.setPaused(this.popover?.isOpen() ?? false);
3223
+ p.onSelect(fp2);
3224
+ return;
3225
+ }
3226
+ const fp = fingerprint(el);
3227
+ if (this.popover?.isOpen() && e.shiftKey) {
3228
+ this.popover.addAnchor(fp, el);
3229
+ return;
3230
+ }
3231
+ if (!this.popover?.isOpen() && (this.multiSelectMode || e.shiftKey)) {
3232
+ this.addToSelection(fp, el);
3233
+ return;
3234
+ }
3235
+ this.bus.emit("element:selected", { target: el, fingerprint: fp });
3236
+ const elComments = this.commentsForElement(fp);
3237
+ this.openPopoverFor([el], [fp], elComments);
3238
+ }
3239
+ openPopoverFor(targets, fps, existingComments) {
3240
+ if (!this.popover) return;
3241
+ const comments = existingComments ?? this.commentsForElement(fps[0]);
3242
+ this.popover.open(targets, fps, comments);
3243
+ this.syncActiveAnchorOutlines(fps);
3244
+ this.highlighter?.setPaused(true);
3245
+ }
3246
+ closePopover() {
3247
+ this.popover?.close();
3248
+ this.clearActiveAnchorOutlines();
3249
+ this.highlighter?.setPaused(false);
3250
+ this.bus.emit("element:deselected", {});
3251
+ }
3252
+ addToSelection(fp, el) {
3253
+ const key2 = fingerprintKey(fp);
3254
+ if (this.pendingFingerprints.some((x) => fingerprintKey(x) === key2)) {
3255
+ this.pendingFingerprints = this.pendingFingerprints.filter((x) => fingerprintKey(x) !== key2);
3256
+ this.pendingTargets = this.pendingTargets.filter((t) => t !== el);
3257
+ } else {
3258
+ this.pendingFingerprints.push(fp);
3259
+ this.pendingTargets.push(el);
3260
+ }
3261
+ this.syncActiveAnchorOutlines(this.pendingFingerprints);
3262
+ this.pill?.setMultiSelectState(true, this.pendingFingerprints.length);
3263
+ if (this.pendingFingerprints.length > 0) this.multiSelectMode = true;
3264
+ else this.multiSelectMode = false;
3265
+ }
3266
+ commentsForElement(fp) {
3267
+ const key2 = fingerprintKey(fp);
3268
+ return this.comments.filter(
3269
+ (c) => (c.fingerprints || []).some((f) => fingerprintKey(f) === key2)
3270
+ );
3271
+ }
3272
+ async addCommentFromPopover(text, fps, references, mentions, attachments) {
3273
+ if (fps.length === 0) return;
3274
+ const pageUrl = typeof location !== "undefined" ? location.href : void 0;
3275
+ const c = {
3276
+ id: uuid(),
3277
+ schemaVersion: SCHEMA_VERSION,
3278
+ fingerprints: fps,
3279
+ text,
3280
+ createdAt: Date.now(),
3281
+ pageVersion: this.opts.pageVersion,
3282
+ pageUrl,
3283
+ author: this.user ?? void 0,
3284
+ authorId: this.user?.id,
3285
+ references: references && Object.keys(references).length ? references : void 0,
3286
+ status: "open",
3287
+ replies: [],
3288
+ attachments: attachments ?? [],
3289
+ mentions: mentions ?? []
3290
+ };
3291
+ this.comments.push(c);
3292
+ await this.storage.addComment(this.opts.namespace, c);
3293
+ this.bus.emit("comment:added", { comment: c });
3294
+ this.popover?.update(this.commentsForElement(fps[0]));
3295
+ this.refreshUi();
3296
+ }
3297
+ async removeComment(id) {
3298
+ const comment = this.comments.find((c) => c.id === id);
3299
+ if (!comment) return;
3300
+ this.comments = this.comments.filter((c) => c.id !== id);
3301
+ await this.storage.removeComment(this.opts.namespace, id);
3302
+ this.bus.emit("comment:removed", { id, comment });
3303
+ if (this.popover?.isOpen()) {
3304
+ const anchors = this.popover.getAnchors();
3305
+ if (anchors[0]) this.popover.update(this.commentsForElement(anchors[0]));
3306
+ }
3307
+ this.refreshUi();
3308
+ }
3309
+ refreshUi() {
3310
+ this.pill?.setComments(this.comments);
3311
+ this.renderAllMarkers();
3312
+ this.sidebar?.update(this.comments, this.resolvedConfidence);
3313
+ }
3314
+ /* ===== Sidebar ===== */
3315
+ toggleSidebar() {
3316
+ if (!this.sidebar) return;
3317
+ if (!this.sidebar.isOpen()) this.renderAllMarkers();
3318
+ this.sidebar.toggle(this.comments, this.resolvedConfidence);
3319
+ const open = this.sidebar.isOpen();
3320
+ this.pill?.setSidebarOpen(open);
3321
+ this.bus.emit(open ? "sidebar:opened" : "sidebar:closed", {});
3322
+ }
3323
+ closeSidebar() {
3324
+ this.sidebar?.close();
3325
+ this.pill?.setSidebarOpen(false);
3326
+ this.bus.emit("sidebar:closed", {});
3327
+ }
3328
+ /* ===== Picking ===== */
3329
+ startPicking(onSelect, onCancel) {
3330
+ if (this.picking) this.picking.onCancel();
3331
+ this.picking = { onSelect, onCancel };
3332
+ this.highlighter?.setPaused(false);
3333
+ this.showPickingBanner();
3334
+ }
3335
+ cancelPicking() {
3336
+ if (!this.picking) return;
3337
+ const p = this.picking;
3338
+ this.picking = null;
3339
+ this.hidePickingBanner();
3340
+ this.highlighter?.setPaused(this.popover?.isOpen() ?? false);
3341
+ p.onCancel();
3342
+ }
3343
+ showPickingBanner() {
3344
+ if (!this.root) return;
3345
+ this.pickingBanner = document.createElement("div");
3346
+ this.pickingBanner.className = "vz-picking-banner";
3347
+ this.pickingBanner.innerHTML = `
3348
+ <span>Click any element to reference</span>
3349
+ <span class="vz-picking-banner-hint">or press <span class="vz-picking-banner-kbd">Esc</span></span>
3350
+ <button class="vz-picking-banner-cancel" data-vz="cancel-pick">Cancel</button>
3351
+ `;
3352
+ this.pickingBanner.addEventListener("click", (e) => {
3353
+ if (e.target.closest('[data-vz="cancel-pick"]')) this.cancelPicking();
3354
+ });
3355
+ this.root.appendChild(this.pickingBanner);
3356
+ }
3357
+ hidePickingBanner() {
3358
+ this.pickingBanner?.remove();
3359
+ this.pickingBanner = null;
3360
+ }
3361
+ jumpToReference(commentId, refId2) {
3362
+ const c = this.comments.find((x) => x.id === commentId);
3363
+ const fp = c?.references?.[refId2];
3364
+ if (fp) this.jumpToFingerprint(fp);
3365
+ }
3366
+ jumpToFingerprint(fp) {
3367
+ const target = findElementByFingerprint(fp);
3368
+ if (!target) {
3369
+ this.pill?.toast("Element not found on this page");
3370
+ return;
3371
+ }
3372
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
3373
+ if (this.root) {
3374
+ const spotlight = document.createElement("div");
3375
+ spotlight.className = "vz-jump-spotlight";
3376
+ this.root.appendChild(spotlight);
3377
+ this.positionOutline(spotlight, target);
3378
+ const entry = { el: spotlight, target };
3379
+ this.spotlights.add(entry);
3380
+ setTimeout(() => {
3381
+ spotlight.remove();
3382
+ this.spotlights.delete(entry);
3383
+ }, 1800);
3384
+ }
3385
+ const key2 = fingerprintKey(fp);
3386
+ const outline = this.outlines.get(key2)?.outline;
3387
+ if (outline) {
3388
+ setTimeout(() => {
3389
+ outline.classList.remove("is-pulsing");
3390
+ void outline.offsetWidth;
3391
+ outline.classList.add("is-pulsing");
3392
+ setTimeout(() => outline.classList.remove("is-pulsing"), 1500);
3393
+ }, 250);
3394
+ }
3395
+ }
3396
+ /* ===== Marker / outline rendering ===== */
3397
+ renderAllMarkers() {
3398
+ if (!this.root) return;
3399
+ for (const { marker } of this.markers.values()) marker.remove();
3400
+ for (const { outline } of this.outlines.values()) outline.remove();
3401
+ this.markers.clear();
3402
+ this.outlines.clear();
3403
+ const byElement = /* @__PURE__ */ new Map();
3404
+ const perComment = /* @__PURE__ */ new Map();
3405
+ const rankConf = (c) => c === "exact" ? 3 : c === "likely" ? 2 : c === "drifted" ? 1 : 0;
3406
+ const bestConf = (a, b) => a === void 0 ? b : rankConf(b) > rankConf(a) ? b : a;
3407
+ for (const c of this.comments) {
3408
+ for (const fp of c.fingerprints || []) {
3409
+ const key2 = fingerprintKey(fp);
3410
+ let bucket = byElement.get(key2);
3411
+ if (!bucket) {
3412
+ const match = findByFingerprint(fp);
3413
+ bucket = {
3414
+ fp,
3415
+ target: match.element,
3416
+ confidence: match.confidence,
3417
+ commentIds: []
3418
+ };
3419
+ byElement.set(key2, bucket);
3420
+ }
3421
+ bucket.commentIds.push(c.id);
3422
+ perComment.set(c.id, bestConf(perComment.get(c.id), bucket.confidence));
3423
+ }
3424
+ }
3425
+ this.resolvedConfidence.clear();
3426
+ for (const c of this.comments) {
3427
+ const conf = perComment.get(c.id) ?? "orphaned";
3428
+ this.resolvedConfidence.set(c.id, conf);
3429
+ const firstFp = (c.fingerprints || [])[0];
3430
+ const elForEvent = firstFp ? byElement.get(fingerprintKey(firstFp))?.target ?? null : null;
3431
+ this.bus.emit("anchor:resolved", { comment: c, confidence: conf, element: elForEvent });
3432
+ }
3433
+ for (const c of this.comments) {
3434
+ if (this.selfHealedThisSession.has(c.id)) continue;
3435
+ if (this.resolvedConfidence.get(c.id) !== "drifted") continue;
3436
+ void this.selfHealComment(c, byElement);
3437
+ }
3438
+ for (const [key2, info] of byElement) {
3439
+ if (info.confidence === "orphaned" || !info.target) continue;
3440
+ const outlineEl = document.createElement("div");
3441
+ outlineEl.className = "vz-comment-outline";
3442
+ if (info.confidence === "drifted") outlineEl.classList.add("vz-comment-outline-drifted");
3443
+ this.root.appendChild(outlineEl);
3444
+ this.outlines.set(key2, { outline: outlineEl, target: info.target, fp: info.fp });
3445
+ const marker = document.createElement("button");
3446
+ marker.className = "vz-marker";
3447
+ if (info.confidence === "drifted") marker.classList.add("vz-marker-drifted");
3448
+ marker.setAttribute("data-element-key", key2);
3449
+ marker.setAttribute("data-confidence", info.confidence);
3450
+ const total = info.commentIds.length;
3451
+ if (total > 1) {
3452
+ marker.textContent = String(total);
3453
+ marker.title = total + " comments";
3454
+ } else {
3455
+ marker.textContent = "";
3456
+ const c = this.comments.find((c2) => c2.id === info.commentIds[0]);
3457
+ marker.title = c?.author ? "Comment by " + c.author.name : "Comment";
3458
+ }
3459
+ marker.addEventListener("click", (e) => {
3460
+ e.preventDefault();
3461
+ e.stopPropagation();
3462
+ if (info.target) {
3463
+ const elComments = this.commentsForElement(info.fp);
3464
+ this.openPopoverFor([info.target], [info.fp], elComments);
3465
+ }
3466
+ });
3467
+ marker.addEventListener("mouseenter", () => this.pulseRelatedElements(info.commentIds, key2));
3468
+ this.root.appendChild(marker);
3469
+ this.markers.set(key2, { marker, target: info.target, fp: info.fp, commentIds: info.commentIds });
3470
+ if (info.target) {
3471
+ this.positionMarker(marker, info.target);
3472
+ this.positionOutline(outlineEl, info.target);
3473
+ } else {
3474
+ marker.style.display = "none";
3475
+ outlineEl.style.display = "none";
3476
+ }
3477
+ }
3478
+ }
3479
+ /** Pulse outlines of OTHER elements that share a multi-anchor comment with this one. */
3480
+ pulseRelatedElements(commentIds, selfKey) {
3481
+ const relatedKeys = /* @__PURE__ */ new Set();
3482
+ for (const cid of commentIds) {
3483
+ const c = this.comments.find((c2) => c2.id === cid);
3484
+ if (!c) continue;
3485
+ const fps = c.fingerprints || [];
3486
+ if (fps.length <= 1) continue;
3487
+ for (const fp of fps) {
3488
+ const k = fingerprintKey(fp);
3489
+ if (k !== selfKey) relatedKeys.add(k);
3490
+ }
3491
+ }
3492
+ for (const key2 of relatedKeys) {
3493
+ const outline = this.outlines.get(key2)?.outline;
3494
+ if (!outline) continue;
3495
+ outline.classList.remove("is-pulsing");
3496
+ void outline.offsetWidth;
3497
+ outline.classList.add("is-pulsing");
3498
+ setTimeout(() => outline.classList.remove("is-pulsing"), 900);
3499
+ }
3500
+ }
3501
+ positionOutline(outline, target) {
3502
+ const rect = target.getBoundingClientRect();
3503
+ if (rect.width === 0 && rect.height === 0) {
3504
+ outline.style.display = "none";
3505
+ return;
3506
+ }
3507
+ outline.style.display = "";
3508
+ outline.style.left = rect.left + "px";
3509
+ outline.style.top = rect.top + "px";
3510
+ outline.style.width = rect.width + "px";
3511
+ outline.style.height = rect.height + "px";
3512
+ }
3513
+ positionMarker(marker, target) {
3514
+ const rect = target.getBoundingClientRect();
3515
+ if (rect.width === 0 && rect.height === 0) {
3516
+ marker.style.display = "none";
3517
+ return;
3518
+ }
3519
+ marker.style.display = "";
3520
+ marker.style.left = rect.right + "px";
3521
+ marker.style.top = rect.top + "px";
3522
+ }
3523
+ /* ===== Active anchor outlines (transient, while popover open or multi-select pending) ===== */
3524
+ syncActiveAnchorOutlines(fps) {
3525
+ if (!this.root) return;
3526
+ const wanted = new Set(fps.map((fp) => fingerprintKey(fp)));
3527
+ for (const [key2, info] of this.activeAnchorOutlines) {
3528
+ if (!wanted.has(key2)) {
3529
+ info.el.remove();
3530
+ this.activeAnchorOutlines.delete(key2);
3531
+ }
3532
+ }
3533
+ for (const fp of fps) {
3534
+ const key2 = fingerprintKey(fp);
3535
+ if (this.activeAnchorOutlines.has(key2)) continue;
3536
+ const outline = document.createElement("div");
3537
+ outline.className = "vz-comment-outline is-selected-pending";
3538
+ this.root.appendChild(outline);
3539
+ const target = findElementByFingerprint(fp);
3540
+ if (target) this.positionOutline(outline, target);
3541
+ else outline.style.display = "none";
3542
+ this.activeAnchorOutlines.set(key2, { el: outline, fp });
3543
+ }
3544
+ }
3545
+ clearActiveAnchorOutlines() {
3546
+ for (const info of this.activeAnchorOutlines.values()) info.el.remove();
3547
+ this.activeAnchorOutlines.clear();
3548
+ }
3549
+ /* ===== Helpers ===== */
3550
+ actionContext() {
3551
+ return {
3552
+ comments: this.getComments(),
3553
+ pageHtml: this.snapshotHtml(),
3554
+ pageUrl: typeof location !== "undefined" ? location.href : void 0,
3555
+ pageVersion: this.opts.pageVersion,
3556
+ user: this.user ?? void 0,
3557
+ timestamp: Date.now(),
3558
+ copyToClipboard: (text) => this.copyToClipboard(text),
3559
+ toast: (msg) => this.pill?.toast(msg)
3560
+ };
3561
+ }
3562
+ snapshotHtml() {
3563
+ const clone = document.documentElement.cloneNode(true);
3564
+ for (const node of clone.querySelectorAll("[data-vizu-root], #vizu-styles")) node.remove();
3565
+ const attrs = document.documentElement.getAttributeNames().map((n) => ` ${n}="${(document.documentElement.getAttribute(n) || "").replace(/"/g, "&quot;")}"`).join("");
3566
+ return "<!DOCTYPE html>\n<html" + attrs + ">\n" + clone.innerHTML + "\n</html>";
3567
+ }
3568
+ copyToClipboard(text) {
3569
+ if (navigator.clipboard?.writeText) {
3570
+ navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
3571
+ } else fallbackCopy(text);
3572
+ }
3573
+ };
3574
+ function fallbackCopy(text) {
3575
+ const ta = document.createElement("textarea");
3576
+ ta.value = text;
3577
+ ta.style.position = "fixed";
3578
+ ta.style.opacity = "0";
3579
+ document.body.appendChild(ta);
3580
+ ta.select();
3581
+ try {
3582
+ document.execCommand("copy");
3583
+ } catch {
3584
+ }
3585
+ ta.remove();
3586
+ }
3587
+ export {
3588
+ CloudStorageAdapter,
3589
+ InMemoryStorageAdapter,
3590
+ LocalStorageAdapter,
3591
+ NullStorageAdapter,
3592
+ SCHEMA_VERSION,
3593
+ Vizu,
3594
+ findByFingerprint,
3595
+ findElementByFingerprint,
3596
+ fingerprint,
3597
+ fingerprintKey,
3598
+ fingerprintLabel
3599
+ };
3600
+ //# sourceMappingURL=index.js.map