@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/auto.cjs
CHANGED
|
@@ -3,9 +3,6 @@ var __defProp = Object.defineProperty;
|
|
|
3
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __esm = (fn, res) => function __init() {
|
|
7
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
-
};
|
|
9
6
|
var __export = (target, all) => {
|
|
10
7
|
for (var name in all)
|
|
11
8
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -20,1515 +17,164 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
20
17
|
};
|
|
21
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
19
|
|
|
23
|
-
// src/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (parent) {
|
|
38
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
39
|
-
if (sameTag.length > 1) {
|
|
40
|
-
const idx = sameTag.indexOf(current);
|
|
41
|
-
part += ":nth-of-type(" + (idx + 1) + ")";
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
parts.unshift(part);
|
|
45
|
-
current = parent;
|
|
46
|
-
depth++;
|
|
20
|
+
// src/auto.ts
|
|
21
|
+
var auto_exports = {};
|
|
22
|
+
__export(auto_exports, {
|
|
23
|
+
Vizu: () => Vizu
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(auto_exports);
|
|
26
|
+
|
|
27
|
+
// src/types.ts
|
|
28
|
+
var SCHEMA_VERSION = 2;
|
|
29
|
+
|
|
30
|
+
// src/util.ts
|
|
31
|
+
function uuid() {
|
|
32
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
33
|
+
return crypto.randomUUID();
|
|
47
34
|
}
|
|
48
|
-
return
|
|
35
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
49
36
|
}
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
37
|
+
function isInside(target, selector) {
|
|
38
|
+
return !!target.closest(selector);
|
|
39
|
+
}
|
|
40
|
+
function escapeHtml(s) {
|
|
41
|
+
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
42
|
+
}
|
|
43
|
+
function formatTime(ts) {
|
|
44
|
+
const d = new Date(ts);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const diff = now - ts;
|
|
47
|
+
if (diff < 6e4) return "just now";
|
|
48
|
+
if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
|
|
49
|
+
if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
|
|
50
|
+
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
51
|
+
}
|
|
52
|
+
function initials(name) {
|
|
53
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
|
|
54
|
+
}
|
|
55
|
+
function avatarHtml(user, className = "vz-comment-author-avatar") {
|
|
56
|
+
if (!user) return `<span class="${className}">\xB7</span>`;
|
|
57
|
+
if (user.avatarUrl) {
|
|
58
|
+
return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
|
|
56
59
|
}
|
|
57
|
-
return {
|
|
58
|
-
selector: buildSelector(el),
|
|
59
|
-
parentSelector: parent ? buildSelector(parent) : "",
|
|
60
|
-
tagName: el.tagName,
|
|
61
|
-
textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
|
|
62
|
-
siblingIndex,
|
|
63
|
-
attributes: {
|
|
64
|
-
id: el.id || void 0,
|
|
65
|
-
classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
|
|
66
|
-
role: el.getAttribute("role") || void 0,
|
|
67
|
-
ariaLabel: el.getAttribute("aria-label") || void 0,
|
|
68
|
-
dataKey: el.getAttribute("data-vizu-key") || void 0
|
|
69
|
-
},
|
|
70
|
-
ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
|
|
71
|
-
algorithmVersion: 2
|
|
72
|
-
};
|
|
60
|
+
return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
|
|
73
61
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
62
|
+
function refId() {
|
|
63
|
+
return Math.random().toString(36).slice(2, 10);
|
|
64
|
+
}
|
|
65
|
+
var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
|
|
66
|
+
var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
|
|
67
|
+
function renderCommentText(text, commentId) {
|
|
68
|
+
const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
|
|
69
|
+
return parts.map((part) => {
|
|
70
|
+
const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
|
|
71
|
+
if (refMatch) {
|
|
72
|
+
return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
|
|
83
73
|
}
|
|
84
|
-
|
|
85
|
-
|
|
74
|
+
const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
|
|
75
|
+
if (mentionMatch) {
|
|
76
|
+
return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
|
|
77
|
+
}
|
|
78
|
+
return escapeHtml(part);
|
|
79
|
+
}).join("");
|
|
80
|
+
}
|
|
81
|
+
function extractMentions(text) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const m of text.matchAll(MENTION_TOKEN_RE)) {
|
|
84
|
+
if (!out.includes(m[2])) out.push(m[2]);
|
|
86
85
|
}
|
|
87
|
-
return
|
|
86
|
+
return out;
|
|
88
87
|
}
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
function renderAttachmentsHtml(attachments) {
|
|
89
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return "";
|
|
90
|
+
const items = attachments.map((a) => {
|
|
91
|
+
const isImage = a.mimeType?.startsWith("image/");
|
|
92
|
+
const filename = a.filename ?? a.url.split("/").pop() ?? "file";
|
|
93
|
+
if (isImage) {
|
|
94
|
+
return `
|
|
95
|
+
<a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
|
|
96
|
+
<img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
|
|
97
|
+
</a>
|
|
98
|
+
`;
|
|
95
99
|
}
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
return `
|
|
101
|
+
<a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
|
|
102
|
+
<span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
|
|
103
|
+
<span class="vz-attachment-filename">${escapeHtml(filename)}</span>
|
|
104
|
+
</a>
|
|
105
|
+
`;
|
|
106
|
+
}).join("");
|
|
107
|
+
return `<div class="vz-attachment-grid">${items}</div>`;
|
|
108
|
+
}
|
|
109
|
+
function migrateComment(raw) {
|
|
110
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
111
|
+
const next = { ...raw };
|
|
112
|
+
if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
|
|
113
|
+
if (next.fingerprint) {
|
|
114
|
+
next.fingerprints = [next.fingerprint];
|
|
103
115
|
}
|
|
104
116
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const m = findByClassSignature(root, fp.tagName, classes);
|
|
109
|
-
if (m?.element) candidates.push({ el: m.element, rung: "class" });
|
|
117
|
+
delete next.fingerprint;
|
|
118
|
+
if (next.url && !next.pageUrl) {
|
|
119
|
+
next.pageUrl = next.url;
|
|
110
120
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
|
|
122
|
+
if (!Array.isArray(next.replies)) next.replies = [];
|
|
123
|
+
if (!Array.isArray(next.attachments)) next.attachments = [];
|
|
124
|
+
if (!Array.isArray(next.mentions)) next.mentions = [];
|
|
125
|
+
if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
|
|
126
|
+
next.schemaVersion = SCHEMA_VERSION;
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
function isValidStatus(s) {
|
|
130
|
+
return s === "open" || s === "resolved" || s === "wontfix";
|
|
131
|
+
}
|
|
132
|
+
function migrateComments(raws) {
|
|
133
|
+
if (!Array.isArray(raws)) return [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const raw of raws) {
|
|
136
|
+
if (!raw || typeof raw !== "object") continue;
|
|
137
|
+
out.push(migrateComment(raw));
|
|
115
138
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function needsPersist(raws) {
|
|
142
|
+
if (!Array.isArray(raws)) return false;
|
|
143
|
+
for (const raw of raws) {
|
|
144
|
+
if (!raw || typeof raw !== "object") continue;
|
|
145
|
+
const c = raw;
|
|
146
|
+
if (c.schemaVersion !== SCHEMA_VERSION) return true;
|
|
147
|
+
if ("fingerprint" in c) return true;
|
|
148
|
+
if (!Array.isArray(c.replies)) return true;
|
|
149
|
+
if (!Array.isArray(c.attachments)) return true;
|
|
150
|
+
if (!Array.isArray(c.mentions)) return true;
|
|
130
151
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/storage.ts
|
|
156
|
+
var key = (ns) => `vizu:comments:${ns}`;
|
|
157
|
+
var LocalStorageAdapter = class {
|
|
158
|
+
async load(namespace) {
|
|
159
|
+
return readArray(namespace);
|
|
134
160
|
}
|
|
135
|
-
|
|
136
|
-
|
|
161
|
+
async addComment(namespace, comment) {
|
|
162
|
+
const list = readArray(namespace);
|
|
163
|
+
list.push(comment);
|
|
164
|
+
writeArray(namespace, list);
|
|
137
165
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
async updateComment(namespace, id, patch) {
|
|
167
|
+
const list = readArray(namespace);
|
|
168
|
+
const idx = list.findIndex((c) => c.id === id);
|
|
169
|
+
if (idx === -1) return null;
|
|
170
|
+
const next = { ...list[idx], ...patch, id: list[idx].id };
|
|
171
|
+
list[idx] = next;
|
|
172
|
+
writeArray(namespace, list);
|
|
173
|
+
return next;
|
|
146
174
|
}
|
|
147
|
-
|
|
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/auto.ts
|
|
1375
|
-
var auto_exports = {};
|
|
1376
|
-
__export(auto_exports, {
|
|
1377
|
-
Vizu: () => Vizu
|
|
1378
|
-
});
|
|
1379
|
-
module.exports = __toCommonJS(auto_exports);
|
|
1380
|
-
|
|
1381
|
-
// src/types.ts
|
|
1382
|
-
var SCHEMA_VERSION = 2;
|
|
1383
|
-
|
|
1384
|
-
// src/util.ts
|
|
1385
|
-
function uuid() {
|
|
1386
|
-
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1387
|
-
return crypto.randomUUID();
|
|
1388
|
-
}
|
|
1389
|
-
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1390
|
-
}
|
|
1391
|
-
function isInside(target, selector) {
|
|
1392
|
-
return !!target.closest(selector);
|
|
1393
|
-
}
|
|
1394
|
-
function escapeHtml(s) {
|
|
1395
|
-
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
1396
|
-
}
|
|
1397
|
-
function formatTime(ts) {
|
|
1398
|
-
const d = new Date(ts);
|
|
1399
|
-
const now = Date.now();
|
|
1400
|
-
const diff = now - ts;
|
|
1401
|
-
if (diff < 6e4) return "just now";
|
|
1402
|
-
if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
|
|
1403
|
-
if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
|
|
1404
|
-
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1405
|
-
}
|
|
1406
|
-
function initials(name) {
|
|
1407
|
-
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
|
|
1408
|
-
}
|
|
1409
|
-
function avatarHtml(user, className = "vz-comment-author-avatar") {
|
|
1410
|
-
if (!user) return `<span class="${className}">\xB7</span>`;
|
|
1411
|
-
if (user.avatarUrl) {
|
|
1412
|
-
return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
|
|
1413
|
-
}
|
|
1414
|
-
return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
|
|
1415
|
-
}
|
|
1416
|
-
function refId() {
|
|
1417
|
-
return Math.random().toString(36).slice(2, 10);
|
|
1418
|
-
}
|
|
1419
|
-
var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
|
|
1420
|
-
var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
|
|
1421
|
-
function renderCommentText(text, commentId) {
|
|
1422
|
-
const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
|
|
1423
|
-
return parts.map((part) => {
|
|
1424
|
-
const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
|
|
1425
|
-
if (refMatch) {
|
|
1426
|
-
return `<a class="vz-ref" data-vz="ref" data-comment-id="${escapeHtml(commentId)}" data-ref-id="${escapeHtml(refMatch[2])}" role="button" tabindex="0">${escapeHtml(refMatch[1])}</a>`;
|
|
1427
|
-
}
|
|
1428
|
-
const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
|
|
1429
|
-
if (mentionMatch) {
|
|
1430
|
-
return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
|
|
1431
|
-
}
|
|
1432
|
-
return escapeHtml(part);
|
|
1433
|
-
}).join("");
|
|
1434
|
-
}
|
|
1435
|
-
function extractMentions(text) {
|
|
1436
|
-
const out = [];
|
|
1437
|
-
for (const m of text.matchAll(MENTION_TOKEN_RE)) {
|
|
1438
|
-
if (!out.includes(m[2])) out.push(m[2]);
|
|
1439
|
-
}
|
|
1440
|
-
return out;
|
|
1441
|
-
}
|
|
1442
|
-
function renderAttachmentsHtml(attachments) {
|
|
1443
|
-
if (!Array.isArray(attachments) || attachments.length === 0) return "";
|
|
1444
|
-
const items = attachments.map((a) => {
|
|
1445
|
-
const isImage = a.mimeType?.startsWith("image/");
|
|
1446
|
-
const filename = a.filename ?? a.url.split("/").pop() ?? "file";
|
|
1447
|
-
if (isImage) {
|
|
1448
|
-
return `
|
|
1449
|
-
<a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
|
|
1450
|
-
<img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
|
|
1451
|
-
</a>
|
|
1452
|
-
`;
|
|
1453
|
-
}
|
|
1454
|
-
return `
|
|
1455
|
-
<a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
|
|
1456
|
-
<span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
|
|
1457
|
-
<span class="vz-attachment-filename">${escapeHtml(filename)}</span>
|
|
1458
|
-
</a>
|
|
1459
|
-
`;
|
|
1460
|
-
}).join("");
|
|
1461
|
-
return `<div class="vz-attachment-grid">${items}</div>`;
|
|
1462
|
-
}
|
|
1463
|
-
function migrateComment(raw) {
|
|
1464
|
-
if (!raw || typeof raw !== "object") return raw;
|
|
1465
|
-
const next = { ...raw };
|
|
1466
|
-
if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
|
|
1467
|
-
if (next.fingerprint) {
|
|
1468
|
-
next.fingerprints = [next.fingerprint];
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
delete next.fingerprint;
|
|
1472
|
-
if (next.url && !next.pageUrl) {
|
|
1473
|
-
next.pageUrl = next.url;
|
|
1474
|
-
}
|
|
1475
|
-
if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
|
|
1476
|
-
if (!Array.isArray(next.replies)) next.replies = [];
|
|
1477
|
-
if (!Array.isArray(next.attachments)) next.attachments = [];
|
|
1478
|
-
if (!Array.isArray(next.mentions)) next.mentions = [];
|
|
1479
|
-
if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
|
|
1480
|
-
next.schemaVersion = SCHEMA_VERSION;
|
|
1481
|
-
return next;
|
|
1482
|
-
}
|
|
1483
|
-
function isValidStatus(s) {
|
|
1484
|
-
return s === "open" || s === "resolved" || s === "wontfix";
|
|
1485
|
-
}
|
|
1486
|
-
function migrateComments(raws) {
|
|
1487
|
-
if (!Array.isArray(raws)) return [];
|
|
1488
|
-
const out = [];
|
|
1489
|
-
for (const raw of raws) {
|
|
1490
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1491
|
-
out.push(migrateComment(raw));
|
|
1492
|
-
}
|
|
1493
|
-
return out;
|
|
1494
|
-
}
|
|
1495
|
-
function needsPersist(raws) {
|
|
1496
|
-
if (!Array.isArray(raws)) return false;
|
|
1497
|
-
for (const raw of raws) {
|
|
1498
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1499
|
-
const c = raw;
|
|
1500
|
-
if (c.schemaVersion !== SCHEMA_VERSION) return true;
|
|
1501
|
-
if ("fingerprint" in c) return true;
|
|
1502
|
-
if (!Array.isArray(c.replies)) return true;
|
|
1503
|
-
if (!Array.isArray(c.attachments)) return true;
|
|
1504
|
-
if (!Array.isArray(c.mentions)) return true;
|
|
1505
|
-
}
|
|
1506
|
-
return false;
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// src/storage.ts
|
|
1510
|
-
var key = (ns) => `vizu:comments:${ns}`;
|
|
1511
|
-
var LocalStorageAdapter = class {
|
|
1512
|
-
async load(namespace) {
|
|
1513
|
-
return readArray(namespace);
|
|
1514
|
-
}
|
|
1515
|
-
async addComment(namespace, comment) {
|
|
1516
|
-
const list = readArray(namespace);
|
|
1517
|
-
list.push(comment);
|
|
1518
|
-
writeArray(namespace, list);
|
|
1519
|
-
}
|
|
1520
|
-
async updateComment(namespace, id, patch) {
|
|
1521
|
-
const list = readArray(namespace);
|
|
1522
|
-
const idx = list.findIndex((c) => c.id === id);
|
|
1523
|
-
if (idx === -1) return null;
|
|
1524
|
-
const next = { ...list[idx], ...patch, id: list[idx].id };
|
|
1525
|
-
list[idx] = next;
|
|
1526
|
-
writeArray(namespace, list);
|
|
1527
|
-
return next;
|
|
1528
|
-
}
|
|
1529
|
-
async removeComment(namespace, id) {
|
|
1530
|
-
const list = readArray(namespace);
|
|
1531
|
-
writeArray(namespace, list.filter((c) => c.id !== id));
|
|
175
|
+
async removeComment(namespace, id) {
|
|
176
|
+
const list = readArray(namespace);
|
|
177
|
+
writeArray(namespace, list.filter((c) => c.id !== id));
|
|
1532
178
|
}
|
|
1533
179
|
async setAll(namespace, comments) {
|
|
1534
180
|
writeArray(namespace, comments);
|
|
@@ -1787,278 +433,562 @@ var CloudStorageAdapter = class {
|
|
|
1787
433
|
throw apiErr(res.status, json);
|
|
1788
434
|
}
|
|
1789
435
|
}
|
|
1790
|
-
async setAll(namespace, comments) {
|
|
1791
|
-
await this.clear(namespace);
|
|
1792
|
-
for (const c of comments) {
|
|
1793
|
-
await this.addComment(namespace, c);
|
|
436
|
+
async setAll(namespace, comments) {
|
|
437
|
+
await this.clear(namespace);
|
|
438
|
+
for (const c of comments) {
|
|
439
|
+
await this.addComment(namespace, c);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async clear(_namespace) {
|
|
443
|
+
const url = new URL(this.apiUrl + "/api/comments");
|
|
444
|
+
url.searchParams.set("workspace", this.workspace);
|
|
445
|
+
url.searchParams.set("all", "true");
|
|
446
|
+
const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
|
|
447
|
+
if (res.status === 404) return;
|
|
448
|
+
if (!res.ok) {
|
|
449
|
+
const json = await this.parseJson(res);
|
|
450
|
+
throw apiErr(res.status, json);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async addReply(_namespace, commentId, reply) {
|
|
454
|
+
const url = new URL(this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies");
|
|
455
|
+
url.searchParams.set("workspace", this.workspace);
|
|
456
|
+
const res = await this.fetchAuthed(url.toString(), {
|
|
457
|
+
method: "POST",
|
|
458
|
+
headers: { "content-type": "application/json" },
|
|
459
|
+
body: JSON.stringify(reply)
|
|
460
|
+
});
|
|
461
|
+
if (res.status === 404) return null;
|
|
462
|
+
const json = await this.parseJson(res);
|
|
463
|
+
if (!res.ok) throw apiErr(res.status, json);
|
|
464
|
+
return json?.comment ? migrateComment(json.comment) : null;
|
|
465
|
+
}
|
|
466
|
+
async removeReply(_namespace, commentId, replyId) {
|
|
467
|
+
const url = new URL(
|
|
468
|
+
this.apiUrl + "/api/comments/" + encodeURIComponent(commentId) + "/replies/" + encodeURIComponent(replyId)
|
|
469
|
+
);
|
|
470
|
+
url.searchParams.set("workspace", this.workspace);
|
|
471
|
+
const res = await this.fetchAuthed(url.toString(), { method: "DELETE" });
|
|
472
|
+
if (res.status === 404) return null;
|
|
473
|
+
if (!res.ok) {
|
|
474
|
+
const json = await this.parseJson(res);
|
|
475
|
+
throw apiErr(res.status, json);
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Upload a file as multipart/form-data to the host backend, which
|
|
481
|
+
* forwards to Vercel Blob server-side. Stays under the 4.5 MB Vercel
|
|
482
|
+
* function body cap (our own 5 MB attachment cap will be enforced
|
|
483
|
+
* server-side before that's hit).
|
|
484
|
+
*
|
|
485
|
+
* Deliberately doesn't use `@vercel/blob/client`'s direct-upload flow:
|
|
486
|
+
* - keeps `@vizu/core` free of any `@vercel/blob` dependency
|
|
487
|
+
* (script-tag and local-only consumers don't pull blob code)
|
|
488
|
+
* - the multipart path is one round-trip; the direct-upload flow
|
|
489
|
+
* is two (signed URL + upload). For attachments capped at 5 MB,
|
|
490
|
+
* the savings don't justify the dep cost.
|
|
491
|
+
*/
|
|
492
|
+
async uploadAttachment(_namespace, file) {
|
|
493
|
+
const form = new FormData();
|
|
494
|
+
form.append("file", file, file.name);
|
|
495
|
+
const url = new URL(this.apiUrl + "/api/uploads");
|
|
496
|
+
url.searchParams.set("workspace", this.workspace);
|
|
497
|
+
const res = await this.fetchAuthed(url.toString(), {
|
|
498
|
+
method: "POST",
|
|
499
|
+
body: form
|
|
500
|
+
// Don't set content-type; the browser fills in the multipart boundary.
|
|
501
|
+
});
|
|
502
|
+
const json = await this.parseJson(res);
|
|
503
|
+
if (!res.ok) throw apiErr(res.status, json);
|
|
504
|
+
if (!json?.url || typeof json.url !== "string") {
|
|
505
|
+
throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
id: uuid(),
|
|
509
|
+
url: json.url,
|
|
510
|
+
mimeType: file.type || "application/octet-stream",
|
|
511
|
+
sizeBytes: file.size,
|
|
512
|
+
uploadedAt: Date.now(),
|
|
513
|
+
filename: file.name
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
getAuthContext() {
|
|
517
|
+
if (!this.cachedToken) return null;
|
|
518
|
+
return {
|
|
519
|
+
userId: this.cachedToken.userId,
|
|
520
|
+
token: this.cachedToken.token,
|
|
521
|
+
expiresAt: this.cachedToken.expiresAt
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/* ─── Auth ────────────────────────────────────────────────────────── */
|
|
525
|
+
isExpired(t) {
|
|
526
|
+
if (!t) return true;
|
|
527
|
+
const exp = new Date(t.expiresAt).getTime();
|
|
528
|
+
return !Number.isFinite(exp) || exp - Date.now() < 3e4;
|
|
529
|
+
}
|
|
530
|
+
readStoredToken() {
|
|
531
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
532
|
+
const raw = sessionStorage.getItem(this.sessionKey());
|
|
533
|
+
if (!raw) return null;
|
|
534
|
+
try {
|
|
535
|
+
const parsed = JSON.parse(raw);
|
|
536
|
+
if (this.isExpired(parsed)) {
|
|
537
|
+
sessionStorage.removeItem(this.sessionKey());
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
return parsed;
|
|
541
|
+
} catch {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
writeStoredToken(t) {
|
|
546
|
+
this.cachedToken = t;
|
|
547
|
+
if (typeof sessionStorage !== "undefined") {
|
|
548
|
+
sessionStorage.setItem(this.sessionKey(), JSON.stringify(t));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
clearStoredToken() {
|
|
552
|
+
this.cachedToken = null;
|
|
553
|
+
if (typeof sessionStorage !== "undefined") {
|
|
554
|
+
sessionStorage.removeItem(this.sessionKey());
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
sessionKey() {
|
|
558
|
+
return `vizu:cloud-token:v2:${this.workspace}`;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Resolve a valid token, opening the popup if needed.
|
|
562
|
+
*
|
|
563
|
+
* Single-flight: simultaneous requests await one popup, not N.
|
|
564
|
+
* Caller-friendly: throws a Error with `.code === 'auth_canceled'`
|
|
565
|
+
* if the user closes the popup; hosts can catch this and present
|
|
566
|
+
* a friendly message instead of a blank failure.
|
|
567
|
+
*/
|
|
568
|
+
async resolveToken() {
|
|
569
|
+
if (this.cachedToken && !this.isExpired(this.cachedToken)) return this.cachedToken;
|
|
570
|
+
const fresh = this.readStoredToken();
|
|
571
|
+
if (fresh) {
|
|
572
|
+
this.cachedToken = fresh;
|
|
573
|
+
this.fireAuthChanged(fresh);
|
|
574
|
+
return fresh;
|
|
575
|
+
}
|
|
576
|
+
if (!this.autoSignIn) {
|
|
577
|
+
throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
|
|
578
|
+
}
|
|
579
|
+
if (!this.pendingAuth) {
|
|
580
|
+
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
581
|
+
this.writeStoredToken(t);
|
|
582
|
+
this.fireAuthChanged(t);
|
|
583
|
+
return t;
|
|
584
|
+
}).finally(() => {
|
|
585
|
+
this.pendingAuth = null;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return this.pendingAuth;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Notify the host that the authenticated identity changed. De-duped
|
|
592
|
+
* via the token string so multiple resolveToken() hits with the same
|
|
593
|
+
* cached token don't re-call setUser on every fetch.
|
|
594
|
+
*/
|
|
595
|
+
fireAuthChanged(token) {
|
|
596
|
+
if (this.lastNotifiedTokenId === token.token) return;
|
|
597
|
+
this.lastNotifiedTokenId = token.token;
|
|
598
|
+
try {
|
|
599
|
+
this.onAuthChanged?.({ userId: token.userId, user: token.user });
|
|
600
|
+
} catch (err) {
|
|
601
|
+
if (typeof console !== "undefined") {
|
|
602
|
+
console.warn("[vizu/cloud] onAuthChanged callback threw", err);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
openSignInPopup() {
|
|
607
|
+
return new Promise((resolve, reject) => {
|
|
608
|
+
if (typeof window === "undefined") {
|
|
609
|
+
return reject(makeAuthError("auth_unavailable", "Sign-in requires a browser environment."));
|
|
610
|
+
}
|
|
611
|
+
const apiOrigin = new URL(this.apiUrl).origin;
|
|
612
|
+
const hostOrigin = window.location.origin;
|
|
613
|
+
const popupUrl = this.apiUrl + "/connect?workspace=" + encodeURIComponent(this.workspace) + "&origin=" + encodeURIComponent(hostOrigin);
|
|
614
|
+
const left = Math.max(0, Math.round((window.screen.width - POPUP_WIDTH) / 2));
|
|
615
|
+
const top = Math.max(0, Math.round((window.screen.height - POPUP_HEIGHT) / 2));
|
|
616
|
+
const popup = window.open(
|
|
617
|
+
popupUrl,
|
|
618
|
+
"vizu-connect",
|
|
619
|
+
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},noopener=no,popup=yes`
|
|
620
|
+
);
|
|
621
|
+
if (!popup) {
|
|
622
|
+
return reject(
|
|
623
|
+
makeAuthError(
|
|
624
|
+
"popup_blocked",
|
|
625
|
+
"Sign-in popup was blocked by the browser. Allow popups for this site and try again."
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
let resolved = false;
|
|
630
|
+
const onMessage = (e) => {
|
|
631
|
+
if (e.origin !== apiOrigin) return;
|
|
632
|
+
const data = e.data;
|
|
633
|
+
if (!data || data.type !== "vizu:auth") return;
|
|
634
|
+
if (data.workspace !== this.workspace) return;
|
|
635
|
+
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
636
|
+
resolved = true;
|
|
637
|
+
cleanup();
|
|
638
|
+
const userPayload = data.user;
|
|
639
|
+
const user = userPayload && typeof userPayload.name === "string" ? {
|
|
640
|
+
id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
|
|
641
|
+
name: userPayload.name,
|
|
642
|
+
avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
|
|
643
|
+
} : null;
|
|
644
|
+
resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
|
|
645
|
+
};
|
|
646
|
+
const onPoll = window.setInterval(() => {
|
|
647
|
+
if (popup.closed) {
|
|
648
|
+
if (!resolved) {
|
|
649
|
+
cleanup();
|
|
650
|
+
reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}, 500);
|
|
654
|
+
function cleanup() {
|
|
655
|
+
window.clearInterval(onPoll);
|
|
656
|
+
window.removeEventListener("message", onMessage);
|
|
657
|
+
}
|
|
658
|
+
window.addEventListener("message", onMessage);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
/* ─── Fetch wrapper ───────────────────────────────────────────────── */
|
|
662
|
+
/**
|
|
663
|
+
* Perform an authenticated fetch with one-shot retry on 401 (re-opens
|
|
664
|
+
* the popup if the token expired during the request). 4xx other than
|
|
665
|
+
* 401 / 404 throw; 5xx throws; 2xx returns.
|
|
666
|
+
*/
|
|
667
|
+
async fetchAuthed(url, init2, retried = false) {
|
|
668
|
+
const token = await this.resolveToken();
|
|
669
|
+
const headers = new Headers(init2.headers);
|
|
670
|
+
headers.set("authorization", `Bearer ${token.token}`);
|
|
671
|
+
const res = await fetch(url, { ...init2, headers, credentials: "omit" });
|
|
672
|
+
if (res.status === 401 && !retried) {
|
|
673
|
+
this.clearStoredToken();
|
|
674
|
+
return this.fetchAuthed(url, init2, true);
|
|
675
|
+
}
|
|
676
|
+
return res;
|
|
677
|
+
}
|
|
678
|
+
async parseJson(res) {
|
|
679
|
+
try {
|
|
680
|
+
const text = await res.text();
|
|
681
|
+
return text ? JSON.parse(text) : null;
|
|
682
|
+
} catch {
|
|
683
|
+
return null;
|
|
1794
684
|
}
|
|
1795
685
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
686
|
+
};
|
|
687
|
+
function makeAuthError(code, message) {
|
|
688
|
+
const err = new Error(message);
|
|
689
|
+
err.code = code;
|
|
690
|
+
return err;
|
|
691
|
+
}
|
|
692
|
+
function apiErr(status, body) {
|
|
693
|
+
const code = body?.error === "origin_not_allowed" ? "origin_not_allowed" : body?.error === "not_found" ? "workspace_not_found" : "http_error";
|
|
694
|
+
const err = new Error(body?.message ?? `HTTP ${status}`);
|
|
695
|
+
err.code = code;
|
|
696
|
+
err.status = status;
|
|
697
|
+
err.body = body;
|
|
698
|
+
return err;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/fingerprint.ts
|
|
702
|
+
var TEXT_SNIPPET_MAX = 80;
|
|
703
|
+
var ANCESTOR_DEPTH = 4;
|
|
704
|
+
var ANCESTOR_MAX_SHIFT = 3;
|
|
705
|
+
var TEXT_RATIO_THRESHOLD = 0.75;
|
|
706
|
+
var CLASS_JACCARD_THRESHOLD = 0.6;
|
|
707
|
+
function buildSelector(el) {
|
|
708
|
+
const parts = [];
|
|
709
|
+
let current = el;
|
|
710
|
+
let depth = 0;
|
|
711
|
+
while (current && current !== document.documentElement && depth < 8) {
|
|
712
|
+
let part = current.tagName.toLowerCase();
|
|
713
|
+
if (current.id) {
|
|
714
|
+
parts.unshift("#" + CSS.escape(current.id));
|
|
715
|
+
break;
|
|
1805
716
|
}
|
|
717
|
+
const cls = Array.from(current.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")).slice(0, 2);
|
|
718
|
+
if (cls.length) part += "." + cls.map((c) => CSS.escape(c)).join(".");
|
|
719
|
+
const parent = current.parentElement;
|
|
720
|
+
if (parent) {
|
|
721
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
722
|
+
if (sameTag.length > 1) {
|
|
723
|
+
const idx = sameTag.indexOf(current);
|
|
724
|
+
part += ":nth-of-type(" + (idx + 1) + ")";
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
parts.unshift(part);
|
|
728
|
+
current = parent;
|
|
729
|
+
depth++;
|
|
1806
730
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
if (res.status === 404) return null;
|
|
1816
|
-
const json = await this.parseJson(res);
|
|
1817
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1818
|
-
return json?.comment ? migrateComment(json.comment) : null;
|
|
731
|
+
return parts.join(" > ");
|
|
732
|
+
}
|
|
733
|
+
function fingerprint(el) {
|
|
734
|
+
const text = el.innerText || el.textContent || "";
|
|
735
|
+
const parent = el.parentElement;
|
|
736
|
+
let siblingIndex = 0;
|
|
737
|
+
if (parent) {
|
|
738
|
+
siblingIndex = Array.from(parent.children).indexOf(el);
|
|
1819
739
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
740
|
+
return {
|
|
741
|
+
selector: buildSelector(el),
|
|
742
|
+
parentSelector: parent ? buildSelector(parent) : "",
|
|
743
|
+
tagName: el.tagName,
|
|
744
|
+
textSnippet: text.trim().slice(0, TEXT_SNIPPET_MAX),
|
|
745
|
+
siblingIndex,
|
|
746
|
+
attributes: {
|
|
747
|
+
id: el.id || void 0,
|
|
748
|
+
classList: Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")),
|
|
749
|
+
role: el.getAttribute("role") || void 0,
|
|
750
|
+
ariaLabel: el.getAttribute("aria-label") || void 0,
|
|
751
|
+
dataKey: el.getAttribute("data-vizu-key") || void 0
|
|
752
|
+
},
|
|
753
|
+
ancestorChain: captureAncestorChain(el, ANCESTOR_DEPTH),
|
|
754
|
+
algorithmVersion: 2
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function captureAncestorChain(el, depth) {
|
|
758
|
+
const steps = [];
|
|
759
|
+
let current = el.parentElement;
|
|
760
|
+
while (current && steps.length < depth) {
|
|
761
|
+
const parent = current.parentElement;
|
|
762
|
+
let nthOfType = 1;
|
|
763
|
+
if (parent) {
|
|
764
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
765
|
+
nthOfType = sameTag.indexOf(current) + 1;
|
|
1830
766
|
}
|
|
1831
|
-
|
|
767
|
+
steps.push({ tag: current.tagName, nthOfType });
|
|
768
|
+
current = parent;
|
|
1832
769
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
* (script-tag and local-only consumers don't pull blob code)
|
|
1842
|
-
* - the multipart path is one round-trip; the direct-upload flow
|
|
1843
|
-
* is two (signed URL + upload). For attachments capped at 5 MB,
|
|
1844
|
-
* the savings don't justify the dep cost.
|
|
1845
|
-
*/
|
|
1846
|
-
async uploadAttachment(_namespace, file) {
|
|
1847
|
-
const form = new FormData();
|
|
1848
|
-
form.append("file", file, file.name);
|
|
1849
|
-
const url = new URL(this.apiUrl + "/api/uploads");
|
|
1850
|
-
url.searchParams.set("workspace", this.workspace);
|
|
1851
|
-
const res = await this.fetchAuthed(url.toString(), {
|
|
1852
|
-
method: "POST",
|
|
1853
|
-
body: form
|
|
1854
|
-
// Don't set content-type; the browser fills in the multipart boundary.
|
|
1855
|
-
});
|
|
1856
|
-
const json = await this.parseJson(res);
|
|
1857
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1858
|
-
if (!json?.url || typeof json.url !== "string") {
|
|
1859
|
-
throw apiErr(500, { error: "upload_failed", message: "Backend did not return a URL." });
|
|
770
|
+
return steps.length > 0 ? { steps } : void 0;
|
|
771
|
+
}
|
|
772
|
+
function findByFingerprint(fp, root = document) {
|
|
773
|
+
if (fp.attributes.dataKey) {
|
|
774
|
+
try {
|
|
775
|
+
const byKey = root.querySelector(`[data-vizu-key="${CSS.escape(fp.attributes.dataKey)}"]`);
|
|
776
|
+
if (byKey) return { element: byKey, confidence: "exact" };
|
|
777
|
+
} catch {
|
|
1860
778
|
}
|
|
1861
|
-
return {
|
|
1862
|
-
id: uuid(),
|
|
1863
|
-
url: json.url,
|
|
1864
|
-
mimeType: file.type || "application/octet-stream",
|
|
1865
|
-
sizeBytes: file.size,
|
|
1866
|
-
uploadedAt: Date.now(),
|
|
1867
|
-
filename: file.name
|
|
1868
|
-
};
|
|
779
|
+
return { element: null, confidence: "orphaned" };
|
|
1869
780
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
};
|
|
781
|
+
if (fp.attributes.id) {
|
|
782
|
+
try {
|
|
783
|
+
const byId = root.querySelector("#" + CSS.escape(fp.attributes.id));
|
|
784
|
+
if (byId) return { element: byId, confidence: "exact" };
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
1877
787
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
const
|
|
1882
|
-
|
|
788
|
+
const candidates = [];
|
|
789
|
+
const classes = fp.attributes.classList;
|
|
790
|
+
if (classes && classes.length > 0) {
|
|
791
|
+
const m = findByClassSignature(root, fp.tagName, classes);
|
|
792
|
+
if (m?.element) candidates.push({ el: m.element, rung: "class" });
|
|
1883
793
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
794
|
+
try {
|
|
795
|
+
const bySelector = root.querySelector(fp.selector);
|
|
796
|
+
if (bySelector) candidates.push({ el: bySelector, rung: "selector" });
|
|
797
|
+
} catch {
|
|
798
|
+
}
|
|
799
|
+
if (fp.ancestorChain && fp.ancestorChain.steps.length > 0) {
|
|
800
|
+
const m = findByAncestorChain(root, fp.tagName, fp.ancestorChain.steps);
|
|
801
|
+
if (m?.element) candidates.push({ el: m.element, rung: "chain" });
|
|
802
|
+
} else {
|
|
1888
803
|
try {
|
|
1889
|
-
const
|
|
1890
|
-
if (
|
|
1891
|
-
|
|
1892
|
-
|
|
804
|
+
const parent = fp.parentSelector ? root.querySelector(fp.parentSelector) : root;
|
|
805
|
+
if (parent) {
|
|
806
|
+
const candidate = parent.children[fp.siblingIndex];
|
|
807
|
+
if (candidate && candidate.tagName === fp.tagName) {
|
|
808
|
+
candidates.push({ el: candidate, rung: "chain" });
|
|
809
|
+
}
|
|
1893
810
|
}
|
|
1894
|
-
return parsed;
|
|
1895
811
|
} catch {
|
|
1896
|
-
return null;
|
|
1897
812
|
}
|
|
1898
813
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
if (
|
|
1902
|
-
|
|
814
|
+
if (fp.textSnippet) {
|
|
815
|
+
const m = findByTextSnippet(root, fp.tagName, fp.textSnippet);
|
|
816
|
+
if (m?.element) candidates.push({ el: m.element, rung: "text" });
|
|
817
|
+
}
|
|
818
|
+
if (candidates.length === 0) {
|
|
819
|
+
return { element: null, confidence: "orphaned" };
|
|
820
|
+
}
|
|
821
|
+
const votes = /* @__PURE__ */ new Map();
|
|
822
|
+
for (const c of candidates) {
|
|
823
|
+
let s = votes.get(c.el);
|
|
824
|
+
if (!s) {
|
|
825
|
+
s = /* @__PURE__ */ new Set();
|
|
826
|
+
votes.set(c.el, s);
|
|
1903
827
|
}
|
|
828
|
+
s.add(c.rung);
|
|
1904
829
|
}
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
if (
|
|
1908
|
-
|
|
830
|
+
let best = null;
|
|
831
|
+
for (const [el, rungs] of votes) {
|
|
832
|
+
if (!best || rungs.size > best.rungs.size) best = { el, rungs };
|
|
833
|
+
}
|
|
834
|
+
if (!best) return { element: null, confidence: "orphaned" };
|
|
835
|
+
if (best.rungs.size >= 3) return { element: best.el, confidence: "likely" };
|
|
836
|
+
if (best.rungs.size >= 2) return { element: best.el, confidence: "drifted" };
|
|
837
|
+
if (best.rungs.has("class") || best.rungs.has("chain")) {
|
|
838
|
+
return { element: best.el, confidence: "drifted" };
|
|
839
|
+
}
|
|
840
|
+
return { element: null, confidence: "orphaned" };
|
|
841
|
+
}
|
|
842
|
+
function findByClassSignature(root, tagName, needle) {
|
|
843
|
+
const needleSet = new Set(needle.filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-")));
|
|
844
|
+
if (needleSet.size === 0) return null;
|
|
845
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
846
|
+
let best = null;
|
|
847
|
+
let secondScore = 0;
|
|
848
|
+
for (const el of all) {
|
|
849
|
+
const haystack = new Set(
|
|
850
|
+
Array.from(el.classList).filter((c) => !c.startsWith("vz-") && !c.startsWith("vizu-"))
|
|
851
|
+
);
|
|
852
|
+
if (haystack.size === 0) continue;
|
|
853
|
+
const score = jaccardSimilarity(needleSet, haystack);
|
|
854
|
+
if (score < CLASS_JACCARD_THRESHOLD) continue;
|
|
855
|
+
if (!best || score > best.score) {
|
|
856
|
+
secondScore = best?.score ?? 0;
|
|
857
|
+
best = { el, score };
|
|
858
|
+
} else if (score > secondScore) {
|
|
859
|
+
secondScore = score;
|
|
1909
860
|
}
|
|
1910
861
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
862
|
+
if (!best) return null;
|
|
863
|
+
if (best.score - secondScore < 0.1) return null;
|
|
864
|
+
return { element: best.el, confidence: "likely" };
|
|
865
|
+
}
|
|
866
|
+
function findByAncestorChain(root, tagName, steps) {
|
|
867
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
868
|
+
let best = null;
|
|
869
|
+
for (const el of all) {
|
|
870
|
+
const ancestors = collectAncestorSteps(el, steps.length + ANCESTOR_MAX_SHIFT);
|
|
871
|
+
const score = scoreChainAgainstAncestors(ancestors, steps, ANCESTOR_MAX_SHIFT);
|
|
872
|
+
if (score < 2) continue;
|
|
873
|
+
if (!best || score > best.score) best = { el, score };
|
|
1913
874
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
}
|
|
1930
|
-
if (!this.autoSignIn) {
|
|
1931
|
-
throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
|
|
1932
|
-
}
|
|
1933
|
-
if (!this.pendingAuth) {
|
|
1934
|
-
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
1935
|
-
this.writeStoredToken(t);
|
|
1936
|
-
this.fireAuthChanged(t);
|
|
1937
|
-
return t;
|
|
1938
|
-
}).finally(() => {
|
|
1939
|
-
this.pendingAuth = null;
|
|
1940
|
-
});
|
|
875
|
+
if (!best) return null;
|
|
876
|
+
return {
|
|
877
|
+
element: best.el,
|
|
878
|
+
confidence: best.score === steps.length ? "likely" : "drifted"
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function collectAncestorSteps(el, limit) {
|
|
882
|
+
const steps = [];
|
|
883
|
+
let current = el.parentElement;
|
|
884
|
+
while (current && steps.length < limit) {
|
|
885
|
+
const parent = current.parentElement;
|
|
886
|
+
let nth = 1;
|
|
887
|
+
if (parent) {
|
|
888
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
|
|
889
|
+
nth = sameTag.indexOf(current) + 1;
|
|
1941
890
|
}
|
|
1942
|
-
|
|
891
|
+
steps.push({ tag: current.tagName, nthOfType: nth });
|
|
892
|
+
current = parent;
|
|
1943
893
|
}
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
894
|
+
return steps;
|
|
895
|
+
}
|
|
896
|
+
function findByTextSnippet(root, tagName, snippet) {
|
|
897
|
+
const needle = normalizeText(snippet);
|
|
898
|
+
if (!needle) return null;
|
|
899
|
+
const all = root.querySelectorAll(tagName.toLowerCase());
|
|
900
|
+
let best = null;
|
|
901
|
+
for (const el of all) {
|
|
902
|
+
const raw = el.innerText || el.textContent || "";
|
|
903
|
+
const candidate = normalizeText(raw.slice(0, TEXT_SNIPPET_MAX * 2));
|
|
904
|
+
if (!candidate) continue;
|
|
905
|
+
const score = levenshteinRatio(needle, candidate);
|
|
906
|
+
if (score < TEXT_RATIO_THRESHOLD) continue;
|
|
907
|
+
if (!best || score > best.score) {
|
|
908
|
+
best = { el, score };
|
|
1958
909
|
}
|
|
1959
910
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
makeAuthError(
|
|
1978
|
-
"popup_blocked",
|
|
1979
|
-
"Sign-in popup was blocked by the browser. Allow popups for this site and try again."
|
|
1980
|
-
)
|
|
1981
|
-
);
|
|
1982
|
-
}
|
|
1983
|
-
let resolved = false;
|
|
1984
|
-
const onMessage = (e) => {
|
|
1985
|
-
if (e.origin !== apiOrigin) return;
|
|
1986
|
-
const data = e.data;
|
|
1987
|
-
if (!data || data.type !== "vizu:auth") return;
|
|
1988
|
-
if (data.workspace !== this.workspace) return;
|
|
1989
|
-
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
1990
|
-
resolved = true;
|
|
1991
|
-
cleanup();
|
|
1992
|
-
const userPayload = data.user;
|
|
1993
|
-
const user = userPayload && typeof userPayload.name === "string" ? {
|
|
1994
|
-
id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
|
|
1995
|
-
name: userPayload.name,
|
|
1996
|
-
avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
|
|
1997
|
-
} : null;
|
|
1998
|
-
resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
|
|
1999
|
-
};
|
|
2000
|
-
const onPoll = window.setInterval(() => {
|
|
2001
|
-
if (popup.closed) {
|
|
2002
|
-
if (!resolved) {
|
|
2003
|
-
cleanup();
|
|
2004
|
-
reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
}, 500);
|
|
2008
|
-
function cleanup() {
|
|
2009
|
-
window.clearInterval(onPoll);
|
|
2010
|
-
window.removeEventListener("message", onMessage);
|
|
2011
|
-
}
|
|
2012
|
-
window.addEventListener("message", onMessage);
|
|
2013
|
-
});
|
|
911
|
+
return best ? { element: best.el, confidence: "drifted" } : null;
|
|
912
|
+
}
|
|
913
|
+
function normalizeText(input) {
|
|
914
|
+
return input.toLowerCase().replace(/\s+/g, " ").trim();
|
|
915
|
+
}
|
|
916
|
+
function levenshteinRatio(a, b) {
|
|
917
|
+
if (a === b) return 1;
|
|
918
|
+
if (a.length === 0 || b.length === 0) return a.length === b.length ? 1 : 0;
|
|
919
|
+
const maxLen = Math.max(a.length, b.length);
|
|
920
|
+
const distance = levenshteinDistance(a, b);
|
|
921
|
+
return 1 - distance / maxLen;
|
|
922
|
+
}
|
|
923
|
+
function levenshteinDistance(a, b) {
|
|
924
|
+
if (a.length > b.length) {
|
|
925
|
+
const tmp = a;
|
|
926
|
+
a = b;
|
|
927
|
+
b = tmp;
|
|
2014
928
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
929
|
+
const prev = new Array(a.length + 1);
|
|
930
|
+
const curr = new Array(a.length + 1);
|
|
931
|
+
for (let i = 0; i <= a.length; i++) prev[i] = i;
|
|
932
|
+
for (let j = 1; j <= b.length; j++) {
|
|
933
|
+
curr[0] = j;
|
|
934
|
+
for (let i = 1; i <= a.length; i++) {
|
|
935
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
936
|
+
curr[i] = Math.min(
|
|
937
|
+
curr[i - 1] + 1,
|
|
938
|
+
// insert
|
|
939
|
+
prev[i] + 1,
|
|
940
|
+
// delete
|
|
941
|
+
prev[i - 1] + cost
|
|
942
|
+
// substitute
|
|
943
|
+
);
|
|
2029
944
|
}
|
|
2030
|
-
|
|
945
|
+
for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
|
|
2031
946
|
}
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
947
|
+
return prev[a.length];
|
|
948
|
+
}
|
|
949
|
+
function scoreChainAgainstAncestors(actual, captured, maxShift) {
|
|
950
|
+
let best = 0;
|
|
951
|
+
for (let offset = 0; offset <= maxShift; offset++) {
|
|
952
|
+
let score = 0;
|
|
953
|
+
for (let i = 0; i < captured.length; i++) {
|
|
954
|
+
const a = actual[i + offset];
|
|
955
|
+
if (!a) break;
|
|
956
|
+
if (a.tag === captured[i].tag && a.nthOfType === captured[i].nthOfType) {
|
|
957
|
+
score++;
|
|
958
|
+
}
|
|
2038
959
|
}
|
|
960
|
+
if (score > best) best = score;
|
|
2039
961
|
}
|
|
2040
|
-
|
|
2041
|
-
function makeAuthError(code, message) {
|
|
2042
|
-
const err = new Error(message);
|
|
2043
|
-
err.code = code;
|
|
2044
|
-
return err;
|
|
962
|
+
return best;
|
|
2045
963
|
}
|
|
2046
|
-
function
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
964
|
+
function jaccardSimilarity(a, b) {
|
|
965
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
966
|
+
let intersection = 0;
|
|
967
|
+
for (const item of a) if (b.has(item)) intersection++;
|
|
968
|
+
const union = a.size + b.size - intersection;
|
|
969
|
+
return union === 0 ? 0 : intersection / union;
|
|
970
|
+
}
|
|
971
|
+
function findElementByFingerprint(fp, root) {
|
|
972
|
+
return findByFingerprint(fp, root).element;
|
|
973
|
+
}
|
|
974
|
+
function fingerprintKey(fp) {
|
|
975
|
+
return fp.selector + "|" + fp.textSnippet;
|
|
976
|
+
}
|
|
977
|
+
function fingerprintLabel(fp) {
|
|
978
|
+
const tag = fp.tagName.toLowerCase();
|
|
979
|
+
if (fp.textSnippet) {
|
|
980
|
+
const snippet = fp.textSnippet.length > 24 ? fp.textSnippet.slice(0, 24) + "\u2026" : fp.textSnippet;
|
|
981
|
+
return tag + ": " + snippet;
|
|
982
|
+
}
|
|
983
|
+
if (fp.attributes.id) return tag + "#" + fp.attributes.id;
|
|
984
|
+
if (fp.attributes.classList && fp.attributes.classList.length) return tag + "." + fp.attributes.classList[0];
|
|
985
|
+
return tag;
|
|
2053
986
|
}
|
|
2054
|
-
|
|
2055
|
-
// src/index.ts
|
|
2056
|
-
init_fingerprint();
|
|
2057
987
|
|
|
2058
988
|
// src/highlighter.ts
|
|
2059
989
|
var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
|
|
2060
990
|
var Highlighter = class {
|
|
2061
|
-
constructor(root, extraIgnore, onClick
|
|
991
|
+
constructor(root, extraIgnore, onClick) {
|
|
2062
992
|
this.current = null;
|
|
2063
993
|
this.active = false;
|
|
2064
994
|
this.paused = false;
|
|
@@ -2093,7 +1023,6 @@ var Highlighter = class {
|
|
|
2093
1023
|
this.root = root;
|
|
2094
1024
|
this.extraIgnore = extraIgnore;
|
|
2095
1025
|
this.onClick = onClick;
|
|
2096
|
-
this.onCurrentChange = onCurrentChange ?? null;
|
|
2097
1026
|
this.overlay = document.createElement("div");
|
|
2098
1027
|
this.overlay.className = "vz-highlight";
|
|
2099
1028
|
this.overlay.style.display = "none";
|
|
@@ -2102,7 +1031,6 @@ var Highlighter = class {
|
|
|
2102
1031
|
setCurrent(el) {
|
|
2103
1032
|
if (this.current === el) return;
|
|
2104
1033
|
this.current = el;
|
|
2105
|
-
this.onCurrentChange?.(el);
|
|
2106
1034
|
}
|
|
2107
1035
|
start() {
|
|
2108
1036
|
if (this.active) return;
|
|
@@ -2163,7 +1091,6 @@ var Highlighter = class {
|
|
|
2163
1091
|
};
|
|
2164
1092
|
|
|
2165
1093
|
// src/popover.ts
|
|
2166
|
-
init_fingerprint();
|
|
2167
1094
|
function cssEscape(s) {
|
|
2168
1095
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
|
|
2169
1096
|
return s.replace(/["\\]/g, "\\$&");
|
|
@@ -2841,7 +1768,6 @@ var Pill = class {
|
|
|
2841
1768
|
};
|
|
2842
1769
|
|
|
2843
1770
|
// src/sidebar.ts
|
|
2844
|
-
init_fingerprint();
|
|
2845
1771
|
var Sidebar = class {
|
|
2846
1772
|
constructor(root, callbacks) {
|
|
2847
1773
|
this.open = false;
|
|
@@ -4090,7 +3016,6 @@ var EventBus = class {
|
|
|
4090
3016
|
};
|
|
4091
3017
|
|
|
4092
3018
|
// src/index.ts
|
|
4093
|
-
init_fingerprint();
|
|
4094
3019
|
var DEFAULTS = {
|
|
4095
3020
|
shortcut: "mod+shift+e",
|
|
4096
3021
|
namespace: "default",
|
|
@@ -4150,8 +3075,6 @@ var Vizu = class {
|
|
|
4150
3075
|
this.selfHealedThisSession = /* @__PURE__ */ new Set();
|
|
4151
3076
|
this.actions = [];
|
|
4152
3077
|
this.user = null;
|
|
4153
|
-
/** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
|
|
4154
|
-
this.liveCollab = null;
|
|
4155
3078
|
this.enabled = false;
|
|
4156
3079
|
this.rafScheduled = false;
|
|
4157
3080
|
this.bus = new EventBus();
|
|
@@ -4223,18 +3146,6 @@ var Vizu = class {
|
|
|
4223
3146
|
if (options.actions) this.actions = [...options.actions];
|
|
4224
3147
|
if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
|
|
4225
3148
|
if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
|
|
4226
|
-
if (options.liveblocks && options.cloud) {
|
|
4227
|
-
const live = options.liveblocks;
|
|
4228
|
-
const cloud = options.cloud;
|
|
4229
|
-
void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
|
|
4230
|
-
this.liveCollab = new LiveCollab2({
|
|
4231
|
-
publicKey: live.publicKey,
|
|
4232
|
-
authEndpoint: live.authEndpoint,
|
|
4233
|
-
workspaceSlug: cloud.workspace
|
|
4234
|
-
});
|
|
4235
|
-
void this.liveCollab.start();
|
|
4236
|
-
});
|
|
4237
|
-
}
|
|
4238
3149
|
if (typeof window !== "undefined") {
|
|
4239
3150
|
window.addEventListener("keydown", this.onKeyDown);
|
|
4240
3151
|
void this.loadComments().then(() => this.subscribeStorage());
|
|
@@ -4292,8 +3203,6 @@ var Vizu = class {
|
|
|
4292
3203
|
this.unsubscribeStorage?.();
|
|
4293
3204
|
this.unsubscribeStorage = null;
|
|
4294
3205
|
this.selfHealedThisSession.clear();
|
|
4295
|
-
this.liveCollab?.destroy();
|
|
4296
|
-
this.liveCollab = null;
|
|
4297
3206
|
this.bus.clear();
|
|
4298
3207
|
}
|
|
4299
3208
|
/* ===== Action API ===== */
|
|
@@ -4615,11 +3524,7 @@ var Vizu = class {
|
|
|
4615
3524
|
this.highlighter = new Highlighter(
|
|
4616
3525
|
this.root,
|
|
4617
3526
|
this.opts.ignoreSelectors ?? [],
|
|
4618
|
-
(el, e) => this.onElementClick(el, e)
|
|
4619
|
-
(el) => {
|
|
4620
|
-
if (!this.liveCollab) return;
|
|
4621
|
-
this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
|
|
4622
|
-
}
|
|
3527
|
+
(el, e) => this.onElementClick(el, e)
|
|
4623
3528
|
);
|
|
4624
3529
|
this.highlighter.start();
|
|
4625
3530
|
this.renderAllMarkers();
|