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