@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/LICENSE +21 -0
- package/README.md +428 -0
- package/dist/auto.cjs +5005 -0
- package/dist/auto.cjs.map +1 -0
- package/dist/auto.js +3626 -0
- package/dist/auto.js.map +1 -0
- package/dist/chunk-OMIFOSQ2.js +295 -0
- package/dist/chunk-OMIFOSQ2.js.map +1 -0
- package/dist/index.cjs +4989 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +736 -0
- package/dist/index.d.ts +736 -0
- package/dist/index.js +3600 -0
- package/dist/index.js.map +1 -0
- package/dist/live-collab-IRUNFAPE.js +1033 -0
- package/dist/live-collab-IRUNFAPE.js.map +1 -0
- package/dist/vizu.min.js +1281 -0
- package/dist/vizu.min.js.map +1 -0
- package/package.json +60 -0
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 “" + escapeHtml(textPreview) + (text.length > 60 ? "\u2026" : "") + "”" : ""}</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">“${escapeHtml(textPreview)}${g.fp.textSnippet.length > 60 ? "\u2026" : ""}”</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'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 ? ` “${escapeHtml(snippet)}${(primary?.textSnippet?.length ?? 0) > 60 ? "\u2026" : ""}”` : ""}
|
|
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, """)}"`).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
|