@unhingged/vizu-core 0.1.2 → 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 -1850
- package/dist/auto.cjs.map +1 -1
- package/dist/auto.js +289 -57
- package/dist/auto.js.map +1 -1
- package/dist/index.cjs +662 -1849
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -39
- package/dist/index.d.ts +0 -39
- package/dist/index.js +289 -57
- package/dist/index.js.map +1 -1
- package/dist/vizu.min.js +55 -234
- package/dist/vizu.min.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-OMIFOSQ2.js +0 -295
- package/dist/chunk-OMIFOSQ2.js.map +0 -1
- package/dist/live-collab-BVLNJ5QI.js +0 -1098
- package/dist/live-collab-BVLNJ5QI.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,1582 +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 generateConnId() {
|
|
774
|
-
const a = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
775
|
-
const b = Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
776
|
-
return `${a}${b}`;
|
|
777
|
-
}
|
|
778
|
-
function fnv1a32Hex(input) {
|
|
779
|
-
let h = 2166136261;
|
|
780
|
-
for (let i = 0; i < input.length; i++) {
|
|
781
|
-
h ^= input.charCodeAt(i);
|
|
782
|
-
h = Math.imul(h, 16777619);
|
|
783
|
-
}
|
|
784
|
-
return (h >>> 0).toString(16).padStart(8, "0");
|
|
785
|
-
}
|
|
786
|
-
function roomIdFor(workspaceSlug, pageUrl) {
|
|
787
|
-
return `ws_${workspaceSlug}__pg_${fnv1a32Hex(pageUrl)}`;
|
|
788
|
-
}
|
|
789
|
-
function createCursorElement(name, color) {
|
|
790
|
-
const wrap = document.createElement("div");
|
|
791
|
-
wrap.setAttribute("data-vizu-cursor", "");
|
|
792
|
-
const transformTransition = prefersReducedMotion() ? "none" : `transform ${CURSOR_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`;
|
|
793
|
-
wrap.style.cssText = `
|
|
794
|
-
position: absolute; top: 0; left: 0;
|
|
795
|
-
pointer-events: none;
|
|
796
|
-
opacity: 0;
|
|
797
|
-
transform: translate(0px, 0px);
|
|
798
|
-
transition: ${transformTransition}, opacity 220ms ease-out;
|
|
799
|
-
will-change: transform;
|
|
800
|
-
`;
|
|
801
|
-
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
802
|
-
svg.setAttribute("width", "18");
|
|
803
|
-
svg.setAttribute("height", "20");
|
|
804
|
-
svg.setAttribute("viewBox", "0 0 16 18");
|
|
805
|
-
svg.style.cssText = "display: block; filter: drop-shadow(0 1px 1.5px rgba(0,0,0,0.3));";
|
|
806
|
-
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
807
|
-
path.setAttribute("d", "M0 0 L0 13 L4 9 L7 16 L9 15 L6 8 L11 8 Z");
|
|
808
|
-
path.setAttribute("fill", color);
|
|
809
|
-
path.setAttribute("stroke", "#ffffff");
|
|
810
|
-
path.setAttribute("stroke-width", "1.2");
|
|
811
|
-
path.setAttribute("stroke-linejoin", "round");
|
|
812
|
-
svg.appendChild(path);
|
|
813
|
-
wrap.appendChild(svg);
|
|
814
|
-
const label = document.createElement("div");
|
|
815
|
-
label.textContent = name;
|
|
816
|
-
label.style.cssText = `
|
|
817
|
-
margin-top: 2px;
|
|
818
|
-
margin-left: 10px;
|
|
819
|
-
padding: 2px 7px;
|
|
820
|
-
background: ${color};
|
|
821
|
-
color: #ffffff;
|
|
822
|
-
font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
823
|
-
border-radius: 4px;
|
|
824
|
-
white-space: nowrap;
|
|
825
|
-
box-shadow: 0 2px 6px rgba(0,0,0,0.18);
|
|
826
|
-
max-width: 160px;
|
|
827
|
-
overflow: hidden;
|
|
828
|
-
text-overflow: ellipsis;
|
|
829
|
-
`;
|
|
830
|
-
wrap.appendChild(label);
|
|
831
|
-
return wrap;
|
|
832
|
-
}
|
|
833
|
-
function labelForStatus(status) {
|
|
834
|
-
switch (status) {
|
|
835
|
-
case "connecting":
|
|
836
|
-
return "Connecting\u2026";
|
|
837
|
-
case "reconnecting":
|
|
838
|
-
return "Reconnecting\u2026";
|
|
839
|
-
case "disconnected":
|
|
840
|
-
return "Offline";
|
|
841
|
-
case "initial":
|
|
842
|
-
return "Connecting\u2026";
|
|
843
|
-
case "connected":
|
|
844
|
-
return "";
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
var PUSH_INTERVAL_MS, PULL_INTERVAL_MS, INACTIVITY_FADE_MS, CURSOR_TRANSITION_MS, Z_INDEX3, FOLLOW_SCROLL_THRESHOLD_PX, RECONNECT_AFTER_ERRORS, DISCONNECT_AFTER_ERRORS, LiveCollab, ConnectionIndicator, FollowPill;
|
|
848
|
-
var init_live_collab = __esm({
|
|
849
|
-
"src/live-collab.ts"() {
|
|
850
|
-
"use strict";
|
|
851
|
-
init_presence_stack();
|
|
852
|
-
init_selection_overlay();
|
|
853
|
-
init_cursor_colors();
|
|
854
|
-
PUSH_INTERVAL_MS = 100;
|
|
855
|
-
PULL_INTERVAL_MS = 200;
|
|
856
|
-
INACTIVITY_FADE_MS = 5e3;
|
|
857
|
-
CURSOR_TRANSITION_MS = 220;
|
|
858
|
-
Z_INDEX3 = 2147483641;
|
|
859
|
-
FOLLOW_SCROLL_THRESHOLD_PX = 8;
|
|
860
|
-
RECONNECT_AFTER_ERRORS = 2;
|
|
861
|
-
DISCONNECT_AFTER_ERRORS = 5;
|
|
862
|
-
LiveCollab = class {
|
|
863
|
-
constructor(opts) {
|
|
864
|
-
this.opts = opts;
|
|
865
|
-
this.container = null;
|
|
866
|
-
this.cursors = /* @__PURE__ */ new Map();
|
|
867
|
-
// keyed by connId now (was numeric connectionId)
|
|
868
|
-
this.presenceStack = null;
|
|
869
|
-
this.followPill = null;
|
|
870
|
-
this.selectionOverlay = null;
|
|
871
|
-
this.mouseListener = null;
|
|
872
|
-
this.scrollListener = null;
|
|
873
|
-
this.visibilityListener = null;
|
|
874
|
-
this.keydownListener = null;
|
|
875
|
-
this.statusIndicator = null;
|
|
876
|
-
this.pushTimer = null;
|
|
877
|
-
this.pullTimer = null;
|
|
878
|
-
this.destroyed = false;
|
|
879
|
-
/** Full room id, built once in start() from workspace + pageUrl. */
|
|
880
|
-
this.roomId = null;
|
|
881
|
-
/** Latest snapshot of other connections from /api/live/[room]/others. */
|
|
882
|
-
this.latestOthers = [];
|
|
883
|
-
/** Local presence — mouse + scroll + selection update this in place; pushTimer ships it. */
|
|
884
|
-
this.currentPresence = { cursor: null, scrollY: 0, selecting: null };
|
|
885
|
-
/** True when something changed since the last push — skip the POST otherwise. */
|
|
886
|
-
this.presenceDirty = false;
|
|
887
|
-
/** Connection-health tracker for the indicator. */
|
|
888
|
-
this.status = "initial";
|
|
889
|
-
this.consecutiveErrors = 0;
|
|
890
|
-
// Follow mode state
|
|
891
|
-
/** Clerk user id we're currently following, or null. */
|
|
892
|
-
this.followingUserId = null;
|
|
893
|
-
/** Last scrollY we received from the followee — for delta gating. */
|
|
894
|
-
this.lastFolloweeScrollY = null;
|
|
895
|
-
this.connId = generateConnId();
|
|
896
|
-
if (typeof window !== "undefined") {
|
|
897
|
-
this.currentPresence.scrollY = window.scrollY;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
async start() {
|
|
901
|
-
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
902
|
-
if (this.destroyed) return;
|
|
903
|
-
try {
|
|
904
|
-
const pageUrl = this.opts.pageUrl ?? window.location.origin + window.location.pathname;
|
|
905
|
-
this.roomId = roomIdFor(this.opts.workspaceSlug, pageUrl);
|
|
906
|
-
this.mountContainer();
|
|
907
|
-
this.presenceStack = new PresenceStack({
|
|
908
|
-
onAvatarClick: (userId) => this.setFollowing(userId)
|
|
909
|
-
});
|
|
910
|
-
this.presenceStack.mount();
|
|
911
|
-
this.followPill = new FollowPill({
|
|
912
|
-
onStop: () => this.setFollowing(null)
|
|
913
|
-
});
|
|
914
|
-
this.selectionOverlay = new SelectionOverlay();
|
|
915
|
-
this.selectionOverlay.mount();
|
|
916
|
-
this.statusIndicator = new ConnectionIndicator();
|
|
917
|
-
this.statusIndicator.mount();
|
|
918
|
-
this.setStatus("connecting");
|
|
919
|
-
this.attachMouseListener();
|
|
920
|
-
this.attachScrollListener();
|
|
921
|
-
this.attachVisibilityListener();
|
|
922
|
-
this.attachKeyboardListener();
|
|
923
|
-
this.pushTimer = window.setInterval(() => void this.push(), PUSH_INTERVAL_MS);
|
|
924
|
-
this.pullTimer = window.setInterval(() => void this.pull(), PULL_INTERVAL_MS);
|
|
925
|
-
this.presenceDirty = true;
|
|
926
|
-
void this.push();
|
|
927
|
-
void this.pull();
|
|
928
|
-
} catch (err) {
|
|
929
|
-
if (typeof console !== "undefined") {
|
|
930
|
-
console.warn("[vizu/live-collab] start failed; live cursors disabled:", err);
|
|
931
|
-
}
|
|
932
|
-
this.cleanup();
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
destroy() {
|
|
936
|
-
this.destroyed = true;
|
|
937
|
-
this.cleanup();
|
|
938
|
-
}
|
|
939
|
-
/* ────────────────── private ────────────────── */
|
|
940
|
-
cleanup() {
|
|
941
|
-
void this.clearRemotePresence();
|
|
942
|
-
if (this.mouseListener) {
|
|
943
|
-
window.removeEventListener("mousemove", this.mouseListener);
|
|
944
|
-
this.mouseListener = null;
|
|
945
|
-
}
|
|
946
|
-
if (this.scrollListener) {
|
|
947
|
-
window.removeEventListener("scroll", this.scrollListener);
|
|
948
|
-
this.scrollListener = null;
|
|
949
|
-
}
|
|
950
|
-
if (this.visibilityListener) {
|
|
951
|
-
document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
952
|
-
this.visibilityListener = null;
|
|
953
|
-
}
|
|
954
|
-
if (this.keydownListener) {
|
|
955
|
-
document.removeEventListener("keydown", this.keydownListener);
|
|
956
|
-
this.keydownListener = null;
|
|
957
|
-
}
|
|
958
|
-
if (this.pushTimer != null) {
|
|
959
|
-
window.clearInterval(this.pushTimer);
|
|
960
|
-
this.pushTimer = null;
|
|
961
|
-
}
|
|
962
|
-
if (this.pullTimer != null) {
|
|
963
|
-
window.clearInterval(this.pullTimer);
|
|
964
|
-
this.pullTimer = null;
|
|
965
|
-
}
|
|
966
|
-
for (const c of this.cursors.values()) {
|
|
967
|
-
if (c.fadeTimer != null) window.clearTimeout(c.fadeTimer);
|
|
968
|
-
c.el.remove();
|
|
969
|
-
}
|
|
970
|
-
this.cursors.clear();
|
|
971
|
-
this.presenceStack?.destroy();
|
|
972
|
-
this.presenceStack = null;
|
|
973
|
-
this.followPill?.destroy();
|
|
974
|
-
this.followPill = null;
|
|
975
|
-
this.selectionOverlay?.destroy();
|
|
976
|
-
this.selectionOverlay = null;
|
|
977
|
-
this.statusIndicator?.destroy();
|
|
978
|
-
this.statusIndicator = null;
|
|
979
|
-
this.followingUserId = null;
|
|
980
|
-
this.lastFolloweeScrollY = null;
|
|
981
|
-
this.container?.remove();
|
|
982
|
-
this.container = null;
|
|
983
|
-
this.roomId = null;
|
|
984
|
-
this.latestOthers = [];
|
|
985
|
-
}
|
|
986
|
-
/**
|
|
987
|
-
* Public API: broadcast the local user's currently-hovered element
|
|
988
|
-
* (highlighter mode) so other clients can render a ghost outline on
|
|
989
|
-
* it. Pass null to clear (highlighter stopped, popover opened, etc.).
|
|
990
|
-
* Idempotent on no-op transitions. Slice 5 of live-collab.md.
|
|
991
|
-
*/
|
|
992
|
-
setLocalSelection(fingerprint2) {
|
|
993
|
-
this.currentPresence.selecting = fingerprint2 ? { fingerprintJson: JSON.stringify(fingerprint2) } : null;
|
|
994
|
-
this.presenceDirty = true;
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Public API: start/stop following a user by Clerk id. Null clears.
|
|
998
|
-
* Called by the avatar click handler and by the FollowPill stop button.
|
|
999
|
-
* Idempotent — same id again is a no-op.
|
|
1000
|
-
*/
|
|
1001
|
-
setFollowing(userId) {
|
|
1002
|
-
if (this.followingUserId === userId) return;
|
|
1003
|
-
this.followingUserId = userId;
|
|
1004
|
-
this.lastFolloweeScrollY = null;
|
|
1005
|
-
this.presenceStack?.setFollowing(userId);
|
|
1006
|
-
this.followPill?.setFollowing(userId ? this.lookupUserForPill(userId) : null);
|
|
1007
|
-
if (userId) this.scrollToFolloweeIfKnown();
|
|
1008
|
-
}
|
|
1009
|
-
lookupUserForPill(userId) {
|
|
1010
|
-
for (const o of this.latestOthers) {
|
|
1011
|
-
if (o.user.userId === userId) {
|
|
1012
|
-
return {
|
|
1013
|
-
id: userId,
|
|
1014
|
-
name: o.user.name ?? "Anonymous",
|
|
1015
|
-
color: colorForUser(userId),
|
|
1016
|
-
avatar: o.user.avatar
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
return null;
|
|
1021
|
-
}
|
|
1022
|
-
scrollToFolloweeIfKnown() {
|
|
1023
|
-
if (!this.followingUserId) return;
|
|
1024
|
-
for (const o of this.latestOthers) {
|
|
1025
|
-
if (o.user.userId === this.followingUserId) {
|
|
1026
|
-
if (typeof o.presence.scrollY === "number") {
|
|
1027
|
-
this.followScrollTo(o.presence.scrollY);
|
|
1028
|
-
}
|
|
1029
|
-
return;
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
followScrollTo(scrollY) {
|
|
1034
|
-
if (this.lastFolloweeScrollY !== null && Math.abs(this.lastFolloweeScrollY - scrollY) < FOLLOW_SCROLL_THRESHOLD_PX) {
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
this.lastFolloweeScrollY = scrollY;
|
|
1038
|
-
window.scrollTo({ top: scrollY, behavior: "smooth" });
|
|
1039
|
-
}
|
|
1040
|
-
mountContainer() {
|
|
1041
|
-
this.container = document.createElement("div");
|
|
1042
|
-
this.container.setAttribute("data-vizu-live-cursors", "");
|
|
1043
|
-
this.container.style.cssText = `
|
|
1044
|
-
position: fixed; inset: 0;
|
|
1045
|
-
pointer-events: none;
|
|
1046
|
-
z-index: ${Z_INDEX3};
|
|
1047
|
-
contain: strict;
|
|
1048
|
-
`;
|
|
1049
|
-
document.body.appendChild(this.container);
|
|
1050
|
-
}
|
|
1051
|
-
attachMouseListener() {
|
|
1052
|
-
this.mouseListener = (e) => {
|
|
1053
|
-
this.currentPresence.cursor = {
|
|
1054
|
-
xPct: e.clientX / window.innerWidth * 100,
|
|
1055
|
-
yPct: e.clientY / window.innerHeight * 100
|
|
1056
|
-
};
|
|
1057
|
-
this.presenceDirty = true;
|
|
1058
|
-
};
|
|
1059
|
-
window.addEventListener("mousemove", this.mouseListener, { passive: true });
|
|
1060
|
-
}
|
|
1061
|
-
attachScrollListener() {
|
|
1062
|
-
this.scrollListener = () => {
|
|
1063
|
-
this.currentPresence.scrollY = window.scrollY;
|
|
1064
|
-
this.presenceDirty = true;
|
|
1065
|
-
};
|
|
1066
|
-
window.addEventListener("scroll", this.scrollListener, { passive: true });
|
|
1067
|
-
}
|
|
1068
|
-
attachVisibilityListener() {
|
|
1069
|
-
this.visibilityListener = () => {
|
|
1070
|
-
if (document.hidden) {
|
|
1071
|
-
void this.clearRemotePresence();
|
|
1072
|
-
} else {
|
|
1073
|
-
this.presenceDirty = true;
|
|
1074
|
-
}
|
|
1075
|
-
};
|
|
1076
|
-
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
1077
|
-
}
|
|
1078
|
-
attachKeyboardListener() {
|
|
1079
|
-
this.keydownListener = (e) => {
|
|
1080
|
-
if (e.key !== "Escape") return;
|
|
1081
|
-
if (!this.followingUserId) return;
|
|
1082
|
-
this.setFollowing(null);
|
|
1083
|
-
};
|
|
1084
|
-
document.addEventListener("keydown", this.keydownListener);
|
|
1085
|
-
}
|
|
1086
|
-
setStatus(next) {
|
|
1087
|
-
if (this.status === next) return;
|
|
1088
|
-
this.status = next;
|
|
1089
|
-
this.statusIndicator?.setStatus(next);
|
|
1090
|
-
}
|
|
1091
|
-
/**
|
|
1092
|
-
* Push tick — POST current presence to /api/live/[room]/me. Skips
|
|
1093
|
-
* the request when nothing changed since last push, so an idle tab
|
|
1094
|
-
* generates near-zero traffic.
|
|
1095
|
-
*/
|
|
1096
|
-
async push() {
|
|
1097
|
-
if (this.destroyed || !this.roomId) return;
|
|
1098
|
-
if (!this.presenceDirty) return;
|
|
1099
|
-
if (typeof document !== "undefined" && document.hidden) return;
|
|
1100
|
-
this.presenceDirty = false;
|
|
1101
|
-
try {
|
|
1102
|
-
const res = await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
|
|
1103
|
-
method: "POST",
|
|
1104
|
-
body: JSON.stringify({
|
|
1105
|
-
connId: this.connId,
|
|
1106
|
-
presence: this.currentPresence
|
|
1107
|
-
})
|
|
1108
|
-
});
|
|
1109
|
-
if (!res.ok) {
|
|
1110
|
-
if (res.status === 402 || res.status === 401 || res.status === 503) {
|
|
1111
|
-
this.setStatus("disconnected");
|
|
1112
|
-
if (this.pushTimer != null) {
|
|
1113
|
-
window.clearInterval(this.pushTimer);
|
|
1114
|
-
this.pushTimer = null;
|
|
1115
|
-
}
|
|
1116
|
-
if (this.pullTimer != null) {
|
|
1117
|
-
window.clearInterval(this.pullTimer);
|
|
1118
|
-
this.pullTimer = null;
|
|
1119
|
-
}
|
|
1120
|
-
return;
|
|
1121
|
-
}
|
|
1122
|
-
throw new Error(`push HTTP ${res.status}`);
|
|
1123
|
-
}
|
|
1124
|
-
this.noteSuccess();
|
|
1125
|
-
} catch (err) {
|
|
1126
|
-
this.noteError(err);
|
|
1127
|
-
this.presenceDirty = true;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
/**
|
|
1131
|
-
* Pull tick — GET /api/live/[room]/others and drive cursor + stack +
|
|
1132
|
-
* selection + follow updates. Same handler shape as the Liveblocks-era
|
|
1133
|
-
* subscribeOthers callback; only the data source changed.
|
|
1134
|
-
*/
|
|
1135
|
-
async pull() {
|
|
1136
|
-
if (this.destroyed || !this.roomId) return;
|
|
1137
|
-
try {
|
|
1138
|
-
const res = await this.fetchLive(
|
|
1139
|
-
`/api/live/${encodeURIComponent(this.roomId)}/others?me=${encodeURIComponent(this.connId)}`,
|
|
1140
|
-
{ method: "GET" }
|
|
1141
|
-
);
|
|
1142
|
-
if (!res.ok) {
|
|
1143
|
-
if (res.status === 503 || res.status === 401) {
|
|
1144
|
-
this.setStatus("disconnected");
|
|
1145
|
-
if (this.pullTimer != null) {
|
|
1146
|
-
window.clearInterval(this.pullTimer);
|
|
1147
|
-
this.pullTimer = null;
|
|
1148
|
-
}
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
throw new Error(`pull HTTP ${res.status}`);
|
|
1152
|
-
}
|
|
1153
|
-
const body = await res.json();
|
|
1154
|
-
const others = (body.others ?? []).map((o) => ({
|
|
1155
|
-
connId: o.connId,
|
|
1156
|
-
presence: o.payload.presence,
|
|
1157
|
-
user: o.payload.user
|
|
1158
|
-
}));
|
|
1159
|
-
this.latestOthers = others;
|
|
1160
|
-
this.renderOthers(others);
|
|
1161
|
-
this.noteSuccess();
|
|
1162
|
-
} catch (err) {
|
|
1163
|
-
this.noteError(err);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
noteSuccess() {
|
|
1167
|
-
this.consecutiveErrors = 0;
|
|
1168
|
-
if (this.status !== "connected") this.setStatus("connected");
|
|
1169
|
-
}
|
|
1170
|
-
noteError(err) {
|
|
1171
|
-
this.consecutiveErrors++;
|
|
1172
|
-
if (this.consecutiveErrors >= DISCONNECT_AFTER_ERRORS) {
|
|
1173
|
-
this.setStatus("disconnected");
|
|
1174
|
-
} else if (this.consecutiveErrors >= RECONNECT_AFTER_ERRORS) {
|
|
1175
|
-
this.setStatus("reconnecting");
|
|
1176
|
-
}
|
|
1177
|
-
if (typeof console !== "undefined" && this.consecutiveErrors === 1) {
|
|
1178
|
-
console.warn("[vizu/live-collab] transport error", err);
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Drive the cursor / stack / selection / follow layers from a fresh
|
|
1183
|
-
* `others` snapshot. Mirrors the body of the Liveblocks-era
|
|
1184
|
-
* subscribeOthers callback so DOM behavior is unchanged.
|
|
1185
|
-
*/
|
|
1186
|
-
renderOthers(others) {
|
|
1187
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1188
|
-
const stackUsers = /* @__PURE__ */ new Map();
|
|
1189
|
-
const selections = [];
|
|
1190
|
-
let followeeScrollY = null;
|
|
1191
|
-
let followeeStillPresent = false;
|
|
1192
|
-
for (const other of others) {
|
|
1193
|
-
seen.add(other.connId);
|
|
1194
|
-
const userId = other.user.userId;
|
|
1195
|
-
const name = other.user.name ?? "Anonymous";
|
|
1196
|
-
const color = colorForUser(userId);
|
|
1197
|
-
if (!stackUsers.has(userId)) {
|
|
1198
|
-
stackUsers.set(userId, { id: userId, name, color, avatar: other.user.avatar });
|
|
1199
|
-
}
|
|
1200
|
-
if (this.followingUserId === userId && followeeScrollY === null) {
|
|
1201
|
-
followeeStillPresent = true;
|
|
1202
|
-
if (typeof other.presence.scrollY === "number") {
|
|
1203
|
-
followeeScrollY = other.presence.scrollY;
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
if (other.presence.selecting && !selections.find((s) => s.id === userId)) {
|
|
1207
|
-
try {
|
|
1208
|
-
const fp = JSON.parse(other.presence.selecting.fingerprintJson);
|
|
1209
|
-
selections.push({ id: userId, name, color, fingerprint: fp });
|
|
1210
|
-
} catch {
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
if (!other.presence.cursor) {
|
|
1214
|
-
this.removeCursor(other.connId);
|
|
1215
|
-
continue;
|
|
1216
|
-
}
|
|
1217
|
-
this.upsertCursor(other.connId, userId, name, other.presence.cursor);
|
|
1218
|
-
}
|
|
1219
|
-
for (const id of Array.from(this.cursors.keys())) {
|
|
1220
|
-
if (!seen.has(id)) this.removeCursor(id);
|
|
1221
|
-
}
|
|
1222
|
-
this.presenceStack?.setOthers(Array.from(stackUsers.values()));
|
|
1223
|
-
this.selectionOverlay?.setSelections(selections);
|
|
1224
|
-
if (this.followingUserId) {
|
|
1225
|
-
if (!followeeStillPresent) {
|
|
1226
|
-
this.setFollowing(null);
|
|
1227
|
-
} else if (followeeScrollY !== null) {
|
|
1228
|
-
this.followScrollTo(followeeScrollY);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Async wrapper around fetch that injects the optional auth header
|
|
1234
|
-
* resolved from opts.getAuthHeader. Same-origin requests rely on
|
|
1235
|
-
* cookies via `credentials: 'include'`; cross-origin gets the
|
|
1236
|
-
* bearer token returned by the host.
|
|
1237
|
-
*/
|
|
1238
|
-
async fetchLive(path, init2) {
|
|
1239
|
-
const headers = new Headers(init2.headers);
|
|
1240
|
-
headers.set("content-type", "application/json");
|
|
1241
|
-
if (this.opts.getAuthHeader) {
|
|
1242
|
-
const extra = await this.opts.getAuthHeader();
|
|
1243
|
-
if (extra) {
|
|
1244
|
-
new Headers(extra).forEach((v, k) => headers.set(k, v));
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
return fetch(this.opts.apiUrl + path, {
|
|
1248
|
-
...init2,
|
|
1249
|
-
headers,
|
|
1250
|
-
credentials: "include"
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1253
|
-
/**
|
|
1254
|
-
* Fire-and-forget clearOnly POST so other clients see us drop within
|
|
1255
|
-
* one pull cycle (~200ms) instead of waiting for the 5s presence TTL.
|
|
1256
|
-
* Errors swallowed — the TTL is the safety net.
|
|
1257
|
-
*/
|
|
1258
|
-
async clearRemotePresence() {
|
|
1259
|
-
if (!this.roomId) return;
|
|
1260
|
-
try {
|
|
1261
|
-
await this.fetchLive(`/api/live/${encodeURIComponent(this.roomId)}/me`, {
|
|
1262
|
-
method: "POST",
|
|
1263
|
-
body: JSON.stringify({ connId: this.connId, clearOnly: true })
|
|
1264
|
-
});
|
|
1265
|
-
} catch {
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
upsertCursor(connectionId, userId, name, cursor) {
|
|
1269
|
-
if (!this.container) return;
|
|
1270
|
-
let entry = this.cursors.get(connectionId);
|
|
1271
|
-
if (!entry) {
|
|
1272
|
-
const color = colorForUser(userId);
|
|
1273
|
-
const el = createCursorElement(name, color);
|
|
1274
|
-
this.container.appendChild(el);
|
|
1275
|
-
entry = { el, fadeTimer: null };
|
|
1276
|
-
this.cursors.set(connectionId, entry);
|
|
1277
|
-
}
|
|
1278
|
-
const inViewport = cursor.xPct >= 0 && cursor.xPct <= 100 && cursor.yPct >= 0 && cursor.yPct <= 100;
|
|
1279
|
-
if (!inViewport) {
|
|
1280
|
-
entry.el.style.opacity = "0";
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
const x = window.innerWidth * cursor.xPct / 100;
|
|
1284
|
-
const y = window.innerHeight * cursor.yPct / 100;
|
|
1285
|
-
entry.el.style.transform = `translate(${x}px, ${y}px)`;
|
|
1286
|
-
entry.el.style.opacity = "1";
|
|
1287
|
-
if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
|
|
1288
|
-
entry.fadeTimer = window.setTimeout(() => {
|
|
1289
|
-
if (entry) entry.el.style.opacity = "0";
|
|
1290
|
-
}, INACTIVITY_FADE_MS);
|
|
1291
|
-
}
|
|
1292
|
-
removeCursor(connectionId) {
|
|
1293
|
-
const entry = this.cursors.get(connectionId);
|
|
1294
|
-
if (!entry) return;
|
|
1295
|
-
if (entry.fadeTimer != null) window.clearTimeout(entry.fadeTimer);
|
|
1296
|
-
entry.el.style.opacity = "0";
|
|
1297
|
-
window.setTimeout(() => entry.el.remove(), 220);
|
|
1298
|
-
this.cursors.delete(connectionId);
|
|
1299
|
-
}
|
|
1300
|
-
};
|
|
1301
|
-
ConnectionIndicator = class {
|
|
1302
|
-
constructor() {
|
|
1303
|
-
this.el = null;
|
|
1304
|
-
this.label = null;
|
|
1305
|
-
}
|
|
1306
|
-
mount() {
|
|
1307
|
-
if (typeof document === "undefined") return;
|
|
1308
|
-
if (this.el) return;
|
|
1309
|
-
this.el = document.createElement("div");
|
|
1310
|
-
this.el.setAttribute("data-vizu-connection-status", "");
|
|
1311
|
-
this.el.style.cssText = `
|
|
1312
|
-
position: fixed;
|
|
1313
|
-
top: 18px;
|
|
1314
|
-
right: 200px;
|
|
1315
|
-
z-index: ${Z_INDEX3};
|
|
1316
|
-
display: none;
|
|
1317
|
-
align-items: center;
|
|
1318
|
-
gap: 7px;
|
|
1319
|
-
padding: 4px 10px 4px 8px;
|
|
1320
|
-
background: rgba(10, 10, 10, 0.88);
|
|
1321
|
-
color: #fafafa;
|
|
1322
|
-
font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1323
|
-
border-radius: 999px;
|
|
1324
|
-
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
|
|
1325
|
-
pointer-events: none;
|
|
1326
|
-
`;
|
|
1327
|
-
const dot = document.createElement("span");
|
|
1328
|
-
dot.style.cssText = `
|
|
1329
|
-
display: inline-block;
|
|
1330
|
-
width: 6px; height: 6px;
|
|
1331
|
-
border-radius: 50%;
|
|
1332
|
-
background: #F59E0B;
|
|
1333
|
-
box-shadow: 0 0 6px #F59E0B;
|
|
1334
|
-
`;
|
|
1335
|
-
const label = document.createElement("span");
|
|
1336
|
-
this.label = label;
|
|
1337
|
-
this.el.appendChild(dot);
|
|
1338
|
-
this.el.appendChild(label);
|
|
1339
|
-
document.body.appendChild(this.el);
|
|
1340
|
-
}
|
|
1341
|
-
setStatus(status) {
|
|
1342
|
-
if (!this.el || !this.label) return;
|
|
1343
|
-
if (status === "connected") {
|
|
1344
|
-
this.el.style.display = "none";
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
this.label.textContent = labelForStatus(status);
|
|
1348
|
-
this.el.style.display = "inline-flex";
|
|
1349
|
-
}
|
|
1350
|
-
destroy() {
|
|
1351
|
-
this.el?.remove();
|
|
1352
|
-
this.el = null;
|
|
1353
|
-
this.label = null;
|
|
1354
|
-
}
|
|
1355
|
-
};
|
|
1356
|
-
FollowPill = class {
|
|
1357
|
-
constructor(opts) {
|
|
1358
|
-
this.el = null;
|
|
1359
|
-
this.onStop = opts.onStop;
|
|
1360
|
-
}
|
|
1361
|
-
setFollowing(user) {
|
|
1362
|
-
if (typeof document === "undefined") return;
|
|
1363
|
-
if (!user) {
|
|
1364
|
-
this.el?.remove();
|
|
1365
|
-
this.el = null;
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
if (!this.el) this.el = this.mount();
|
|
1369
|
-
this.paint(user);
|
|
1370
|
-
}
|
|
1371
|
-
destroy() {
|
|
1372
|
-
this.el?.remove();
|
|
1373
|
-
this.el = null;
|
|
1374
|
-
}
|
|
1375
|
-
mount() {
|
|
1376
|
-
const pill = document.createElement("div");
|
|
1377
|
-
pill.setAttribute("data-vizu-follow-pill", "");
|
|
1378
|
-
pill.style.cssText = `
|
|
1379
|
-
position: fixed;
|
|
1380
|
-
top: 56px;
|
|
1381
|
-
right: 16px;
|
|
1382
|
-
z-index: ${Z_INDEX3};
|
|
1383
|
-
display: inline-flex;
|
|
1384
|
-
align-items: center;
|
|
1385
|
-
gap: 8px;
|
|
1386
|
-
padding: 6px 6px 6px 12px;
|
|
1387
|
-
background: rgba(10, 10, 10, 0.92);
|
|
1388
|
-
color: #ffffff;
|
|
1389
|
-
border-radius: 999px;
|
|
1390
|
-
font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
1391
|
-
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
|
|
1392
|
-
pointer-events: auto;
|
|
1393
|
-
`;
|
|
1394
|
-
document.body.appendChild(pill);
|
|
1395
|
-
return pill;
|
|
1396
|
-
}
|
|
1397
|
-
paint(user) {
|
|
1398
|
-
if (!this.el) return;
|
|
1399
|
-
this.el.textContent = "";
|
|
1400
|
-
const dot = document.createElement("span");
|
|
1401
|
-
dot.style.cssText = `
|
|
1402
|
-
display: inline-block;
|
|
1403
|
-
width: 8px; height: 8px;
|
|
1404
|
-
border-radius: 50%;
|
|
1405
|
-
background: ${user.color};
|
|
1406
|
-
box-shadow: 0 0 8px ${user.color};
|
|
1407
|
-
`;
|
|
1408
|
-
this.el.appendChild(dot);
|
|
1409
|
-
const label = document.createElement("span");
|
|
1410
|
-
label.textContent = `Following ${user.name}`;
|
|
1411
|
-
label.style.cssText = "max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
|
|
1412
|
-
this.el.appendChild(label);
|
|
1413
|
-
const stop = document.createElement("button");
|
|
1414
|
-
stop.type = "button";
|
|
1415
|
-
stop.setAttribute("aria-label", `Stop following ${user.name}`);
|
|
1416
|
-
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>';
|
|
1417
|
-
stop.style.cssText = `
|
|
1418
|
-
display: inline-flex;
|
|
1419
|
-
align-items: center;
|
|
1420
|
-
justify-content: center;
|
|
1421
|
-
width: 22px; height: 22px;
|
|
1422
|
-
border: none;
|
|
1423
|
-
background: rgba(255, 255, 255, 0.12);
|
|
1424
|
-
color: #ffffff;
|
|
1425
|
-
border-radius: 50%;
|
|
1426
|
-
cursor: pointer;
|
|
1427
|
-
transition: background 120ms ease-out;
|
|
1428
|
-
`;
|
|
1429
|
-
stop.addEventListener("mouseenter", () => stop.style.background = "rgba(255, 255, 255, 0.22)");
|
|
1430
|
-
stop.addEventListener("mouseleave", () => stop.style.background = "rgba(255, 255, 255, 0.12)");
|
|
1431
|
-
stop.addEventListener("click", (e) => {
|
|
1432
|
-
e.preventDefault();
|
|
1433
|
-
this.onStop();
|
|
1434
|
-
});
|
|
1435
|
-
this.el.appendChild(stop);
|
|
1436
|
-
}
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
// src/auto.ts
|
|
1442
|
-
var auto_exports = {};
|
|
1443
|
-
__export(auto_exports, {
|
|
1444
|
-
Vizu: () => Vizu
|
|
1445
|
-
});
|
|
1446
|
-
module.exports = __toCommonJS(auto_exports);
|
|
1447
|
-
|
|
1448
|
-
// src/types.ts
|
|
1449
|
-
var SCHEMA_VERSION = 2;
|
|
1450
|
-
|
|
1451
|
-
// src/util.ts
|
|
1452
|
-
function uuid() {
|
|
1453
|
-
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
1454
|
-
return crypto.randomUUID();
|
|
1455
|
-
}
|
|
1456
|
-
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1457
|
-
}
|
|
1458
|
-
function isInside(target, selector) {
|
|
1459
|
-
return !!target.closest(selector);
|
|
1460
|
-
}
|
|
1461
|
-
function escapeHtml(s) {
|
|
1462
|
-
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]);
|
|
1463
|
-
}
|
|
1464
|
-
function formatTime(ts) {
|
|
1465
|
-
const d = new Date(ts);
|
|
1466
|
-
const now = Date.now();
|
|
1467
|
-
const diff = now - ts;
|
|
1468
|
-
if (diff < 6e4) return "just now";
|
|
1469
|
-
if (diff < 36e5) return Math.floor(diff / 6e4) + "m ago";
|
|
1470
|
-
if (diff < 864e5) return Math.floor(diff / 36e5) + "h ago";
|
|
1471
|
-
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
1472
|
-
}
|
|
1473
|
-
function initials(name) {
|
|
1474
|
-
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join("");
|
|
1475
|
-
}
|
|
1476
|
-
function avatarHtml(user, className = "vz-comment-author-avatar") {
|
|
1477
|
-
if (!user) return `<span class="${className}">\xB7</span>`;
|
|
1478
|
-
if (user.avatarUrl) {
|
|
1479
|
-
return `<span class="${className}"><img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.name)}" /></span>`;
|
|
1480
|
-
}
|
|
1481
|
-
return `<span class="${className}">${escapeHtml(initials(user.name) || "?")}</span>`;
|
|
1482
|
-
}
|
|
1483
|
-
function refId() {
|
|
1484
|
-
return Math.random().toString(36).slice(2, 10);
|
|
1485
|
-
}
|
|
1486
|
-
var MENTION_TOKEN_RE = /\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)/g;
|
|
1487
|
-
var COMBINED_TOKEN_SPLIT_RE = /(\[#[^\]]+\]\(vizu-ref:[a-zA-Z0-9_-]+\)|\[@[^\]]+\]\(vizu-mention:[a-zA-Z0-9_-]+\))/g;
|
|
1488
|
-
function renderCommentText(text, commentId) {
|
|
1489
|
-
const parts = text.split(COMBINED_TOKEN_SPLIT_RE);
|
|
1490
|
-
return parts.map((part) => {
|
|
1491
|
-
const refMatch = part.match(/^\[#([^\]]+)\]\(vizu-ref:([a-zA-Z0-9_-]+)\)$/);
|
|
1492
|
-
if (refMatch) {
|
|
1493
|
-
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>`;
|
|
1494
|
-
}
|
|
1495
|
-
const mentionMatch = part.match(/^\[@([^\]]+)\]\(vizu-mention:([a-zA-Z0-9_-]+)\)$/);
|
|
1496
|
-
if (mentionMatch) {
|
|
1497
|
-
return `<span class="vz-mention" data-vz="mention" data-mention-id="${escapeHtml(mentionMatch[2])}">@${escapeHtml(mentionMatch[1])}</span>`;
|
|
1498
|
-
}
|
|
1499
|
-
return escapeHtml(part);
|
|
1500
|
-
}).join("");
|
|
1501
|
-
}
|
|
1502
|
-
function extractMentions(text) {
|
|
1503
|
-
const out = [];
|
|
1504
|
-
for (const m of text.matchAll(MENTION_TOKEN_RE)) {
|
|
1505
|
-
if (!out.includes(m[2])) out.push(m[2]);
|
|
1506
|
-
}
|
|
1507
|
-
return out;
|
|
1508
|
-
}
|
|
1509
|
-
function renderAttachmentsHtml(attachments) {
|
|
1510
|
-
if (!Array.isArray(attachments) || attachments.length === 0) return "";
|
|
1511
|
-
const items = attachments.map((a) => {
|
|
1512
|
-
const isImage = a.mimeType?.startsWith("image/");
|
|
1513
|
-
const filename = a.filename ?? a.url.split("/").pop() ?? "file";
|
|
1514
|
-
if (isImage) {
|
|
1515
|
-
return `
|
|
1516
|
-
<a class="vz-attachment-display" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer" title="${escapeHtml(filename)}">
|
|
1517
|
-
<img src="${escapeHtml(a.url)}" alt="${escapeHtml(filename)}" loading="lazy" />
|
|
1518
|
-
</a>
|
|
1519
|
-
`;
|
|
1520
|
-
}
|
|
1521
|
-
return `
|
|
1522
|
-
<a class="vz-attachment-display vz-attachment-file" href="${escapeHtml(a.url)}" target="_blank" rel="noreferrer">
|
|
1523
|
-
<span class="vz-attachment-icon" aria-hidden="true">\u{1F4C4}</span>
|
|
1524
|
-
<span class="vz-attachment-filename">${escapeHtml(filename)}</span>
|
|
1525
|
-
</a>
|
|
1526
|
-
`;
|
|
1527
|
-
}).join("");
|
|
1528
|
-
return `<div class="vz-attachment-grid">${items}</div>`;
|
|
1529
|
-
}
|
|
1530
|
-
function migrateComment(raw) {
|
|
1531
|
-
if (!raw || typeof raw !== "object") return raw;
|
|
1532
|
-
const next = { ...raw };
|
|
1533
|
-
if (!Array.isArray(next.fingerprints) || next.fingerprints.length === 0) {
|
|
1534
|
-
if (next.fingerprint) {
|
|
1535
|
-
next.fingerprints = [next.fingerprint];
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
delete next.fingerprint;
|
|
1539
|
-
if (next.url && !next.pageUrl) {
|
|
1540
|
-
next.pageUrl = next.url;
|
|
1541
|
-
}
|
|
1542
|
-
if (!("status" in next) || !isValidStatus(next.status)) next.status = "open";
|
|
1543
|
-
if (!Array.isArray(next.replies)) next.replies = [];
|
|
1544
|
-
if (!Array.isArray(next.attachments)) next.attachments = [];
|
|
1545
|
-
if (!Array.isArray(next.mentions)) next.mentions = [];
|
|
1546
|
-
if (!("fingerprintsRefreshedAt" in next)) next.fingerprintsRefreshedAt = null;
|
|
1547
|
-
next.schemaVersion = SCHEMA_VERSION;
|
|
1548
|
-
return next;
|
|
1549
|
-
}
|
|
1550
|
-
function isValidStatus(s) {
|
|
1551
|
-
return s === "open" || s === "resolved" || s === "wontfix";
|
|
1552
|
-
}
|
|
1553
|
-
function migrateComments(raws) {
|
|
1554
|
-
if (!Array.isArray(raws)) return [];
|
|
1555
|
-
const out = [];
|
|
1556
|
-
for (const raw of raws) {
|
|
1557
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1558
|
-
out.push(migrateComment(raw));
|
|
1559
|
-
}
|
|
1560
|
-
return out;
|
|
1561
|
-
}
|
|
1562
|
-
function needsPersist(raws) {
|
|
1563
|
-
if (!Array.isArray(raws)) return false;
|
|
1564
|
-
for (const raw of raws) {
|
|
1565
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1566
|
-
const c = raw;
|
|
1567
|
-
if (c.schemaVersion !== SCHEMA_VERSION) return true;
|
|
1568
|
-
if ("fingerprint" in c) return true;
|
|
1569
|
-
if (!Array.isArray(c.replies)) return true;
|
|
1570
|
-
if (!Array.isArray(c.attachments)) return true;
|
|
1571
|
-
if (!Array.isArray(c.mentions)) return true;
|
|
1572
|
-
}
|
|
1573
|
-
return false;
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
// src/storage.ts
|
|
1577
|
-
var key = (ns) => `vizu:comments:${ns}`;
|
|
1578
|
-
var LocalStorageAdapter = class {
|
|
1579
|
-
async load(namespace) {
|
|
1580
|
-
return readArray(namespace);
|
|
1581
|
-
}
|
|
1582
|
-
async addComment(namespace, comment) {
|
|
1583
|
-
const list = readArray(namespace);
|
|
1584
|
-
list.push(comment);
|
|
1585
|
-
writeArray(namespace, list);
|
|
1586
|
-
}
|
|
1587
|
-
async updateComment(namespace, id, patch) {
|
|
1588
|
-
const list = readArray(namespace);
|
|
1589
|
-
const idx = list.findIndex((c) => c.id === id);
|
|
1590
|
-
if (idx === -1) return null;
|
|
1591
|
-
const next = { ...list[idx], ...patch, id: list[idx].id };
|
|
1592
|
-
list[idx] = next;
|
|
1593
|
-
writeArray(namespace, list);
|
|
1594
|
-
return next;
|
|
1595
|
-
}
|
|
1596
|
-
async removeComment(namespace, id) {
|
|
1597
|
-
const list = readArray(namespace);
|
|
1598
|
-
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));
|
|
1599
178
|
}
|
|
1600
179
|
async setAll(namespace, comments) {
|
|
1601
180
|
writeArray(namespace, comments);
|
|
@@ -1804,18 +383,6 @@ var CloudStorageAdapter = class {
|
|
|
1804
383
|
async preflightAuth() {
|
|
1805
384
|
await this.resolveToken();
|
|
1806
385
|
}
|
|
1807
|
-
/**
|
|
1808
|
-
* Synchronous accessor for the currently cached bearer token, used
|
|
1809
|
-
* by sibling modules that need to authenticate against the same
|
|
1810
|
-
* backend (live-collab cross-origin polling). Returns null when no
|
|
1811
|
-
* valid token is cached — callers fall back to credentials:'include'
|
|
1812
|
-
* for same-origin or just disable themselves.
|
|
1813
|
-
*/
|
|
1814
|
-
getCachedAuthToken() {
|
|
1815
|
-
if (!this.cachedToken) return null;
|
|
1816
|
-
if (this.isExpired(this.cachedToken)) return null;
|
|
1817
|
-
return this.cachedToken.token;
|
|
1818
|
-
}
|
|
1819
386
|
/* ─── StorageAdapter v2 ───────────────────────────────────────────── */
|
|
1820
387
|
async load(_namespace) {
|
|
1821
388
|
const out = [];
|
|
@@ -1866,278 +433,562 @@ var CloudStorageAdapter = class {
|
|
|
1866
433
|
throw apiErr(res.status, json);
|
|
1867
434
|
}
|
|
1868
435
|
}
|
|
1869
|
-
async setAll(namespace, comments) {
|
|
1870
|
-
await this.clear(namespace);
|
|
1871
|
-
for (const c of comments) {
|
|
1872
|
-
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;
|
|
1873
684
|
}
|
|
1874
685
|
}
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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;
|
|
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
|
+
}
|
|
1884
726
|
}
|
|
727
|
+
parts.unshift(part);
|
|
728
|
+
current = parent;
|
|
729
|
+
depth++;
|
|
1885
730
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
if (res.status === 404) return null;
|
|
1895
|
-
const json = await this.parseJson(res);
|
|
1896
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1897
|
-
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);
|
|
1898
739
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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;
|
|
1909
766
|
}
|
|
1910
|
-
|
|
767
|
+
steps.push({ tag: current.tagName, nthOfType });
|
|
768
|
+
current = parent;
|
|
1911
769
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
* (script-tag and local-only consumers don't pull blob code)
|
|
1921
|
-
* - the multipart path is one round-trip; the direct-upload flow
|
|
1922
|
-
* is two (signed URL + upload). For attachments capped at 5 MB,
|
|
1923
|
-
* the savings don't justify the dep cost.
|
|
1924
|
-
*/
|
|
1925
|
-
async uploadAttachment(_namespace, file) {
|
|
1926
|
-
const form = new FormData();
|
|
1927
|
-
form.append("file", file, file.name);
|
|
1928
|
-
const url = new URL(this.apiUrl + "/api/uploads");
|
|
1929
|
-
url.searchParams.set("workspace", this.workspace);
|
|
1930
|
-
const res = await this.fetchAuthed(url.toString(), {
|
|
1931
|
-
method: "POST",
|
|
1932
|
-
body: form
|
|
1933
|
-
// Don't set content-type; the browser fills in the multipart boundary.
|
|
1934
|
-
});
|
|
1935
|
-
const json = await this.parseJson(res);
|
|
1936
|
-
if (!res.ok) throw apiErr(res.status, json);
|
|
1937
|
-
if (!json?.url || typeof json.url !== "string") {
|
|
1938
|
-
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 {
|
|
1939
778
|
}
|
|
1940
|
-
return {
|
|
1941
|
-
id: uuid(),
|
|
1942
|
-
url: json.url,
|
|
1943
|
-
mimeType: file.type || "application/octet-stream",
|
|
1944
|
-
sizeBytes: file.size,
|
|
1945
|
-
uploadedAt: Date.now(),
|
|
1946
|
-
filename: file.name
|
|
1947
|
-
};
|
|
779
|
+
return { element: null, confidence: "orphaned" };
|
|
1948
780
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
};
|
|
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
|
+
}
|
|
1956
787
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
const
|
|
1961
|
-
|
|
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" });
|
|
1962
793
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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 {
|
|
1967
803
|
try {
|
|
1968
|
-
const
|
|
1969
|
-
if (
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
+
}
|
|
1972
810
|
}
|
|
1973
|
-
return parsed;
|
|
1974
811
|
} catch {
|
|
1975
|
-
return null;
|
|
1976
812
|
}
|
|
1977
813
|
}
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
if (
|
|
1981
|
-
|
|
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);
|
|
1982
827
|
}
|
|
828
|
+
s.add(c.rung);
|
|
1983
829
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
if (
|
|
1987
|
-
|
|
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;
|
|
1988
860
|
}
|
|
1989
861
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
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 };
|
|
1992
874
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
const
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
}
|
|
2009
|
-
if (!this.autoSignIn) {
|
|
2010
|
-
throw makeAuthError("auth_required", "Sign-in required; autoSignIn is disabled.");
|
|
2011
|
-
}
|
|
2012
|
-
if (!this.pendingAuth) {
|
|
2013
|
-
this.pendingAuth = this.openSignInPopup().then((t) => {
|
|
2014
|
-
this.writeStoredToken(t);
|
|
2015
|
-
this.fireAuthChanged(t);
|
|
2016
|
-
return t;
|
|
2017
|
-
}).finally(() => {
|
|
2018
|
-
this.pendingAuth = null;
|
|
2019
|
-
});
|
|
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;
|
|
2020
890
|
}
|
|
2021
|
-
|
|
891
|
+
steps.push({ tag: current.tagName, nthOfType: nth });
|
|
892
|
+
current = parent;
|
|
2022
893
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
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 };
|
|
2037
909
|
}
|
|
2038
910
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
makeAuthError(
|
|
2057
|
-
"popup_blocked",
|
|
2058
|
-
"Sign-in popup was blocked by the browser. Allow popups for this site and try again."
|
|
2059
|
-
)
|
|
2060
|
-
);
|
|
2061
|
-
}
|
|
2062
|
-
let resolved = false;
|
|
2063
|
-
const onMessage = (e) => {
|
|
2064
|
-
if (e.origin !== apiOrigin) return;
|
|
2065
|
-
const data = e.data;
|
|
2066
|
-
if (!data || data.type !== "vizu:auth") return;
|
|
2067
|
-
if (data.workspace !== this.workspace) return;
|
|
2068
|
-
if (typeof data.token !== "string" || typeof data.expiresAt !== "string" || typeof data.userId !== "string") return;
|
|
2069
|
-
resolved = true;
|
|
2070
|
-
cleanup();
|
|
2071
|
-
const userPayload = data.user;
|
|
2072
|
-
const user = userPayload && typeof userPayload.name === "string" ? {
|
|
2073
|
-
id: typeof userPayload.id === "string" ? userPayload.id : data.userId,
|
|
2074
|
-
name: userPayload.name,
|
|
2075
|
-
avatarUrl: typeof userPayload.avatarUrl === "string" ? userPayload.avatarUrl : void 0
|
|
2076
|
-
} : null;
|
|
2077
|
-
resolve({ token: data.token, expiresAt: data.expiresAt, userId: data.userId, user });
|
|
2078
|
-
};
|
|
2079
|
-
const onPoll = window.setInterval(() => {
|
|
2080
|
-
if (popup.closed) {
|
|
2081
|
-
if (!resolved) {
|
|
2082
|
-
cleanup();
|
|
2083
|
-
reject(makeAuthError("auth_canceled", "Sign-in popup was closed before completing."));
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
}, 500);
|
|
2087
|
-
function cleanup() {
|
|
2088
|
-
window.clearInterval(onPoll);
|
|
2089
|
-
window.removeEventListener("message", onMessage);
|
|
2090
|
-
}
|
|
2091
|
-
window.addEventListener("message", onMessage);
|
|
2092
|
-
});
|
|
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;
|
|
2093
928
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
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
|
+
);
|
|
2108
944
|
}
|
|
2109
|
-
|
|
945
|
+
for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
|
|
2110
946
|
}
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
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
|
+
}
|
|
2117
959
|
}
|
|
960
|
+
if (score > best) best = score;
|
|
2118
961
|
}
|
|
2119
|
-
|
|
2120
|
-
function makeAuthError(code, message) {
|
|
2121
|
-
const err = new Error(message);
|
|
2122
|
-
err.code = code;
|
|
2123
|
-
return err;
|
|
962
|
+
return best;
|
|
2124
963
|
}
|
|
2125
|
-
function
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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;
|
|
2132
986
|
}
|
|
2133
|
-
|
|
2134
|
-
// src/index.ts
|
|
2135
|
-
init_fingerprint();
|
|
2136
987
|
|
|
2137
988
|
// src/highlighter.ts
|
|
2138
989
|
var IGNORE_SELECTOR = "[data-vizu-root], [data-vizu-ignore], script, style, link, meta, head";
|
|
2139
990
|
var Highlighter = class {
|
|
2140
|
-
constructor(root, extraIgnore, onClick
|
|
991
|
+
constructor(root, extraIgnore, onClick) {
|
|
2141
992
|
this.current = null;
|
|
2142
993
|
this.active = false;
|
|
2143
994
|
this.paused = false;
|
|
@@ -2172,7 +1023,6 @@ var Highlighter = class {
|
|
|
2172
1023
|
this.root = root;
|
|
2173
1024
|
this.extraIgnore = extraIgnore;
|
|
2174
1025
|
this.onClick = onClick;
|
|
2175
|
-
this.onCurrentChange = onCurrentChange ?? null;
|
|
2176
1026
|
this.overlay = document.createElement("div");
|
|
2177
1027
|
this.overlay.className = "vz-highlight";
|
|
2178
1028
|
this.overlay.style.display = "none";
|
|
@@ -2181,7 +1031,6 @@ var Highlighter = class {
|
|
|
2181
1031
|
setCurrent(el) {
|
|
2182
1032
|
if (this.current === el) return;
|
|
2183
1033
|
this.current = el;
|
|
2184
|
-
this.onCurrentChange?.(el);
|
|
2185
1034
|
}
|
|
2186
1035
|
start() {
|
|
2187
1036
|
if (this.active) return;
|
|
@@ -2242,7 +1091,6 @@ var Highlighter = class {
|
|
|
2242
1091
|
};
|
|
2243
1092
|
|
|
2244
1093
|
// src/popover.ts
|
|
2245
|
-
init_fingerprint();
|
|
2246
1094
|
function cssEscape(s) {
|
|
2247
1095
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
|
|
2248
1096
|
return s.replace(/["\\]/g, "\\$&");
|
|
@@ -2920,7 +1768,6 @@ var Pill = class {
|
|
|
2920
1768
|
};
|
|
2921
1769
|
|
|
2922
1770
|
// src/sidebar.ts
|
|
2923
|
-
init_fingerprint();
|
|
2924
1771
|
var Sidebar = class {
|
|
2925
1772
|
constructor(root, callbacks) {
|
|
2926
1773
|
this.open = false;
|
|
@@ -4169,7 +3016,6 @@ var EventBus = class {
|
|
|
4169
3016
|
};
|
|
4170
3017
|
|
|
4171
3018
|
// src/index.ts
|
|
4172
|
-
init_fingerprint();
|
|
4173
3019
|
var DEFAULTS = {
|
|
4174
3020
|
shortcut: "mod+shift+e",
|
|
4175
3021
|
namespace: "default",
|
|
@@ -4229,8 +3075,6 @@ var Vizu = class {
|
|
|
4229
3075
|
this.selfHealedThisSession = /* @__PURE__ */ new Set();
|
|
4230
3076
|
this.actions = [];
|
|
4231
3077
|
this.user = null;
|
|
4232
|
-
/** Live-collab module — non-null only when `opts.liveblocks` and `opts.cloud` are both set AND the plan carries `live_collab`. Stays null otherwise. */
|
|
4233
|
-
this.liveCollab = null;
|
|
4234
3078
|
this.enabled = false;
|
|
4235
3079
|
this.rafScheduled = false;
|
|
4236
3080
|
this.bus = new EventBus();
|
|
@@ -4302,31 +3146,6 @@ var Vizu = class {
|
|
|
4302
3146
|
if (options.actions) this.actions = [...options.actions];
|
|
4303
3147
|
if (options.onCommentAdded) this.bus.on("comment:added", (p) => options.onCommentAdded(p.comment));
|
|
4304
3148
|
if (options.onCommentRemoved) this.bus.on("comment:removed", (p) => options.onCommentRemoved(p.id));
|
|
4305
|
-
if (options.cloud) {
|
|
4306
|
-
const cloud = options.cloud;
|
|
4307
|
-
const apiUrl = (cloud.apiUrl ?? "https://vizu.unhingged.com").replace(/\/$/, "");
|
|
4308
|
-
void Promise.resolve().then(() => (init_live_collab(), live_collab_exports)).then(({ LiveCollab: LiveCollab2 }) => {
|
|
4309
|
-
this.liveCollab = new LiveCollab2({
|
|
4310
|
-
workspaceSlug: cloud.workspace,
|
|
4311
|
-
apiUrl,
|
|
4312
|
-
getAuthHeader: async () => {
|
|
4313
|
-
if (typeof window === "undefined") return null;
|
|
4314
|
-
try {
|
|
4315
|
-
if (new URL(apiUrl).origin === window.location.origin) return null;
|
|
4316
|
-
} catch {
|
|
4317
|
-
return null;
|
|
4318
|
-
}
|
|
4319
|
-
const adapter = this.storage;
|
|
4320
|
-
if (adapter instanceof CloudStorageAdapter) {
|
|
4321
|
-
const token = adapter.getCachedAuthToken();
|
|
4322
|
-
if (token) return { Authorization: `Bearer ${token}` };
|
|
4323
|
-
}
|
|
4324
|
-
return null;
|
|
4325
|
-
}
|
|
4326
|
-
});
|
|
4327
|
-
void this.liveCollab.start();
|
|
4328
|
-
});
|
|
4329
|
-
}
|
|
4330
3149
|
if (typeof window !== "undefined") {
|
|
4331
3150
|
window.addEventListener("keydown", this.onKeyDown);
|
|
4332
3151
|
void this.loadComments().then(() => this.subscribeStorage());
|
|
@@ -4384,8 +3203,6 @@ var Vizu = class {
|
|
|
4384
3203
|
this.unsubscribeStorage?.();
|
|
4385
3204
|
this.unsubscribeStorage = null;
|
|
4386
3205
|
this.selfHealedThisSession.clear();
|
|
4387
|
-
this.liveCollab?.destroy();
|
|
4388
|
-
this.liveCollab = null;
|
|
4389
3206
|
this.bus.clear();
|
|
4390
3207
|
}
|
|
4391
3208
|
/* ===== Action API ===== */
|
|
@@ -4707,11 +3524,7 @@ var Vizu = class {
|
|
|
4707
3524
|
this.highlighter = new Highlighter(
|
|
4708
3525
|
this.root,
|
|
4709
3526
|
this.opts.ignoreSelectors ?? [],
|
|
4710
|
-
(el, e) => this.onElementClick(el, e)
|
|
4711
|
-
(el) => {
|
|
4712
|
-
if (!this.liveCollab) return;
|
|
4713
|
-
this.liveCollab.setLocalSelection(el ? fingerprint(el) : null);
|
|
4714
|
-
}
|
|
3527
|
+
(el, e) => this.onElementClick(el, e)
|
|
4715
3528
|
);
|
|
4716
3529
|
this.highlighter.start();
|
|
4717
3530
|
this.renderAllMarkers();
|