@nuniapp/widget 0.1.3 → 0.1.5
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/README.md +13 -6
- package/dist/index.d.ts +181 -3
- package/dist/index.js +2815 -393
- package/dist/mark-B8wojiFu.js +90 -0
- package/dist/nuni-screenshot.global.js +14 -0
- package/dist/nuni.global.js +199 -4
- package/dist/screenshot-DcJ_SzKF.js +1288 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,43 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
3
|
-
const PROJECT_ID_PATTERN = new RegExp(`^nuni_[${ALPHABET}]{22}$`);
|
|
4
|
-
/** Random base58 string using the platform CSPRNG (browser, Node, Convex). */
|
|
5
|
-
function randomBase58(length) {
|
|
6
|
-
const bytes = new Uint8Array(length);
|
|
7
|
-
crypto.getRandomValues(bytes);
|
|
8
|
-
let out = "";
|
|
9
|
-
for (const byte of bytes) {
|
|
10
|
-
let b = byte;
|
|
11
|
-
while (b >= 232) {
|
|
12
|
-
const retry = /* @__PURE__ */ new Uint8Array(1);
|
|
13
|
-
crypto.getRandomValues(retry);
|
|
14
|
-
b = retry[0];
|
|
15
|
-
}
|
|
16
|
-
out += ALPHABET[b % 58];
|
|
17
|
-
}
|
|
18
|
-
return out;
|
|
19
|
-
}
|
|
20
|
-
function isProjectId(value) {
|
|
21
|
-
return typeof value === "string" && PROJECT_ID_PATTERN.test(value);
|
|
22
|
-
}
|
|
23
|
-
/** Secret used by anonymous commenters to edit/delete their own comments. */
|
|
24
|
-
function generateSecret() {
|
|
25
|
-
return randomBase58(32);
|
|
26
|
-
}
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region ../shared/src/limits.ts
|
|
29
|
-
const LIMITS = {
|
|
30
|
-
bodyMaxLength: 2e3,
|
|
31
|
-
nameMaxLength: 50,
|
|
32
|
-
unclaimedCommentCap: 500,
|
|
33
|
-
maxOriginsPerProject: 20,
|
|
34
|
-
sessionTtlMs: 2592e6,
|
|
35
|
-
anchorTextMaxLength: 120,
|
|
36
|
-
/** Per page, the widget loads this many newest open and resolved comments. */
|
|
37
|
-
pageOpenLimit: 1e3,
|
|
38
|
-
pageResolvedLimit: 200
|
|
39
|
-
};
|
|
40
|
-
//#endregion
|
|
1
|
+
import { a as REACTIONS, i as LIMITS, n as MASK_ATTRIBUTE, o as generateSecret, r as MASK_SELECTOR, s as isProjectId, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
|
|
41
2
|
//#region ../shared/src/path.ts
|
|
42
3
|
/**
|
|
43
4
|
* Normalize a URL to the key comments are matched by. Query and hash are
|
|
@@ -71,16 +32,472 @@ function describeLocation(input) {
|
|
|
71
32
|
url: url.href
|
|
72
33
|
};
|
|
73
34
|
}
|
|
35
|
+
/** URL that opens the page with the comment focused by the widget. */
|
|
36
|
+
function commentUrl(page, id) {
|
|
37
|
+
const [pathname, hashRoute] = page.path.split("#");
|
|
38
|
+
const origin = new URL(page.origin).origin;
|
|
39
|
+
let url = new URL(pathname ?? "/", origin);
|
|
40
|
+
if (url.origin !== origin) url = new URL("/", origin);
|
|
41
|
+
const params = new URLSearchParams(page.search ?? "");
|
|
42
|
+
params.set("nuni", id);
|
|
43
|
+
url.search = params.toString();
|
|
44
|
+
if (hashRoute) url.hash = hashRoute;
|
|
45
|
+
return url.toString();
|
|
46
|
+
}
|
|
47
|
+
/** Origin + path only: query strings and hashes often carry tokens. */
|
|
48
|
+
function withoutQuery(input, base) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(input, base);
|
|
51
|
+
return `${url.origin}${url.pathname}`;
|
|
52
|
+
} catch {
|
|
53
|
+
return input.split(/[?#]/)[0] ?? "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Cut a word of free text at the query or hash of the URL in it: after a
|
|
58
|
+
* path ("/reset?token=1", "https://a.com/x#t") or before a key=value pair
|
|
59
|
+
* ("?token=1", "#access_token=1"). One pass, so long words stay cheap.
|
|
60
|
+
*/
|
|
61
|
+
function stripWord(word) {
|
|
62
|
+
const slash = word.indexOf("/");
|
|
63
|
+
const equals = word.lastIndexOf("=");
|
|
64
|
+
for (let i = 0; i < word.length - 1; i++) {
|
|
65
|
+
const c = word[i];
|
|
66
|
+
if ((c === "?" || c === "#") && (slash >= 0 && slash < i || equals > i)) return word.slice(0, i);
|
|
67
|
+
}
|
|
68
|
+
return word;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Remove query strings and hashes from every URL inside free text (log
|
|
72
|
+
* lines, markup, CSS values), absolute or relative.
|
|
73
|
+
*/
|
|
74
|
+
function stripUrlQueries(text) {
|
|
75
|
+
return text.replace(/[^\s"'<>`]+/g, stripWord);
|
|
76
|
+
}
|
|
77
|
+
const ATTRIBUTE = /(\s([^\s"'<>/=]+)\s*=\s*)("[^"]*"|'[^']*')/g;
|
|
78
|
+
/** Attributes that hold URLs, including lazy-loading ones like data-src. */
|
|
79
|
+
const URL_ATTRIBUTE = /^(?:action|formaction|poster|cite|ping|data|background|(?:[\w-]*[-:])?(?:href|src|srcset|url|uri))$/i;
|
|
80
|
+
/**
|
|
81
|
+
* Remove query strings and hashes from serialized HTML: URL attributes
|
|
82
|
+
* (links, images, srcset lists, data-src) always, relative or absolute,
|
|
83
|
+
* and URLs anywhere else (inline styles, other attributes, text).
|
|
84
|
+
*/
|
|
85
|
+
function stripHtmlUrlQueries(html) {
|
|
86
|
+
return stripUrlQueries(html.replace(ATTRIBUTE, (all, prefix, name, quoted) => {
|
|
87
|
+
if (!URL_ATTRIBUTE.test(name)) return all;
|
|
88
|
+
const quote = quoted[0];
|
|
89
|
+
const value = quoted.slice(1, -1);
|
|
90
|
+
return `${prefix}${quote}${/srcset$/i.test(name) ? value.replace(/[?#][^\s,]*/g, "") : value.replace(/[?#][\s\S]*$/, "")}${quote}`;
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region ../shared/src/prompt.ts
|
|
95
|
+
const PACKAGES = {
|
|
96
|
+
widget: "@nuniapp/widget",
|
|
97
|
+
react: "@nuniapp/react",
|
|
98
|
+
cli: "@nuniapp/cli"
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region ../shared/src/comment-prompt.ts
|
|
102
|
+
/** A code fence longer than any backtick run inside `content`. */
|
|
103
|
+
function fence(content, lang = "") {
|
|
104
|
+
const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
|
|
105
|
+
const ticks = "`".repeat(Math.max(3, longest + 1));
|
|
106
|
+
return `${ticks}${lang}\n${content}\n${ticks}`;
|
|
107
|
+
}
|
|
108
|
+
function quote(text) {
|
|
109
|
+
return text.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
|
|
110
|
+
}
|
|
111
|
+
function inline(text) {
|
|
112
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
113
|
+
return clean.includes("`") ? `"${clean}"` : `\`${clean}\``;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One comment as a ready-to-paste task for a coding agent. Single source of
|
|
117
|
+
* truth for the widget, dashboard, CLI and MCP server.
|
|
118
|
+
*/
|
|
119
|
+
function buildCommentPrompt(comment, options = {}) {
|
|
120
|
+
const includeContext = options.includeContext ?? true;
|
|
121
|
+
const { anchor, page } = comment;
|
|
122
|
+
const out = [];
|
|
123
|
+
out.push("Fix this piece of feedback that was left on the live site with Nuni.", "", "## Comment", "", quote(comment.body), "", `From ${comment.authorName} on ${new Date(comment.createdAt).toISOString().slice(0, 10)}` + (comment.status === "resolved" ? " (already resolved)" : ""));
|
|
124
|
+
if (comment.replies?.length) {
|
|
125
|
+
out.push("", `## Replies (${comment.replies.length})`, "");
|
|
126
|
+
for (const reply of comment.replies) out.push(`${reply.authorName}${reply.isOwner ? " (owner)" : ""}:`, quote(reply.body), "");
|
|
127
|
+
out.pop();
|
|
128
|
+
}
|
|
129
|
+
out.push("", "## Where", "");
|
|
130
|
+
out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
|
|
131
|
+
out.push(`- Open it: ${commentUrl(page, comment._id)}`);
|
|
132
|
+
out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
|
|
133
|
+
if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
|
|
134
|
+
out.push("", "## Element", "");
|
|
135
|
+
out.push(`- Tag: <${anchor.tag}>`);
|
|
136
|
+
if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
|
|
137
|
+
if (anchor.quote) out.push(`- Selected text: ${inline(anchor.quote.exact)}`);
|
|
138
|
+
if (anchor.region) {
|
|
139
|
+
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
140
|
+
const r = anchor.region;
|
|
141
|
+
out.push(`- Area: ${pct(r.w)} × ${pct(r.h)} of the element, from ${pct(r.x)} left and ${pct(r.y)} top`);
|
|
142
|
+
}
|
|
143
|
+
if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
|
|
144
|
+
if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
|
|
145
|
+
if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
|
|
146
|
+
if (anchor.selectors.css) out.push(`- CSS selector: ${inline(anchor.selectors.css)}`);
|
|
147
|
+
out.push(`- DOM path: ${inline(anchor.selectors.path)}`);
|
|
148
|
+
const labels = Object.entries(anchor.attrs).filter(([name]) => name !== "class" && name !== "style").map(([name, value]) => `${name}="${value}"`);
|
|
149
|
+
if (labels.length) out.push(`- Attributes: ${inline(labels.join(" "))}`);
|
|
150
|
+
const context = includeContext ? comment.context : void 0;
|
|
151
|
+
if (context?.dom?.html) out.push("", fence(context.dom.html, "html"));
|
|
152
|
+
const styles = Object.entries(context?.dom?.styles ?? {});
|
|
153
|
+
if (styles.length) out.push("", "Computed styles:", "", fence(styles.map(([k, v]) => `${k}: ${v};`).join("\n"), "css"));
|
|
154
|
+
if (context?.console?.length) {
|
|
155
|
+
out.push("", `## Console (${context.console.length})`, "");
|
|
156
|
+
out.push(fence(context.console.map((e) => `[${e.level}] ${e.message}`).join("\n"), "text"));
|
|
157
|
+
}
|
|
158
|
+
if (context?.network?.length) {
|
|
159
|
+
out.push("", `## Failed requests (${context.network.length})`, "");
|
|
160
|
+
for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
|
|
161
|
+
}
|
|
162
|
+
if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
|
|
163
|
+
out.push("", "## Steps", "", "1. Find the code that renders this element. Search for the text, the component name, the test ID or the selector.", "2. Make the change the comment asks for, and keep the fix focused on it.", "3. Tell me what you changed, so the comment can be resolved in Nuni.", ` If the Nuni CLI is signed in to this project (\`npx ${PACKAGES.cli}@latest login\`), resolve it with \`npx ${PACKAGES.cli}@latest resolve ${comment._id}\` or the \`resolve_comment\` MCP tool.`);
|
|
164
|
+
return out.join("\n");
|
|
165
|
+
}
|
|
74
166
|
//#endregion
|
|
75
167
|
//#region src/config.ts
|
|
76
|
-
const VERSION = "0.1.
|
|
168
|
+
const VERSION = "0.1.5";
|
|
77
169
|
function resolveConfig(options) {
|
|
78
170
|
return {
|
|
79
171
|
project: options.project,
|
|
80
172
|
convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
|
|
81
173
|
convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
|
|
82
174
|
appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
|
|
83
|
-
getPageKey: options.getPageKey
|
|
175
|
+
getPageKey: options.getPageKey ?? pageKeyFor(options.pageKey),
|
|
176
|
+
position: oneOf(options.position, POSITIONS, "bottom-right"),
|
|
177
|
+
accentColor: options.accentColor?.trim() || null,
|
|
178
|
+
theme: oneOf(options.theme, THEMES, "auto"),
|
|
179
|
+
label: options.label?.trim() || null,
|
|
180
|
+
hotkey: options.hotkey === false ? null : options.hotkey?.trim().slice(0, 1).toLowerCase() || "c",
|
|
181
|
+
zIndex: typeof options.zIndex === "number" && Number.isFinite(options.zIndex) ? Math.round(options.zIndex) : null,
|
|
182
|
+
locale: options.locale?.trim() || void 0,
|
|
183
|
+
messages: options.messages ?? {},
|
|
184
|
+
capture: {
|
|
185
|
+
console: options.capture?.console ?? true,
|
|
186
|
+
network: options.capture?.network ?? true,
|
|
187
|
+
dom: options.capture?.dom ?? true,
|
|
188
|
+
screenshot: options.capture?.screenshot ?? true
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const POSITIONS = [
|
|
193
|
+
"bottom-right",
|
|
194
|
+
"bottom-left",
|
|
195
|
+
"top-right",
|
|
196
|
+
"top-left"
|
|
197
|
+
];
|
|
198
|
+
const THEMES = [
|
|
199
|
+
"auto",
|
|
200
|
+
"light",
|
|
201
|
+
"dark"
|
|
202
|
+
];
|
|
203
|
+
function oneOf(value, allowed, fallback) {
|
|
204
|
+
return allowed.includes(value) ? value : fallback;
|
|
205
|
+
}
|
|
206
|
+
/** Query parameters that never tell pages apart. */
|
|
207
|
+
const IGNORED_PARAMS = /^(nuni|utm_.*|fbclid|gclid|msclkid|ref)$/i;
|
|
208
|
+
function pageKeyFor(preset = "path") {
|
|
209
|
+
return (url) => {
|
|
210
|
+
const path = normalizePath(url);
|
|
211
|
+
if (preset === "path+search") {
|
|
212
|
+
const params = [...url.searchParams].filter(([name]) => !IGNORED_PARAMS.test(name)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
213
|
+
return params.length ? `${path}?${new URLSearchParams(params).toString()}` : path;
|
|
214
|
+
}
|
|
215
|
+
if (preset === "path+hash" && url.hash.length > 1 && !path.includes("#")) {
|
|
216
|
+
if (!/^#!?\//.test(url.hash)) return `${path}${url.hash}`;
|
|
217
|
+
}
|
|
218
|
+
return path;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
|
|
223
|
+
function _typeof(o) {
|
|
224
|
+
"@babel/helpers - typeof";
|
|
225
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
226
|
+
return typeof o;
|
|
227
|
+
} : function(o) {
|
|
228
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
229
|
+
}, _typeof(o);
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
|
|
233
|
+
function toPrimitive(t, r) {
|
|
234
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
235
|
+
var e = t[Symbol.toPrimitive];
|
|
236
|
+
if (void 0 !== e) {
|
|
237
|
+
var i = e.call(t, r || "default");
|
|
238
|
+
if ("object" != _typeof(i)) return i;
|
|
239
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
240
|
+
}
|
|
241
|
+
return ("string" === r ? String : Number)(t);
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
|
|
245
|
+
function toPropertyKey(t) {
|
|
246
|
+
var i = toPrimitive(t, "string");
|
|
247
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
|
|
251
|
+
function _defineProperty(e, r, t) {
|
|
252
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
253
|
+
value: t,
|
|
254
|
+
enumerable: !0,
|
|
255
|
+
configurable: !0,
|
|
256
|
+
writable: !0
|
|
257
|
+
}) : e[r] = t, e;
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/context.ts
|
|
261
|
+
const MESSAGE_MAX = LIMITS.contextMessageMaxLength;
|
|
262
|
+
function describeValue(value) {
|
|
263
|
+
if (typeof value === "string") return value;
|
|
264
|
+
if (value instanceof Error) {
|
|
265
|
+
const frame = value.stack?.split("\n").find((line) => line.trim().startsWith("at "));
|
|
266
|
+
return `${value.name}: ${value.message}${frame ? ` (${frame.trim()})` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
let budget = 200;
|
|
270
|
+
return JSON.stringify(value, (_key, v) => budget-- > 0 ? v : void 0) ?? String(value);
|
|
271
|
+
} catch {
|
|
272
|
+
return String(value);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function clip(text) {
|
|
276
|
+
return text.length > MESSAGE_MAX ? `${text.slice(0, MESSAGE_MAX - 1)}…` : text;
|
|
277
|
+
}
|
|
278
|
+
/** Origin + path only: query strings and hashes often carry tokens. */
|
|
279
|
+
function safeUrl(input) {
|
|
280
|
+
return clip(withoutQuery(input, location.href));
|
|
281
|
+
}
|
|
282
|
+
var Ring = class {
|
|
283
|
+
constructor() {
|
|
284
|
+
_defineProperty(this, "items", []);
|
|
285
|
+
}
|
|
286
|
+
push(item) {
|
|
287
|
+
this.items.push(item);
|
|
288
|
+
if (this.items.length > LIMITS.contextEntryMax) this.items.shift();
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Keep the latest console errors and failed requests, so a comment can carry
|
|
293
|
+
* what went wrong on the page. Installed once per mount, removed on destroy.
|
|
294
|
+
* `ignore` skips Nuni's own traffic.
|
|
295
|
+
*/
|
|
296
|
+
function startCollectors(options, ignore) {
|
|
297
|
+
const logs = new Ring();
|
|
298
|
+
const requests = new Ring();
|
|
299
|
+
const undo = [];
|
|
300
|
+
const log = (level, args) => {
|
|
301
|
+
logs.push({
|
|
302
|
+
level,
|
|
303
|
+
message: clip(stripUrlQueries(args.map(describeValue).join(" "))),
|
|
304
|
+
at: Date.now()
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
const failed = (method, url, status) => {
|
|
308
|
+
if (ignore(url)) return;
|
|
309
|
+
requests.push({
|
|
310
|
+
method: method.toUpperCase(),
|
|
311
|
+
url: safeUrl(url),
|
|
312
|
+
status,
|
|
313
|
+
at: Date.now()
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
if (options.console) {
|
|
317
|
+
for (const level of ["error", "warn"]) {
|
|
318
|
+
const original = console[level];
|
|
319
|
+
const wrapped = (...args) => {
|
|
320
|
+
log(level, args);
|
|
321
|
+
original.apply(console, args);
|
|
322
|
+
};
|
|
323
|
+
console[level] = wrapped;
|
|
324
|
+
undo.push(() => {
|
|
325
|
+
if (console[level] === wrapped) console[level] = original;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const onError = (e) => {
|
|
329
|
+
if (e.error) log("error", [e.error]);
|
|
330
|
+
else log("error", [`${e.message}${e.filename ? ` (${safeUrl(e.filename)}:${e.lineno})` : ""}`]);
|
|
331
|
+
};
|
|
332
|
+
const onRejection = (e) => log("error", ["Unhandled rejection:", e.reason]);
|
|
333
|
+
window.addEventListener("error", onError);
|
|
334
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
335
|
+
undo.push(() => {
|
|
336
|
+
window.removeEventListener("error", onError);
|
|
337
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (options.network) {
|
|
341
|
+
const originalFetch = window.fetch;
|
|
342
|
+
if (typeof originalFetch === "function") {
|
|
343
|
+
const wrappedFetch = async (input, init) => {
|
|
344
|
+
if (init && init[CAPTURE_MARK]) return originalFetch.call(window, input, init);
|
|
345
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
346
|
+
const method = init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET");
|
|
347
|
+
try {
|
|
348
|
+
const response = await originalFetch.call(window, input, init);
|
|
349
|
+
if (!response.ok && response.type !== "opaque") failed(method, url, response.status);
|
|
350
|
+
return response;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (!(error instanceof DOMException && error.name === "AbortError")) failed(method, url, 0);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
window.fetch = wrappedFetch;
|
|
357
|
+
undo.push(() => {
|
|
358
|
+
if (window.fetch === wrappedFetch) window.fetch = originalFetch;
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
const proto = window.XMLHttpRequest?.prototype;
|
|
362
|
+
if (proto) {
|
|
363
|
+
const open = proto.open;
|
|
364
|
+
const send = proto.send;
|
|
365
|
+
const info = /* @__PURE__ */ new WeakMap();
|
|
366
|
+
const wrappedOpen = function(method, url, ...rest) {
|
|
367
|
+
info.set(this, {
|
|
368
|
+
method,
|
|
369
|
+
url: String(url)
|
|
370
|
+
});
|
|
371
|
+
return open.call(this, method, url, ...rest);
|
|
372
|
+
};
|
|
373
|
+
proto.open = wrappedOpen;
|
|
374
|
+
const wrappedSend = function(body) {
|
|
375
|
+
const request = info.get(this);
|
|
376
|
+
if (request) {
|
|
377
|
+
let aborted = false;
|
|
378
|
+
const onAbort = () => aborted = true;
|
|
379
|
+
const onEnd = () => {
|
|
380
|
+
this.removeEventListener("abort", onAbort);
|
|
381
|
+
this.removeEventListener("loadend", onEnd);
|
|
382
|
+
if (!aborted && (this.status === 0 || this.status >= 400)) failed(request.method, request.url, this.status);
|
|
383
|
+
};
|
|
384
|
+
this.addEventListener("abort", onAbort);
|
|
385
|
+
this.addEventListener("loadend", onEnd);
|
|
386
|
+
}
|
|
387
|
+
return send.call(this, body);
|
|
388
|
+
};
|
|
389
|
+
proto.send = wrappedSend;
|
|
390
|
+
undo.push(() => {
|
|
391
|
+
if (proto.open === wrappedOpen) proto.open = open;
|
|
392
|
+
if (proto.send === wrappedSend) proto.send = send;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
console: () => [...logs.items],
|
|
398
|
+
network: () => [...requests.items],
|
|
399
|
+
stop() {
|
|
400
|
+
for (const fn of undo.splice(0)) fn();
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const STYLE_PROPS = [
|
|
405
|
+
"display",
|
|
406
|
+
"position",
|
|
407
|
+
"width",
|
|
408
|
+
"height",
|
|
409
|
+
"margin",
|
|
410
|
+
"padding",
|
|
411
|
+
"gap",
|
|
412
|
+
"font-family",
|
|
413
|
+
"font-size",
|
|
414
|
+
"font-weight",
|
|
415
|
+
"line-height",
|
|
416
|
+
"letter-spacing",
|
|
417
|
+
"text-align",
|
|
418
|
+
"color",
|
|
419
|
+
"background-color",
|
|
420
|
+
"border",
|
|
421
|
+
"border-radius",
|
|
422
|
+
"box-shadow",
|
|
423
|
+
"opacity"
|
|
424
|
+
];
|
|
425
|
+
const ATTR_MAX = 200;
|
|
426
|
+
const TEXT_MAX = 200;
|
|
427
|
+
/** Replace an element's contents with a short note. */
|
|
428
|
+
function redact(el, note) {
|
|
429
|
+
el.replaceChildren(el.ownerDocument.createComment(` ${note} `));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Remove what should never leave the page (form values, option lists and
|
|
433
|
+
* masked areas) and trim the rest.
|
|
434
|
+
*/
|
|
435
|
+
function scrub(root) {
|
|
436
|
+
const walker = root.ownerDocument.createTreeWalker(root, 5);
|
|
437
|
+
const drop = [];
|
|
438
|
+
for (let node = root; node; node = walker.nextNode()) {
|
|
439
|
+
if (node.nodeType === 3) {
|
|
440
|
+
const text = node.textContent ?? "";
|
|
441
|
+
if (text.length > TEXT_MAX) node.textContent = `${text.slice(0, TEXT_MAX)}…`;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const el = node;
|
|
445
|
+
const tag = el.tagName;
|
|
446
|
+
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
|
|
447
|
+
drop.push(el);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (el.hasAttribute("data-nuni-mask")) {
|
|
451
|
+
for (const attr of Array.from(el.attributes)) if (attr.name !== "id" && attr.name !== "class" && attr.name !== "data-nuni-mask") el.removeAttribute(attr.name);
|
|
452
|
+
redact(el, "masked");
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (tag === "INPUT" || tag === "TEXTAREA") {
|
|
456
|
+
el.removeAttribute("value");
|
|
457
|
+
if (tag === "TEXTAREA") el.textContent = "";
|
|
458
|
+
}
|
|
459
|
+
if (tag === "SELECT" || tag === "DATALIST") {
|
|
460
|
+
const count = el.getElementsByTagName("option").length;
|
|
461
|
+
redact(el, `${count} options`);
|
|
462
|
+
}
|
|
463
|
+
for (const attr of Array.from(el.attributes)) if (attr.value.length > ATTR_MAX) el.setAttribute(attr.name, `${attr.value.slice(0, ATTR_MAX)}…`);
|
|
464
|
+
}
|
|
465
|
+
for (const node of drop) node.parentNode?.removeChild(node);
|
|
466
|
+
}
|
|
467
|
+
/** Clone at most `depth` levels of children. */
|
|
468
|
+
function shallowClone(el, depth) {
|
|
469
|
+
const copy = el.cloneNode(false);
|
|
470
|
+
if (depth > 0) for (const child of Array.from(el.childNodes)) copy.appendChild(child.nodeType === 1 ? shallowClone(child, depth - 1) : child.cloneNode(false));
|
|
471
|
+
else if (el.childElementCount) copy.appendChild(el.ownerDocument.createComment(` ${el.childElementCount} more `));
|
|
472
|
+
return copy;
|
|
473
|
+
}
|
|
474
|
+
/** The element's markup and key computed styles, for design feedback. */
|
|
475
|
+
function domContext(el) {
|
|
476
|
+
const max = LIMITS.domSnippetMaxLength;
|
|
477
|
+
let html = "";
|
|
478
|
+
const inMasked = Boolean(el.parentElement?.closest(`[${MASK_ATTRIBUTE}]`));
|
|
479
|
+
for (const depth of inMasked ? [-1] : [
|
|
480
|
+
Infinity,
|
|
481
|
+
3,
|
|
482
|
+
1,
|
|
483
|
+
0
|
|
484
|
+
]) {
|
|
485
|
+
const copy = depth === Infinity ? el.cloneNode(true) : shallowClone(el, Math.max(0, depth));
|
|
486
|
+
if (depth < 0) copy.setAttribute(MASK_ATTRIBUTE, "");
|
|
487
|
+
scrub(copy);
|
|
488
|
+
html = stripHtmlUrlQueries(copy.outerHTML);
|
|
489
|
+
if (html.length <= max) break;
|
|
490
|
+
}
|
|
491
|
+
if (html.length > max) html = `${html.slice(0, max - 1)}…`;
|
|
492
|
+
const styles = {};
|
|
493
|
+
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
494
|
+
for (const prop of STYLE_PROPS) {
|
|
495
|
+
const value = computed?.getPropertyValue(prop).trim();
|
|
496
|
+
if (value) styles[prop] = value;
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
html,
|
|
500
|
+
styles
|
|
84
501
|
};
|
|
85
502
|
}
|
|
86
503
|
//#endregion
|
|
@@ -151,6 +568,463 @@ function attributeAllowed(name, options) {
|
|
|
151
568
|
return !prefixes || !prefixes.some((p) => name.startsWith(p));
|
|
152
569
|
}
|
|
153
570
|
//#endregion
|
|
571
|
+
//#region ../anchor/src/text.ts
|
|
572
|
+
const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
573
|
+
"SCRIPT",
|
|
574
|
+
"STYLE",
|
|
575
|
+
"NOSCRIPT",
|
|
576
|
+
"TEMPLATE",
|
|
577
|
+
"SVG"
|
|
578
|
+
]);
|
|
579
|
+
function normalizeText(value) {
|
|
580
|
+
return value.replace(/\s+/g, " ").trim();
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Visible-ish text of an element, whitespace-normalized and truncated.
|
|
584
|
+
* Areas marked data-nuni-mask count as empty, both when capturing and when
|
|
585
|
+
* resolving, so their (private) text is never stored and matching stays
|
|
586
|
+
* consistent.
|
|
587
|
+
*/
|
|
588
|
+
function elementText(el, max = 120) {
|
|
589
|
+
if (el.closest(MASK_SELECTOR)) return "";
|
|
590
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
|
|
591
|
+
let out = "";
|
|
592
|
+
const walk = (node) => {
|
|
593
|
+
if (out.length > max * 2) return;
|
|
594
|
+
if (node.nodeType === 3) {
|
|
595
|
+
out += node.nodeValue ?? "";
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (node.nodeType !== 1) return;
|
|
599
|
+
const tag = node.tagName.toUpperCase();
|
|
600
|
+
if (SKIP_TEXT_TAGS.has(tag)) return;
|
|
601
|
+
if (node.hasAttribute("data-nuni-mask")) return;
|
|
602
|
+
if (node.hasAttribute("data-nuni")) return;
|
|
603
|
+
if (tag === "IMG") {
|
|
604
|
+
out += " " + (node.getAttribute("alt") ?? "") + " ";
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const shadow = node.shadowRoot;
|
|
608
|
+
if (shadow) {
|
|
609
|
+
for (const child of Array.from(shadow.childNodes)) walk(child);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (tag === "SLOT") {
|
|
613
|
+
const assigned = node.assignedNodes?.({ flatten: true });
|
|
614
|
+
if (assigned?.length) {
|
|
615
|
+
for (const child of assigned) walk(child);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const child of Array.from(node.childNodes)) walk(child);
|
|
620
|
+
if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
|
|
621
|
+
};
|
|
622
|
+
walk(el);
|
|
623
|
+
return normalizeText(out).slice(0, max);
|
|
624
|
+
}
|
|
625
|
+
function bigrams(value) {
|
|
626
|
+
const s = value.toLowerCase();
|
|
627
|
+
const map = /* @__PURE__ */ new Map();
|
|
628
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
629
|
+
const gram = s.slice(i, i + 2);
|
|
630
|
+
map.set(gram, (map.get(gram) ?? 0) + 1);
|
|
631
|
+
}
|
|
632
|
+
return map;
|
|
633
|
+
}
|
|
634
|
+
/** Sørensen-Dice coefficient on character bigrams, 0..1. */
|
|
635
|
+
function textSimilarity(a, b) {
|
|
636
|
+
if (a === b) return 1;
|
|
637
|
+
const na = a.toLowerCase();
|
|
638
|
+
const nb = b.toLowerCase();
|
|
639
|
+
if (na === nb) return 1;
|
|
640
|
+
if (na.length < 2 || nb.length < 2) return 0;
|
|
641
|
+
const ga = bigrams(na);
|
|
642
|
+
const gb = bigrams(nb);
|
|
643
|
+
let overlap = 0;
|
|
644
|
+
for (const [gram, count] of ga) {
|
|
645
|
+
const other = gb.get(gram);
|
|
646
|
+
if (other) overlap += Math.min(count, other);
|
|
647
|
+
}
|
|
648
|
+
return 2 * overlap / (na.length - 1 + (nb.length - 1));
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region ../anchor/src/quote.ts
|
|
652
|
+
const SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
653
|
+
"SCRIPT",
|
|
654
|
+
"STYLE",
|
|
655
|
+
"NOSCRIPT",
|
|
656
|
+
"TEMPLATE",
|
|
657
|
+
"SVG"
|
|
658
|
+
]);
|
|
659
|
+
/** Elements whose edges read as a space between words. */
|
|
660
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
661
|
+
"ADDRESS",
|
|
662
|
+
"ARTICLE",
|
|
663
|
+
"ASIDE",
|
|
664
|
+
"BLOCKQUOTE",
|
|
665
|
+
"BR",
|
|
666
|
+
"DD",
|
|
667
|
+
"DIV",
|
|
668
|
+
"DL",
|
|
669
|
+
"DT",
|
|
670
|
+
"FIGCAPTION",
|
|
671
|
+
"FIGURE",
|
|
672
|
+
"FOOTER",
|
|
673
|
+
"FORM",
|
|
674
|
+
"H1",
|
|
675
|
+
"H2",
|
|
676
|
+
"H3",
|
|
677
|
+
"H4",
|
|
678
|
+
"H5",
|
|
679
|
+
"H6",
|
|
680
|
+
"HEADER",
|
|
681
|
+
"HR",
|
|
682
|
+
"LI",
|
|
683
|
+
"MAIN",
|
|
684
|
+
"NAV",
|
|
685
|
+
"OL",
|
|
686
|
+
"P",
|
|
687
|
+
"PRE",
|
|
688
|
+
"SECTION",
|
|
689
|
+
"TABLE",
|
|
690
|
+
"TD",
|
|
691
|
+
"TH",
|
|
692
|
+
"TR",
|
|
693
|
+
"UL"
|
|
694
|
+
]);
|
|
695
|
+
/** Formatting inside a paragraph: a quote climbs out of these. */
|
|
696
|
+
const INLINE_TAGS = /* @__PURE__ */ new Set([
|
|
697
|
+
"A",
|
|
698
|
+
"ABBR",
|
|
699
|
+
"B",
|
|
700
|
+
"BDI",
|
|
701
|
+
"BDO",
|
|
702
|
+
"CITE",
|
|
703
|
+
"CODE",
|
|
704
|
+
"DATA",
|
|
705
|
+
"DEL",
|
|
706
|
+
"DFN",
|
|
707
|
+
"EM",
|
|
708
|
+
"I",
|
|
709
|
+
"INS",
|
|
710
|
+
"KBD",
|
|
711
|
+
"LABEL",
|
|
712
|
+
"MARK",
|
|
713
|
+
"Q",
|
|
714
|
+
"S",
|
|
715
|
+
"SAMP",
|
|
716
|
+
"SMALL",
|
|
717
|
+
"SPAN",
|
|
718
|
+
"STRONG",
|
|
719
|
+
"SUB",
|
|
720
|
+
"SUP",
|
|
721
|
+
"TIME",
|
|
722
|
+
"U",
|
|
723
|
+
"VAR"
|
|
724
|
+
]);
|
|
725
|
+
/** Longest text searched for a quote inside one element. */
|
|
726
|
+
const MAX_TEXT = 2e4;
|
|
727
|
+
/** Longest text searched for an edited quote, word by word. */
|
|
728
|
+
const MAX_FUZZY_TEXT = 6e3;
|
|
729
|
+
const FUZZY_MIN = .75;
|
|
730
|
+
const WORD = /[\p{L}\p{N}]/u;
|
|
731
|
+
function textMap(el) {
|
|
732
|
+
const map = {
|
|
733
|
+
text: "",
|
|
734
|
+
nodes: [],
|
|
735
|
+
node: [],
|
|
736
|
+
offset: []
|
|
737
|
+
};
|
|
738
|
+
if (el.closest(MASK_SELECTOR)) return map;
|
|
739
|
+
const chars = [];
|
|
740
|
+
let space = true;
|
|
741
|
+
const push = (c, node, offset) => {
|
|
742
|
+
chars.push(c);
|
|
743
|
+
map.node.push(node);
|
|
744
|
+
map.offset.push(offset);
|
|
745
|
+
};
|
|
746
|
+
const gap = () => {
|
|
747
|
+
if (space || !map.nodes.length) return;
|
|
748
|
+
const last = map.nodes.length - 1;
|
|
749
|
+
push(" ", last, map.nodes[last].length);
|
|
750
|
+
space = true;
|
|
751
|
+
};
|
|
752
|
+
const walk = (node) => {
|
|
753
|
+
if (chars.length >= MAX_TEXT) return;
|
|
754
|
+
if (node.nodeType === 3) {
|
|
755
|
+
const value = node.nodeValue ?? "";
|
|
756
|
+
if (!value) return;
|
|
757
|
+
const index = map.nodes.push(node) - 1;
|
|
758
|
+
for (let i = 0; i < value.length && chars.length < MAX_TEXT; i++) {
|
|
759
|
+
const c = value[i];
|
|
760
|
+
if (c === " " || c === "\n" || c === " " || c === "\r" || c === "\f") {
|
|
761
|
+
if (!space) push(" ", index, i);
|
|
762
|
+
space = true;
|
|
763
|
+
} else {
|
|
764
|
+
push(c, index, i);
|
|
765
|
+
space = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (node.nodeType !== 1) return;
|
|
771
|
+
const el = node;
|
|
772
|
+
const tag = el.tagName.toUpperCase();
|
|
773
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
774
|
+
if (el.hasAttribute("data-nuni-mask") || el.hasAttribute("data-nuni")) return;
|
|
775
|
+
const block = BLOCK_TAGS.has(tag);
|
|
776
|
+
if (block) gap();
|
|
777
|
+
for (const child of Array.from(el.childNodes)) walk(child);
|
|
778
|
+
if (block) gap();
|
|
779
|
+
};
|
|
780
|
+
walk(el);
|
|
781
|
+
while (chars.length && chars[chars.length - 1] === " ") {
|
|
782
|
+
chars.pop();
|
|
783
|
+
map.node.pop();
|
|
784
|
+
map.offset.pop();
|
|
785
|
+
}
|
|
786
|
+
map.text = chars.join("");
|
|
787
|
+
return map;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Where a DOM boundary point falls in the element's text: the index of the
|
|
791
|
+
* first character at or after it.
|
|
792
|
+
*/
|
|
793
|
+
function textOffset(map, container, offset) {
|
|
794
|
+
if (!map.text.length) return 0;
|
|
795
|
+
const point = (container.ownerDocument ?? container).createRange();
|
|
796
|
+
point.setStart(container, offset);
|
|
797
|
+
point.collapse(true);
|
|
798
|
+
let lo = 0;
|
|
799
|
+
let hi = map.text.length;
|
|
800
|
+
try {
|
|
801
|
+
while (lo < hi) {
|
|
802
|
+
const mid = lo + hi >> 1;
|
|
803
|
+
const node = map.nodes[map.node[mid]];
|
|
804
|
+
if (point.comparePoint(node, map.offset[mid]) < 0) lo = mid + 1;
|
|
805
|
+
else hi = mid;
|
|
806
|
+
}
|
|
807
|
+
} catch {
|
|
808
|
+
return -1;
|
|
809
|
+
}
|
|
810
|
+
return lo;
|
|
811
|
+
}
|
|
812
|
+
/** A Range over characters [start, end) of the element's text. */
|
|
813
|
+
function rangeFor(map, start, end) {
|
|
814
|
+
const first = map.nodes[map.node[start]];
|
|
815
|
+
const last = map.nodes[map.node[end - 1]];
|
|
816
|
+
const range = first.ownerDocument.createRange();
|
|
817
|
+
range.setStart(first, map.offset[start]);
|
|
818
|
+
range.setEnd(last, Math.min(last.length, map.offset[end - 1] + 1));
|
|
819
|
+
return range;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* The element a text selection is anchored to: the closest element holding
|
|
823
|
+
* all of it, out of inline formatting (a <strong>, a link) so it is the
|
|
824
|
+
* paragraph or heading, which is easier to find again.
|
|
825
|
+
*/
|
|
826
|
+
function quoteContainer(range) {
|
|
827
|
+
let node = range.commonAncestorContainer;
|
|
828
|
+
if (node.nodeType !== 1) node = node.parentNode;
|
|
829
|
+
if (!node || node.nodeType !== 1) return null;
|
|
830
|
+
let el = node;
|
|
831
|
+
const body = el.ownerDocument.body;
|
|
832
|
+
while (INLINE_TAGS.has(el.tagName.toUpperCase()) && el.parentElement && el.parentElement !== body) el = el.parentElement;
|
|
833
|
+
return el;
|
|
834
|
+
}
|
|
835
|
+
/** The quoted words of a selection inside `el`, with context around them. */
|
|
836
|
+
function captureQuote(range, el) {
|
|
837
|
+
const map = textMap(el);
|
|
838
|
+
let start = textOffset(map, range.startContainer, range.startOffset);
|
|
839
|
+
let end = textOffset(map, range.endContainer, range.endOffset);
|
|
840
|
+
if (start < 0 || end < 0) return null;
|
|
841
|
+
const { text } = map;
|
|
842
|
+
while (start < end && text[start] === " ") start++;
|
|
843
|
+
while (end > start && text[end - 1] === " ") end--;
|
|
844
|
+
if (end <= start) return null;
|
|
845
|
+
const exact = text.slice(start, Math.min(end, start + LIMITS.quoteMaxLength));
|
|
846
|
+
const after = start + exact.length;
|
|
847
|
+
return {
|
|
848
|
+
exact,
|
|
849
|
+
prefix: text.slice(Math.max(0, start - LIMITS.quoteContextLength), start),
|
|
850
|
+
suffix: text.slice(after, after + LIMITS.quoteContextLength)
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Find a quote again inside `el`. When it is not there (the sentence moved
|
|
855
|
+
* to the next paragraph), look in the parent and grandparent, but only take
|
|
856
|
+
* a match there whose surrounding text still agrees.
|
|
857
|
+
*/
|
|
858
|
+
function findQuote(el, quote) {
|
|
859
|
+
const own = findIn(el, quote, 0);
|
|
860
|
+
if (own) return own;
|
|
861
|
+
let node = el.parentElement;
|
|
862
|
+
const body = el.ownerDocument.body;
|
|
863
|
+
for (let i = 0; node && node !== body && i < 2; i++) {
|
|
864
|
+
const hit = findIn(node, quote, .6);
|
|
865
|
+
if (hit) return hit;
|
|
866
|
+
node = node.parentElement;
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
function affixSimilarity(actual, expected) {
|
|
871
|
+
if (!expected) return actual ? .8 : 1;
|
|
872
|
+
return textSimilarity(actual, expected);
|
|
873
|
+
}
|
|
874
|
+
/** How well the text around a match agrees with the quote's context. */
|
|
875
|
+
function contextScore(text, start, end, quote) {
|
|
876
|
+
const before = text.slice(Math.max(0, start - quote.prefix.length), start);
|
|
877
|
+
const after = text.slice(end, end + quote.suffix.length);
|
|
878
|
+
return (affixSimilarity(before.trimStart(), quote.prefix.trimStart()) + affixSimilarity(after.trimEnd(), quote.suffix.trimEnd())) / 2;
|
|
879
|
+
}
|
|
880
|
+
function findIn(el, quote, minContext) {
|
|
881
|
+
const { exact } = quote;
|
|
882
|
+
if (!exact) return null;
|
|
883
|
+
const map = textMap(el);
|
|
884
|
+
const { text } = map;
|
|
885
|
+
if (!text) return null;
|
|
886
|
+
let best = -1;
|
|
887
|
+
let bestScore = -1;
|
|
888
|
+
for (let i = text.indexOf(exact); i >= 0; i = text.indexOf(exact, i + 1)) {
|
|
889
|
+
const score = contextScore(text, i, i + exact.length, quote);
|
|
890
|
+
if (score > bestScore) {
|
|
891
|
+
best = i;
|
|
892
|
+
bestScore = score;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (best >= 0 && bestScore >= minContext) return {
|
|
896
|
+
range: rangeFor(map, best, best + exact.length),
|
|
897
|
+
exact: true
|
|
898
|
+
};
|
|
899
|
+
if (exact.length < 8 || text.length > MAX_FUZZY_TEXT) return null;
|
|
900
|
+
const fuzzy = closestSpan(text, quote);
|
|
901
|
+
if (!fuzzy) return null;
|
|
902
|
+
const context = contextScore(text, fuzzy.start, fuzzy.end, quote);
|
|
903
|
+
if (fuzzy.score < FUZZY_MIN || context < Math.max(minContext, .3)) return null;
|
|
904
|
+
return {
|
|
905
|
+
range: rangeFor(map, fuzzy.start, fuzzy.end),
|
|
906
|
+
exact: false
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
/** Word-aligned spans of `text` scored by similarity to the quote. */
|
|
910
|
+
function closestSpan(text, quote) {
|
|
911
|
+
const word = (c) => c !== void 0 && WORD.test(c);
|
|
912
|
+
const starts = [];
|
|
913
|
+
const ends = [];
|
|
914
|
+
for (let i = 0; i < text.length; i++) {
|
|
915
|
+
const c = text[i];
|
|
916
|
+
if (c === " ") continue;
|
|
917
|
+
const prev = text[i - 1];
|
|
918
|
+
const next = text[i + 1];
|
|
919
|
+
if (prev === void 0 || prev === " " || word(c) && !word(prev)) starts.push(i);
|
|
920
|
+
if (next === void 0 || next === " " || word(c) && !word(next)) ends.push(i + 1);
|
|
921
|
+
}
|
|
922
|
+
const length = quote.exact.length;
|
|
923
|
+
let best = null;
|
|
924
|
+
let e = 0;
|
|
925
|
+
for (const start of starts) {
|
|
926
|
+
while (e < ends.length && ends[e] <= start) e++;
|
|
927
|
+
for (let j = e; j < ends.length; j++) {
|
|
928
|
+
const span = ends[j] - start;
|
|
929
|
+
if (span < length * .7) continue;
|
|
930
|
+
if (span > length * 1.3) break;
|
|
931
|
+
const score = textSimilarity(text.slice(start, ends[j]), quote.exact);
|
|
932
|
+
if (!best || score > best.score) best = {
|
|
933
|
+
start,
|
|
934
|
+
end: ends[j],
|
|
935
|
+
score
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return best;
|
|
940
|
+
}
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region ../anchor/src/scope.ts
|
|
943
|
+
function isShadowRoot(node) {
|
|
944
|
+
return Boolean(node && node.nodeType === 11 && "host" in node);
|
|
945
|
+
}
|
|
946
|
+
/** The shadow root or document the element lives in. */
|
|
947
|
+
function scopeRootOf(el) {
|
|
948
|
+
const root = el.getRootNode();
|
|
949
|
+
return isShadowRoot(root) ? root : el.ownerDocument;
|
|
950
|
+
}
|
|
951
|
+
function scopeDocument(root) {
|
|
952
|
+
return isShadowRoot(root) ? root.ownerDocument : root;
|
|
953
|
+
}
|
|
954
|
+
/** The <iframe> showing this document, when the parent is same-origin. */
|
|
955
|
+
function frameElementOf(doc) {
|
|
956
|
+
try {
|
|
957
|
+
return doc.defaultView?.frameElement ?? null;
|
|
958
|
+
} catch {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/** A same-origin iframe's document, or null (cross-origin, not loaded). */
|
|
963
|
+
function frameDocument(el) {
|
|
964
|
+
if (el.tagName !== "IFRAME" && el.tagName !== "FRAME") return null;
|
|
965
|
+
try {
|
|
966
|
+
return el.contentDocument;
|
|
967
|
+
} catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Shadow hosts and iframes between the page's document and the element,
|
|
973
|
+
* outermost first.
|
|
974
|
+
*/
|
|
975
|
+
function scopeChain(el, top) {
|
|
976
|
+
const chain = [];
|
|
977
|
+
let node = el;
|
|
978
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
979
|
+
const root = node.getRootNode();
|
|
980
|
+
if (isShadowRoot(root)) {
|
|
981
|
+
chain.unshift({
|
|
982
|
+
kind: "shadow",
|
|
983
|
+
host: root.host
|
|
984
|
+
});
|
|
985
|
+
node = root.host;
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (node.ownerDocument === top) break;
|
|
989
|
+
const frame = frameElementOf(node.ownerDocument);
|
|
990
|
+
if (!frame) break;
|
|
991
|
+
chain.unshift({
|
|
992
|
+
kind: "frame",
|
|
993
|
+
host: frame
|
|
994
|
+
});
|
|
995
|
+
node = frame;
|
|
996
|
+
}
|
|
997
|
+
return chain;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* The element's box in the page's viewport: getBoundingClientRect plus the
|
|
1001
|
+
* offset of every iframe it is inside.
|
|
1002
|
+
*/
|
|
1003
|
+
function viewportRect(el, top) {
|
|
1004
|
+
const r = el.getBoundingClientRect();
|
|
1005
|
+
let left = r.left;
|
|
1006
|
+
let topPx = r.top;
|
|
1007
|
+
let clipped = false;
|
|
1008
|
+
let doc = el.ownerDocument;
|
|
1009
|
+
for (let depth = 0; depth < 8 && doc !== top; depth++) {
|
|
1010
|
+
const frame = frameElementOf(doc);
|
|
1011
|
+
if (!frame) break;
|
|
1012
|
+
const view = doc.defaultView;
|
|
1013
|
+
if (view && (left + r.width < 0 || topPx + r.height < 0 || left > view.innerWidth || topPx > view.innerHeight)) clipped = true;
|
|
1014
|
+
const f = frame.getBoundingClientRect();
|
|
1015
|
+
left += f.left + frame.clientLeft;
|
|
1016
|
+
topPx += f.top + frame.clientTop;
|
|
1017
|
+
doc = frame.ownerDocument;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
left,
|
|
1021
|
+
top: topPx,
|
|
1022
|
+
width: r.width,
|
|
1023
|
+
height: r.height,
|
|
1024
|
+
clipped
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
//#endregion
|
|
154
1028
|
//#region ../anchor/src/stable.ts
|
|
155
1029
|
/**
|
|
156
1030
|
* Heuristics that decide whether an id, class or attribute is a stable,
|
|
@@ -304,7 +1178,7 @@ function nthOfType(el) {
|
|
|
304
1178
|
return index;
|
|
305
1179
|
}
|
|
306
1180
|
function countOfType(el) {
|
|
307
|
-
const parent = el.
|
|
1181
|
+
const parent = el.parentNode;
|
|
308
1182
|
if (!parent) return 1;
|
|
309
1183
|
let count = 0;
|
|
310
1184
|
for (const child of Array.from(parent.children)) if (child.tagName === el.tagName) count++;
|
|
@@ -317,21 +1191,23 @@ function queryCount(root, selector) {
|
|
|
317
1191
|
return Infinity;
|
|
318
1192
|
}
|
|
319
1193
|
}
|
|
320
|
-
function isUniqueId(
|
|
321
|
-
return queryCount(
|
|
1194
|
+
function isUniqueId(root, id) {
|
|
1195
|
+
return queryCount(root, `#${cssEscape(id)}`) === 1;
|
|
322
1196
|
}
|
|
323
1197
|
/**
|
|
324
1198
|
* Structural path: nth-of-type chain from the nearest ancestor with a
|
|
325
1199
|
* stable unique id (or from <html>). Always resolvable when the DOM is
|
|
326
|
-
* unchanged; the last-resort selector.
|
|
1200
|
+
* unchanged; the last-resort selector. Inside a shadow root it starts at
|
|
1201
|
+
* the root's top-level element instead of <html>.
|
|
327
1202
|
*/
|
|
328
1203
|
function buildPath(el) {
|
|
1204
|
+
const root = scopeRootOf(el);
|
|
329
1205
|
const doc = el.ownerDocument;
|
|
330
1206
|
const parts = [];
|
|
331
1207
|
let node = el;
|
|
332
1208
|
while (node && node !== doc.documentElement) {
|
|
333
1209
|
const id = stableIdOf(node);
|
|
334
|
-
if (id && node !== el && isUniqueId(
|
|
1210
|
+
if (id && node !== el && isUniqueId(root, id)) {
|
|
335
1211
|
parts.unshift(`#${cssEscape(id)}`);
|
|
336
1212
|
return parts.join(" > ");
|
|
337
1213
|
}
|
|
@@ -339,8 +1215,28 @@ function buildPath(el) {
|
|
|
339
1215
|
parts.unshift(countOfType(node) > 1 ? `${tag}:nth-of-type(${nthOfType(node)})` : tag);
|
|
340
1216
|
node = node.parentElement;
|
|
341
1217
|
}
|
|
1218
|
+
if (isShadowRoot(root)) return parts.join(" > ");
|
|
342
1219
|
return parts.length ? `html > ${parts.join(" > ")}` : "html";
|
|
343
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The element a path points to in a scope. Shadow paths are matched from
|
|
1223
|
+
* the root's top-level elements, like document paths are from <html>.
|
|
1224
|
+
*/
|
|
1225
|
+
function queryPath(root, path) {
|
|
1226
|
+
let matches;
|
|
1227
|
+
try {
|
|
1228
|
+
matches = Array.from(root.querySelectorAll(path)).slice(0, 50);
|
|
1229
|
+
} catch {
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
if (!isShadowRoot(root) || path.startsWith("#")) return matches[0] ?? null;
|
|
1233
|
+
const depth = path.split(" > ").length;
|
|
1234
|
+
return matches.find((el) => {
|
|
1235
|
+
let node = el;
|
|
1236
|
+
for (let i = 1; i < depth && node; i++) node = node.parentElement;
|
|
1237
|
+
return node?.parentNode === root;
|
|
1238
|
+
}) ?? null;
|
|
1239
|
+
}
|
|
344
1240
|
function describeSegment(el, options) {
|
|
345
1241
|
const tag = tagOf(el);
|
|
346
1242
|
const testId = testIdOf(el, options);
|
|
@@ -359,7 +1255,7 @@ function describeSegment(el, options) {
|
|
|
359
1255
|
}
|
|
360
1256
|
}
|
|
361
1257
|
const label = el.getAttribute("aria-label");
|
|
362
|
-
if (label && label.length <= 60) segment += attrSelector("aria-label", label);
|
|
1258
|
+
if (label && label.length <= 60 && !el.closest(MASK_SELECTOR)) segment += attrSelector("aria-label", label);
|
|
363
1259
|
return segment;
|
|
364
1260
|
}
|
|
365
1261
|
/**
|
|
@@ -369,8 +1265,9 @@ function describeSegment(el, options) {
|
|
|
369
1265
|
*/
|
|
370
1266
|
function buildCssSelector(el, options = {}) {
|
|
371
1267
|
const doc = el.ownerDocument;
|
|
1268
|
+
const root = scopeRootOf(el);
|
|
372
1269
|
const id = stableIdOf(el);
|
|
373
|
-
if (id && isUniqueId(
|
|
1270
|
+
if (id && isUniqueId(root, id)) return `#${cssEscape(id)}`;
|
|
374
1271
|
const segments = [];
|
|
375
1272
|
let node = el;
|
|
376
1273
|
for (let depth = 0; node && depth < 6; depth++) {
|
|
@@ -379,93 +1276,34 @@ function buildCssSelector(el, options = {}) {
|
|
|
379
1276
|
break;
|
|
380
1277
|
}
|
|
381
1278
|
const nodeId = stableIdOf(node);
|
|
382
|
-
if (depth > 0 && nodeId && isUniqueId(
|
|
1279
|
+
if (depth > 0 && nodeId && isUniqueId(root, nodeId)) {
|
|
383
1280
|
segments.unshift(`#${cssEscape(nodeId)}`);
|
|
384
1281
|
const selector = segments.join(" > ");
|
|
385
|
-
if (queryCount(
|
|
1282
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
386
1283
|
break;
|
|
387
1284
|
}
|
|
388
1285
|
segments.unshift(describeSegment(node, options));
|
|
389
1286
|
let selector = segments.join(" > ");
|
|
390
|
-
if (queryCount(
|
|
1287
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
391
1288
|
const parent = node.parentElement;
|
|
392
1289
|
if (parent && queryCount(parent, `:scope > ${segments[0]}`) > 1) {
|
|
393
1290
|
segments[0] = `${segments[0]}:nth-of-type(${nthOfType(node)})`;
|
|
394
1291
|
selector = segments.join(" > ");
|
|
395
|
-
if (queryCount(
|
|
1292
|
+
if (queryCount(root, selector) === 1) return selector;
|
|
396
1293
|
}
|
|
397
1294
|
node = node.parentElement;
|
|
398
1295
|
}
|
|
399
1296
|
const selector = segments.join(" > ");
|
|
400
|
-
return queryCount(
|
|
401
|
-
}
|
|
402
|
-
//#endregion
|
|
403
|
-
//#region ../anchor/src/text.ts
|
|
404
|
-
const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
405
|
-
"SCRIPT",
|
|
406
|
-
"STYLE",
|
|
407
|
-
"NOSCRIPT",
|
|
408
|
-
"TEMPLATE",
|
|
409
|
-
"SVG"
|
|
410
|
-
]);
|
|
411
|
-
function normalizeText(value) {
|
|
412
|
-
return value.replace(/\s+/g, " ").trim();
|
|
413
|
-
}
|
|
414
|
-
/** Visible-ish text of an element, whitespace-normalized and truncated. */
|
|
415
|
-
function elementText(el, max = 120) {
|
|
416
|
-
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
|
|
417
|
-
let out = "";
|
|
418
|
-
const walk = (node) => {
|
|
419
|
-
if (out.length > max * 2) return;
|
|
420
|
-
if (node.nodeType === 3) {
|
|
421
|
-
out += node.nodeValue ?? "";
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
if (node.nodeType !== 1) return;
|
|
425
|
-
const tag = node.tagName.toUpperCase();
|
|
426
|
-
if (SKIP_TEXT_TAGS.has(tag)) return;
|
|
427
|
-
if (tag === "IMG") {
|
|
428
|
-
out += " " + (node.getAttribute("alt") ?? "") + " ";
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
for (const child of Array.from(node.childNodes)) walk(child);
|
|
432
|
-
if (tag === "BR" || tag === "P" || tag === "DIV" || tag === "LI") out += " ";
|
|
433
|
-
};
|
|
434
|
-
walk(el);
|
|
435
|
-
return normalizeText(out).slice(0, max);
|
|
436
|
-
}
|
|
437
|
-
function bigrams(value) {
|
|
438
|
-
const s = value.toLowerCase();
|
|
439
|
-
const map = /* @__PURE__ */ new Map();
|
|
440
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
441
|
-
const gram = s.slice(i, i + 2);
|
|
442
|
-
map.set(gram, (map.get(gram) ?? 0) + 1);
|
|
443
|
-
}
|
|
444
|
-
return map;
|
|
445
|
-
}
|
|
446
|
-
/** Sørensen-Dice coefficient on character bigrams, 0..1. */
|
|
447
|
-
function textSimilarity(a, b) {
|
|
448
|
-
if (a === b) return 1;
|
|
449
|
-
const na = a.toLowerCase();
|
|
450
|
-
const nb = b.toLowerCase();
|
|
451
|
-
if (na === nb) return 1;
|
|
452
|
-
if (na.length < 2 || nb.length < 2) return 0;
|
|
453
|
-
const ga = bigrams(na);
|
|
454
|
-
const gb = bigrams(nb);
|
|
455
|
-
let overlap = 0;
|
|
456
|
-
for (const [gram, count] of ga) {
|
|
457
|
-
const other = gb.get(gram);
|
|
458
|
-
if (other) overlap += Math.min(count, other);
|
|
459
|
-
}
|
|
460
|
-
return 2 * overlap / (na.length - 1 + (nb.length - 1));
|
|
1297
|
+
return queryCount(root, selector) === 1 ? selector : void 0;
|
|
461
1298
|
}
|
|
462
1299
|
//#endregion
|
|
463
1300
|
//#region ../anchor/src/capture.ts
|
|
464
1301
|
function capturedAttributes(el, options) {
|
|
465
1302
|
const attrs = {};
|
|
1303
|
+
if (el.closest(MASK_SELECTOR)) return attrs;
|
|
466
1304
|
for (const name of CAPTURED_ATTRIBUTES) {
|
|
467
1305
|
if (!attributeAllowed(name, options)) continue;
|
|
468
|
-
if (name === "value" && !(el
|
|
1306
|
+
if (name === "value" && !(el.tagName === "BUTTON" || el.getAttribute("type") === "submit")) continue;
|
|
469
1307
|
const value = el.getAttribute(name);
|
|
470
1308
|
if (value) attrs[name] = value.slice(0, 200);
|
|
471
1309
|
}
|
|
@@ -493,8 +1331,22 @@ function ancestorsOf(el) {
|
|
|
493
1331
|
/**
|
|
494
1332
|
* Record everything needed to find `el` again later.
|
|
495
1333
|
* `point` is the click position in viewport (client) coordinates.
|
|
1334
|
+
*
|
|
1335
|
+
* An element inside open shadow roots or same-origin iframes also records
|
|
1336
|
+
* each host and iframe on the way (`scope`), captured the same way, so it
|
|
1337
|
+
* can be found again level by level.
|
|
496
1338
|
*/
|
|
497
1339
|
function captureAnchor(el, point, options = {}) {
|
|
1340
|
+
const anchor = captureLocal(el, point, options);
|
|
1341
|
+
const chain = scopeChain(el, options.document ?? (typeof document === "undefined" ? el.ownerDocument : document));
|
|
1342
|
+
if (chain.length) anchor.scope = chain.map(({ kind, host }) => ({
|
|
1343
|
+
kind,
|
|
1344
|
+
host: captureLocal(host, void 0, options)
|
|
1345
|
+
}));
|
|
1346
|
+
return anchor;
|
|
1347
|
+
}
|
|
1348
|
+
/** The anchor of `el` within its own scope (document or shadow root). */
|
|
1349
|
+
function captureLocal(el, point, options) {
|
|
498
1350
|
const doc = el.ownerDocument;
|
|
499
1351
|
const rect = documentRect(el);
|
|
500
1352
|
const viewport = viewportInfo(doc);
|
|
@@ -510,7 +1362,7 @@ function captureAnchor(el, point, options = {}) {
|
|
|
510
1362
|
const testId = testIdOf(el, options);
|
|
511
1363
|
const css = buildCssSelector(el, options);
|
|
512
1364
|
const role = el.getAttribute("role") ?? void 0;
|
|
513
|
-
const parent = el.
|
|
1365
|
+
const parent = el.parentNode;
|
|
514
1366
|
const siblings = parent ? Array.from(parent.children) : [el];
|
|
515
1367
|
const anchor = {
|
|
516
1368
|
v: 1,
|
|
@@ -532,13 +1384,140 @@ function captureAnchor(el, point, options = {}) {
|
|
|
532
1384
|
viewport,
|
|
533
1385
|
docSize: documentSize(doc)
|
|
534
1386
|
};
|
|
535
|
-
if (role) anchor.role = role;
|
|
536
|
-
const componentName = reactComponentName(el);
|
|
537
|
-
if (componentName) anchor.componentName = componentName;
|
|
1387
|
+
if (role) anchor.role = role;
|
|
1388
|
+
const componentName = reactComponentName(el);
|
|
1389
|
+
if (componentName) anchor.componentName = componentName;
|
|
1390
|
+
return anchor;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* A text comment: the anchor of the paragraph (or heading, cell...) holding
|
|
1394
|
+
* the selected text, plus the words themselves and the text around them.
|
|
1395
|
+
* Null when the selection holds no text.
|
|
1396
|
+
*/
|
|
1397
|
+
function captureSelection(range, options = {}) {
|
|
1398
|
+
const element = quoteContainer(range);
|
|
1399
|
+
if (!element) return null;
|
|
1400
|
+
const quote = captureQuote(range, element);
|
|
1401
|
+
if (!quote) return null;
|
|
1402
|
+
const first = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
|
1403
|
+
const anchor = captureAnchor(element, {
|
|
1404
|
+
x: first.left,
|
|
1405
|
+
y: first.top + first.height / 2
|
|
1406
|
+
}, options);
|
|
1407
|
+
anchor.quote = quote;
|
|
1408
|
+
return {
|
|
1409
|
+
element,
|
|
1410
|
+
anchor
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* The element an area comment belongs to: the smallest one that covers the
|
|
1415
|
+
* whole box (in viewport coordinates).
|
|
1416
|
+
*/
|
|
1417
|
+
function areaContainer(doc, box, isIgnored = () => false) {
|
|
1418
|
+
const right = box.left + box.width;
|
|
1419
|
+
const bottom = box.top + box.height;
|
|
1420
|
+
const corners = [
|
|
1421
|
+
[box.left + 1, box.top + 1],
|
|
1422
|
+
[right - 1, box.top + 1],
|
|
1423
|
+
[box.left + 1, bottom - 1],
|
|
1424
|
+
[right - 1, bottom - 1]
|
|
1425
|
+
];
|
|
1426
|
+
const ignored = (el) => isIgnored(el) || el.closest("[data-nuni]") !== null;
|
|
1427
|
+
let common = null;
|
|
1428
|
+
for (const [x, y] of corners) {
|
|
1429
|
+
const hit = doc.elementsFromPoint(x, y).find((el) => !ignored(el));
|
|
1430
|
+
if (hit) common = common ? commonAncestor(common, hit) : hit;
|
|
1431
|
+
}
|
|
1432
|
+
let el = common ?? doc.body;
|
|
1433
|
+
while (el.parentElement && el !== doc.body) {
|
|
1434
|
+
const r = el.getBoundingClientRect();
|
|
1435
|
+
if (r.left <= box.left + 1 && r.top <= box.top + 1 && r.right >= right - 1 && r.bottom >= bottom - 1) break;
|
|
1436
|
+
el = el.parentElement;
|
|
1437
|
+
}
|
|
1438
|
+
return el;
|
|
1439
|
+
}
|
|
1440
|
+
function commonAncestor(a, b) {
|
|
1441
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1442
|
+
for (let n = a; n; n = n.parentElement) seen.add(n);
|
|
1443
|
+
for (let n = b; n; n = n.parentElement) if (seen.has(n)) return n;
|
|
1444
|
+
return a.ownerDocument.body;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* An area comment: the covering element's anchor plus the box as fractions
|
|
1448
|
+
* of that element's size, so it scales with the layout.
|
|
1449
|
+
*/
|
|
1450
|
+
function captureArea(el, box, options = {}) {
|
|
1451
|
+
const anchor = captureAnchor(el, {
|
|
1452
|
+
x: box.left + box.width,
|
|
1453
|
+
y: box.top
|
|
1454
|
+
}, options);
|
|
1455
|
+
const r = el.getBoundingClientRect();
|
|
1456
|
+
const x = r.width ? clamp01((box.left - r.left) / r.width) : 0;
|
|
1457
|
+
const y = r.height ? clamp01((box.top - r.top) / r.height) : 0;
|
|
1458
|
+
anchor.region = {
|
|
1459
|
+
x,
|
|
1460
|
+
y,
|
|
1461
|
+
w: r.width ? Math.min(1 - x, box.width / r.width) : 1,
|
|
1462
|
+
h: r.height ? Math.min(1 - y, box.height / r.height) : 1
|
|
1463
|
+
};
|
|
538
1464
|
return anchor;
|
|
539
1465
|
}
|
|
540
1466
|
//#endregion
|
|
541
1467
|
//#region ../anchor/src/resolve.ts
|
|
1468
|
+
function createResolveCache() {
|
|
1469
|
+
return {
|
|
1470
|
+
byTag: /* @__PURE__ */ new Map(),
|
|
1471
|
+
elements: /* @__PURE__ */ new Map(),
|
|
1472
|
+
text: /* @__PURE__ */ new WeakMap(),
|
|
1473
|
+
content: /* @__PURE__ */ new WeakMap(),
|
|
1474
|
+
words: /* @__PURE__ */ new WeakMap()
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
function textOf(el, max, cache) {
|
|
1478
|
+
if (!cache) return elementText(el, max);
|
|
1479
|
+
let byMax = cache.text.get(el);
|
|
1480
|
+
if (!byMax) cache.text.set(el, byMax = /* @__PURE__ */ new Map());
|
|
1481
|
+
let text = byMax.get(max);
|
|
1482
|
+
if (text === void 0) byMax.set(max, text = elementText(el, max));
|
|
1483
|
+
return text;
|
|
1484
|
+
}
|
|
1485
|
+
function contentOf(el, cache) {
|
|
1486
|
+
if (!cache) return el.textContent ?? "";
|
|
1487
|
+
let content = cache.content.get(el);
|
|
1488
|
+
if (content === void 0) cache.content.set(el, content = el.textContent ?? "");
|
|
1489
|
+
return content;
|
|
1490
|
+
}
|
|
1491
|
+
/** All of an element's text with whitespace collapsed, for quote checks. */
|
|
1492
|
+
function wordsOf(el, cache) {
|
|
1493
|
+
let words = cache?.words.get(el);
|
|
1494
|
+
if (words === void 0) {
|
|
1495
|
+
words = normalizeText(contentOf(el, cache));
|
|
1496
|
+
cache?.words.set(el, words);
|
|
1497
|
+
}
|
|
1498
|
+
return words;
|
|
1499
|
+
}
|
|
1500
|
+
function elementsByTag(root, tag, cache) {
|
|
1501
|
+
const find = () => isShadowRoot(root) ? root.querySelectorAll(tag) : root.getElementsByTagName(tag);
|
|
1502
|
+
if (!cache) return find();
|
|
1503
|
+
let tags = cache.byTag.get(root);
|
|
1504
|
+
if (!tags) cache.byTag.set(root, tags = /* @__PURE__ */ new Map());
|
|
1505
|
+
let list = tags.get(tag);
|
|
1506
|
+
if (!list) tags.set(tag, list = Array.from(find()));
|
|
1507
|
+
return list;
|
|
1508
|
+
}
|
|
1509
|
+
/** Every element in the scope, in document order, up to `limit`. */
|
|
1510
|
+
function allElements(root, limit, cache) {
|
|
1511
|
+
const cached = cache?.elements.get(root);
|
|
1512
|
+
if (cached) return cached;
|
|
1513
|
+
const doc = scopeDocument(root);
|
|
1514
|
+
const start = isShadowRoot(root) ? root : doc.body ?? doc.documentElement;
|
|
1515
|
+
const walker = doc.createTreeWalker(start, 1);
|
|
1516
|
+
const out = [];
|
|
1517
|
+
for (let n = walker.nextNode(); n && out.length < limit; n = walker.nextNode()) out.push(n);
|
|
1518
|
+
cache?.elements.set(root, out);
|
|
1519
|
+
return out;
|
|
1520
|
+
}
|
|
542
1521
|
const THRESHOLD = {
|
|
543
1522
|
exact: .9,
|
|
544
1523
|
high: .72,
|
|
@@ -606,7 +1585,7 @@ const IDENTIFYING_ATTRS = /* @__PURE__ */ new Set([
|
|
|
606
1585
|
"alt",
|
|
607
1586
|
"title"
|
|
608
1587
|
]);
|
|
609
|
-
function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
1588
|
+
function scoreCandidate(anchor, el, doc, ctx, options, cache) {
|
|
610
1589
|
let total = 0;
|
|
611
1590
|
let weights = 0;
|
|
612
1591
|
const add = (weight, value) => {
|
|
@@ -653,9 +1632,11 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
653
1632
|
}
|
|
654
1633
|
if (anchor.role) add(.5, el.getAttribute("role") === anchor.role ? 1 : 0);
|
|
655
1634
|
if (anchor.componentName) add(.5, reactComponentName(el) === anchor.componentName ? 1 : 0);
|
|
656
|
-
const text =
|
|
1635
|
+
const text = textOf(el, 120, cache);
|
|
657
1636
|
const textSim = anchor.text ? textSimilarity(text, anchor.text) : text ? .3 : 1;
|
|
658
1637
|
add(anchor.text ? 3 : 1, textSim);
|
|
1638
|
+
const quoteHit = anchor.quote ? wordsOf(el, cache).includes(anchor.quote.exact) : null;
|
|
1639
|
+
if (quoteHit !== null) add(3, quoteHit ? 1 : 0);
|
|
659
1640
|
let ancTotal = 0;
|
|
660
1641
|
let ancWeights = 0;
|
|
661
1642
|
let context = null;
|
|
@@ -670,18 +1651,19 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
670
1651
|
else s += .35 * (nodeId ? .5 : 1);
|
|
671
1652
|
s += .25 * jaccard(stableClasses(node), expected.classes);
|
|
672
1653
|
ancTotal += weight * s;
|
|
673
|
-
if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(
|
|
1654
|
+
if (context === null && expected.text && expected.text !== anchor.text) context = textSimilarity(textOf(node, 80, cache), expected.text);
|
|
674
1655
|
node = node.parentElement;
|
|
675
1656
|
});
|
|
676
1657
|
if (ancWeights) add(1.5, ancTotal / ancWeights);
|
|
677
1658
|
if (context !== null) add(2, context);
|
|
678
|
-
const parent = el.
|
|
1659
|
+
const parent = el.parentNode;
|
|
679
1660
|
const index = parent ? Array.prototype.indexOf.call(parent.children, el) : 0;
|
|
680
1661
|
add(.5, Math.max(0, 1 - Math.abs(index - anchor.siblingIndex) / 3));
|
|
681
1662
|
const geoWeight = Math.max(0, 1 - Math.abs(ctx.viewportRatio - 1) * 2);
|
|
1663
|
+
const r = el.getBoundingClientRect();
|
|
1664
|
+
const rendered = r.width + r.height > 0;
|
|
682
1665
|
if (geoWeight > 0 && anchor.rect.w + anchor.rect.h > 0) {
|
|
683
|
-
|
|
684
|
-
if (r.width + r.height > 0) {
|
|
1666
|
+
if (rendered) {
|
|
685
1667
|
const view = doc.defaultView;
|
|
686
1668
|
const cx = r.left + (view?.scrollX ?? 0) + r.width / 2;
|
|
687
1669
|
const cy = r.top + (view?.scrollY ?? 0) + r.height / 2;
|
|
@@ -696,34 +1678,38 @@ function scoreCandidate(anchor, el, doc, ctx, options) {
|
|
|
696
1678
|
}
|
|
697
1679
|
let score = weights ? total / weights : 0;
|
|
698
1680
|
const structural = (cssHit || !anchor.selectors.css) && pathHit && identifyingAttrsMatch;
|
|
699
|
-
|
|
700
|
-
|
|
1681
|
+
const identity = ctx.quoteOnly === el ? Math.max(textSim, .9) : textSim;
|
|
1682
|
+
if (anchor.text.length >= 2 && identity < .999) {
|
|
1683
|
+
const factor = peerCount(el) > 0 ? .2 + .8 * identity * identity : .7 + .3 * identity;
|
|
701
1684
|
score *= strong || structural ? Math.max(factor, .92) : factor;
|
|
702
1685
|
}
|
|
1686
|
+
if (quoteHit === false) score *= .85;
|
|
703
1687
|
return {
|
|
704
1688
|
el,
|
|
705
1689
|
score,
|
|
706
1690
|
text: textSim,
|
|
707
1691
|
context,
|
|
708
|
-
strong: strong || structural
|
|
1692
|
+
strong: strong || structural,
|
|
1693
|
+
rendered
|
|
709
1694
|
};
|
|
710
1695
|
}
|
|
711
|
-
function collectCandidates(anchor,
|
|
1696
|
+
function collectCandidates(anchor, root, options, cache) {
|
|
1697
|
+
const doc = scopeDocument(root);
|
|
712
1698
|
const candidates = /* @__PURE__ */ new Set();
|
|
713
1699
|
const ignored = (el) => el === doc.documentElement || (options.isIgnored?.(el) ?? false);
|
|
714
1700
|
const add = (el) => {
|
|
715
1701
|
if (el && !ignored(el)) candidates.add(el);
|
|
716
1702
|
};
|
|
717
|
-
if (anchor.selectors.id) add(
|
|
1703
|
+
if (anchor.selectors.id) add(isShadowRoot(root) ? safeQueryAll(root, `#${cssEscape(anchor.selectors.id)}`, 1)[0] : root.getElementById(anchor.selectors.id));
|
|
718
1704
|
if (anchor.selectors.testId) {
|
|
719
1705
|
const parts = splitTestId(anchor.selectors.testId);
|
|
720
|
-
if (parts) safeQueryAll(
|
|
1706
|
+
if (parts) safeQueryAll(root, attrQuery(parts[0], parts[1])).forEach(add);
|
|
721
1707
|
}
|
|
722
|
-
safeQueryAll(
|
|
723
|
-
const pathMatch =
|
|
1708
|
+
safeQueryAll(root, anchor.selectors.css).forEach(add);
|
|
1709
|
+
const pathMatch = queryPath(root, anchor.selectors.path);
|
|
724
1710
|
add(pathMatch);
|
|
725
1711
|
const needle = anchor.text.slice(0, 24).toLowerCase();
|
|
726
|
-
const sameTag =
|
|
1712
|
+
const sameTag = elementsByTag(root, anchor.tag, cache);
|
|
727
1713
|
const scanAll = sameTag.length <= MAX_SCAN;
|
|
728
1714
|
for (let i = 0; i < sameTag.length && i < MAX_SCAN * 2; i++) {
|
|
729
1715
|
const el = sameTag[i];
|
|
@@ -731,20 +1717,16 @@ function collectCandidates(anchor, doc, options) {
|
|
|
731
1717
|
add(el);
|
|
732
1718
|
continue;
|
|
733
1719
|
}
|
|
734
|
-
const content = el.
|
|
1720
|
+
const content = contentOf(el, cache).toLowerCase();
|
|
735
1721
|
if (needle && content.includes(needle)) add(el);
|
|
736
1722
|
}
|
|
737
1723
|
if (anchor.text.length >= 3) {
|
|
738
1724
|
const exact = anchor.text.toLowerCase();
|
|
739
|
-
const
|
|
740
|
-
let count = 0;
|
|
741
|
-
for (let n = walker.nextNode(); n && count < MAX_SCAN * 3; n = walker.nextNode()) {
|
|
742
|
-
count++;
|
|
743
|
-
const el = n;
|
|
1725
|
+
for (const el of allElements(root, MAX_SCAN * 3, cache)) {
|
|
744
1726
|
if (el.tagName.toLowerCase() === anchor.tag) continue;
|
|
745
|
-
const content = el
|
|
1727
|
+
const content = contentOf(el, cache);
|
|
746
1728
|
if (!content || content.length > exact.length * 3 + 20) continue;
|
|
747
|
-
if (
|
|
1729
|
+
if (textOf(el, 120, cache).toLowerCase() === exact) add(el);
|
|
748
1730
|
}
|
|
749
1731
|
}
|
|
750
1732
|
return {
|
|
@@ -752,44 +1734,104 @@ function collectCandidates(anchor, doc, options) {
|
|
|
752
1734
|
pathMatch
|
|
753
1735
|
};
|
|
754
1736
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1737
|
+
const RANK = {
|
|
1738
|
+
lost: 0,
|
|
1739
|
+
low: 1,
|
|
1740
|
+
high: 2,
|
|
1741
|
+
exact: 3
|
|
1742
|
+
};
|
|
1743
|
+
/**
|
|
1744
|
+
* Find the element an anchor was captured on, in the current document.
|
|
1745
|
+
* Elements in shadow roots and iframes are found host by host first.
|
|
1746
|
+
* Pass one `cache` when resolving many anchors over the same DOM.
|
|
1747
|
+
*/
|
|
1748
|
+
function resolveAnchor(anchor, doc = document, options = {}, cache) {
|
|
1749
|
+
let root = doc;
|
|
1750
|
+
let floor = "exact";
|
|
1751
|
+
const lost = {
|
|
1752
|
+
element: null,
|
|
1753
|
+
confidence: "lost",
|
|
1754
|
+
score: 0
|
|
1755
|
+
};
|
|
1756
|
+
for (const step of anchor.scope ?? []) {
|
|
1757
|
+
const host = resolveIn(step.host, root, options, cache);
|
|
1758
|
+
if (!host.element) return lost;
|
|
1759
|
+
let confidence = host.confidence;
|
|
1760
|
+
if (step.host.text) {
|
|
1761
|
+
if (host.text < .9 && RANK[confidence] < RANK.high) return lost;
|
|
1762
|
+
if (host.text >= .999 && host.margin >= .1 && confidence === "low") confidence = "high";
|
|
1763
|
+
}
|
|
1764
|
+
const next = step.kind === "shadow" ? host.element.shadowRoot : frameDocument(host.element);
|
|
1765
|
+
if (!next) return lost;
|
|
1766
|
+
if (RANK[confidence] < RANK[floor]) floor = confidence;
|
|
1767
|
+
root = next;
|
|
1768
|
+
}
|
|
1769
|
+
const { text: _text, margin: _margin, ...result } = resolveIn(anchor, root, options, cache);
|
|
1770
|
+
if (result.element && RANK[floor] < RANK[result.confidence]) result.confidence = floor;
|
|
1771
|
+
if (result.element && anchor.quote) {
|
|
1772
|
+
const quote = findQuote(result.element, anchor.quote);
|
|
1773
|
+
if (quote) result.range = quote.range;
|
|
1774
|
+
if ((!quote || !quote.exact) && RANK[result.confidence] > RANK.low) result.confidence = "low";
|
|
1775
|
+
}
|
|
1776
|
+
return result;
|
|
1777
|
+
}
|
|
1778
|
+
function resolveIn(anchor, root, options, cache) {
|
|
1779
|
+
const doc = scopeDocument(root);
|
|
1780
|
+
if (!isShadowRoot(root) && (anchor.tag === "body" || anchor.tag === "html")) return {
|
|
758
1781
|
element: doc.body,
|
|
759
1782
|
confidence: "exact",
|
|
760
|
-
score: 1
|
|
1783
|
+
score: 1,
|
|
1784
|
+
text: 1,
|
|
1785
|
+
margin: 1
|
|
761
1786
|
};
|
|
762
|
-
const { candidates, pathMatch } = collectCandidates(anchor,
|
|
1787
|
+
const { candidates, pathMatch } = collectCandidates(anchor, root, options, cache);
|
|
763
1788
|
if (!candidates.size) return {
|
|
764
1789
|
element: null,
|
|
765
1790
|
confidence: "lost",
|
|
766
|
-
score: 0
|
|
1791
|
+
score: 0,
|
|
1792
|
+
text: 0,
|
|
1793
|
+
margin: 0
|
|
767
1794
|
};
|
|
768
1795
|
const view = doc.defaultView;
|
|
769
1796
|
const viewportRatio = anchor.viewport.w && view?.innerWidth ? view.innerWidth / anchor.viewport.w : 1;
|
|
1797
|
+
let quoteOnly = null;
|
|
1798
|
+
if (anchor.quote) {
|
|
1799
|
+
const exact = anchor.quote.exact;
|
|
1800
|
+
const hits = [...candidates].filter((el) => wordsOf(el, cache).includes(exact));
|
|
1801
|
+
const innermost = hits.filter((el) => !hits.some((other) => other !== el && el.contains(other)));
|
|
1802
|
+
if (innermost.length === 1) quoteOnly = innermost[0];
|
|
1803
|
+
}
|
|
770
1804
|
const scored = [];
|
|
771
1805
|
for (const el of candidates) scored.push(scoreCandidate(anchor, el, doc, {
|
|
772
1806
|
pathMatch,
|
|
773
|
-
viewportRatio
|
|
774
|
-
|
|
775
|
-
|
|
1807
|
+
viewportRatio,
|
|
1808
|
+
quoteOnly
|
|
1809
|
+
}, options, cache));
|
|
1810
|
+
const visibleOnly = anchor.rect.w + anchor.rect.h > 0 && scored.some((s) => s.rendered);
|
|
1811
|
+
const rival = (s) => !visibleOnly || s.rendered;
|
|
1812
|
+
if (scored.filter((s) => s.text >= .999 && s.el.tagName.toLowerCase() === anchor.tag && rival(s)).length > 1) for (const s of scored) {
|
|
776
1813
|
if (s.context === null || s.strong) continue;
|
|
777
1814
|
s.score *= .2 + .8 * s.context * s.context;
|
|
778
1815
|
}
|
|
779
1816
|
scored.sort((a, b) => b.score - a.score);
|
|
780
1817
|
const best = scored[0];
|
|
781
|
-
const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el));
|
|
1818
|
+
const second = scored.find((s) => s.el !== best.el && !best.el.contains(s.el) && !s.el.contains(best.el) && (!best.rendered || rival(s)));
|
|
782
1819
|
if (best.score < THRESHOLD.low) return {
|
|
783
1820
|
element: null,
|
|
784
1821
|
confidence: "lost",
|
|
785
|
-
score: best.score
|
|
1822
|
+
score: best.score,
|
|
1823
|
+
text: best.text,
|
|
1824
|
+
margin: 0
|
|
786
1825
|
};
|
|
787
1826
|
let confidence = best.score >= THRESHOLD.exact && best.text >= .999 ? "exact" : best.score >= THRESHOLD.high ? "high" : "low";
|
|
788
|
-
|
|
1827
|
+
const margin = second ? best.score - second.score : 1;
|
|
1828
|
+
if (margin < AMBIGUITY_MARGIN) confidence = "low";
|
|
789
1829
|
return {
|
|
790
1830
|
element: best.el,
|
|
791
1831
|
confidence,
|
|
792
|
-
score: best.score
|
|
1832
|
+
score: best.score,
|
|
1833
|
+
text: best.text,
|
|
1834
|
+
margin
|
|
793
1835
|
};
|
|
794
1836
|
}
|
|
795
1837
|
//#endregion
|
|
@@ -4188,44 +5230,6 @@ var ConvexClient = class {
|
|
|
4188
5230
|
}
|
|
4189
5231
|
};
|
|
4190
5232
|
//#endregion
|
|
4191
|
-
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
|
|
4192
|
-
function _typeof(o) {
|
|
4193
|
-
"@babel/helpers - typeof";
|
|
4194
|
-
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
4195
|
-
return typeof o;
|
|
4196
|
-
} : function(o) {
|
|
4197
|
-
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
4198
|
-
}, _typeof(o);
|
|
4199
|
-
}
|
|
4200
|
-
//#endregion
|
|
4201
|
-
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
|
|
4202
|
-
function toPrimitive(t, r) {
|
|
4203
|
-
if ("object" != _typeof(t) || !t) return t;
|
|
4204
|
-
var e = t[Symbol.toPrimitive];
|
|
4205
|
-
if (void 0 !== e) {
|
|
4206
|
-
var i = e.call(t, r || "default");
|
|
4207
|
-
if ("object" != _typeof(i)) return i;
|
|
4208
|
-
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
4209
|
-
}
|
|
4210
|
-
return ("string" === r ? String : Number)(t);
|
|
4211
|
-
}
|
|
4212
|
-
//#endregion
|
|
4213
|
-
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
|
|
4214
|
-
function toPropertyKey(t) {
|
|
4215
|
-
var i = toPrimitive(t, "string");
|
|
4216
|
-
return "symbol" == _typeof(i) ? i : i + "";
|
|
4217
|
-
}
|
|
4218
|
-
//#endregion
|
|
4219
|
-
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
|
|
4220
|
-
function _defineProperty(e, r, t) {
|
|
4221
|
-
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
4222
|
-
value: t,
|
|
4223
|
-
enumerable: !0,
|
|
4224
|
-
configurable: !0,
|
|
4225
|
-
writable: !0
|
|
4226
|
-
}) : e[r] = t, e;
|
|
4227
|
-
}
|
|
4228
|
-
//#endregion
|
|
4229
5233
|
//#region src/api.ts
|
|
4230
5234
|
var NuniApiError = class extends Error {
|
|
4231
5235
|
constructor(message, code) {
|
|
@@ -4275,6 +5279,21 @@ var NuniApi = class {
|
|
|
4275
5279
|
sessionToken
|
|
4276
5280
|
}, cb, () => {});
|
|
4277
5281
|
}
|
|
5282
|
+
/** A comment with the owner-only context (screenshot, console, DOM). */
|
|
5283
|
+
onOwnerComment(id, sessionToken, cb) {
|
|
5284
|
+
return this.client.onUpdate(api.comments.getForOwner, {
|
|
5285
|
+
publicId: this.config.project,
|
|
5286
|
+
id,
|
|
5287
|
+
sessionToken
|
|
5288
|
+
}, (comment) => cb(comment), () => {});
|
|
5289
|
+
}
|
|
5290
|
+
/** Replies and reactions of one thread, live while its card is open. */
|
|
5291
|
+
onThread(commentId, cb) {
|
|
5292
|
+
return this.client.onUpdate(api.replies.listForComment, {
|
|
5293
|
+
publicId: this.config.project,
|
|
5294
|
+
commentId
|
|
5295
|
+
}, (thread) => cb(thread), () => {});
|
|
5296
|
+
}
|
|
4278
5297
|
/** One comment by id, for deep links outside the page listing window. */
|
|
4279
5298
|
getComment(id) {
|
|
4280
5299
|
return this.client.query(api.comments.getById, {
|
|
@@ -4300,6 +5319,42 @@ var NuniApi = class {
|
|
|
4300
5319
|
if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
|
|
4301
5320
|
return data.id;
|
|
4302
5321
|
}
|
|
5322
|
+
/** Through the HTTP action, so replies are rate limited per IP. */
|
|
5323
|
+
async createReply(reply) {
|
|
5324
|
+
let response;
|
|
5325
|
+
try {
|
|
5326
|
+
response = await fetch(`${this.config.convexSiteUrl}/widget/replies`, {
|
|
5327
|
+
method: "POST",
|
|
5328
|
+
headers: { "Content-Type": "application/json" },
|
|
5329
|
+
body: JSON.stringify({
|
|
5330
|
+
publicId: this.config.project,
|
|
5331
|
+
...reply
|
|
5332
|
+
})
|
|
5333
|
+
});
|
|
5334
|
+
} catch {
|
|
5335
|
+
throw new NuniApiError("Couldn't reach Nuni. Check your connection.", "network");
|
|
5336
|
+
}
|
|
5337
|
+
const data = await response.json().catch(() => ({}));
|
|
5338
|
+
if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the reply", data.code ?? "error");
|
|
5339
|
+
return data.id;
|
|
5340
|
+
}
|
|
5341
|
+
/** Attach a screenshot to the author's own, just-posted comment. */
|
|
5342
|
+
async uploadScreenshot(id, authorSecret, image) {
|
|
5343
|
+
const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
|
|
5344
|
+
method: "POST",
|
|
5345
|
+
headers: {
|
|
5346
|
+
"Content-Type": image.type,
|
|
5347
|
+
"X-Nuni-Project": this.config.project,
|
|
5348
|
+
"X-Nuni-Comment": id,
|
|
5349
|
+
"X-Nuni-Author": authorSecret
|
|
5350
|
+
},
|
|
5351
|
+
body: image
|
|
5352
|
+
});
|
|
5353
|
+
if (!response.ok) {
|
|
5354
|
+
const data = await response.json().catch(() => ({}));
|
|
5355
|
+
throw new NuniApiError(data.message ?? "Couldn't attach the screenshot", data.code ?? "error");
|
|
5356
|
+
}
|
|
5357
|
+
}
|
|
4303
5358
|
async run(promise) {
|
|
4304
5359
|
try {
|
|
4305
5360
|
return await promise;
|
|
@@ -4332,6 +5387,34 @@ var NuniApi = class {
|
|
|
4332
5387
|
sessionToken
|
|
4333
5388
|
}));
|
|
4334
5389
|
}
|
|
5390
|
+
editReply(id, authorSecret, body) {
|
|
5391
|
+
return this.run(this.client.mutation(api.replies.editOwn, {
|
|
5392
|
+
id,
|
|
5393
|
+
authorSecret,
|
|
5394
|
+
body
|
|
5395
|
+
}));
|
|
5396
|
+
}
|
|
5397
|
+
deleteReply(id, authorSecret) {
|
|
5398
|
+
return this.run(this.client.mutation(api.replies.deleteOwn, {
|
|
5399
|
+
id,
|
|
5400
|
+
authorSecret
|
|
5401
|
+
}));
|
|
5402
|
+
}
|
|
5403
|
+
removeReply(id, sessionToken) {
|
|
5404
|
+
return this.run(this.client.mutation(api.replies.remove, {
|
|
5405
|
+
id,
|
|
5406
|
+
sessionToken
|
|
5407
|
+
}));
|
|
5408
|
+
}
|
|
5409
|
+
toggleReaction(commentId, targetId, emoji, authorSecret) {
|
|
5410
|
+
return this.run(this.client.mutation(api.reactions.toggle, {
|
|
5411
|
+
publicId: this.config.project,
|
|
5412
|
+
commentId,
|
|
5413
|
+
targetId,
|
|
5414
|
+
emoji,
|
|
5415
|
+
authorSecret
|
|
5416
|
+
}));
|
|
5417
|
+
}
|
|
4335
5418
|
remove(id, sessionToken) {
|
|
4336
5419
|
return this.run(this.client.mutation(api.comments.remove, {
|
|
4337
5420
|
id,
|
|
@@ -4366,17 +5449,6 @@ function icon(svg) {
|
|
|
4366
5449
|
span.append(document.importNode(parsed.documentElement, true));
|
|
4367
5450
|
return span;
|
|
4368
5451
|
}
|
|
4369
|
-
function timeAgo(ts, now = Date.now()) {
|
|
4370
|
-
const s = Math.max(0, Math.round((now - ts) / 1e3));
|
|
4371
|
-
if (s < 45) return "just now";
|
|
4372
|
-
const m = Math.round(s / 60);
|
|
4373
|
-
if (m < 60) return `${m}m ago`;
|
|
4374
|
-
const hr = Math.round(m / 60);
|
|
4375
|
-
if (hr < 24) return `${hr}h ago`;
|
|
4376
|
-
const d = Math.round(hr / 24);
|
|
4377
|
-
if (d < 30) return `${d}d ago`;
|
|
4378
|
-
return new Date(ts).toLocaleDateString();
|
|
4379
|
-
}
|
|
4380
5452
|
function initials(name) {
|
|
4381
5453
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
4382
5454
|
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
|
|
@@ -4385,7 +5457,221 @@ function initials(name) {
|
|
|
4385
5457
|
function colorFor(name) {
|
|
4386
5458
|
let hash = 0;
|
|
4387
5459
|
for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
|
|
4388
|
-
return `oklch(0.
|
|
5460
|
+
return `oklch(0.5 0.17 ${hash % 360})`;
|
|
5461
|
+
}
|
|
5462
|
+
/** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
|
|
5463
|
+
async function copyText(text) {
|
|
5464
|
+
try {
|
|
5465
|
+
await navigator.clipboard.writeText(text);
|
|
5466
|
+
return true;
|
|
5467
|
+
} catch {}
|
|
5468
|
+
const area = document.createElement("textarea");
|
|
5469
|
+
area.value = text;
|
|
5470
|
+
area.setAttribute("readonly", "");
|
|
5471
|
+
area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
5472
|
+
document.body.appendChild(area);
|
|
5473
|
+
area.select();
|
|
5474
|
+
try {
|
|
5475
|
+
return document.execCommand("copy");
|
|
5476
|
+
} catch {
|
|
5477
|
+
return false;
|
|
5478
|
+
} finally {
|
|
5479
|
+
area.remove();
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
/** Any CSS color as RGB, using the browser's own parser. */
|
|
5483
|
+
function parseColor(color) {
|
|
5484
|
+
const probe = document.createElement("span");
|
|
5485
|
+
probe.style.color = color;
|
|
5486
|
+
if (!probe.style.color) return null;
|
|
5487
|
+
probe.style.display = "none";
|
|
5488
|
+
document.documentElement.appendChild(probe);
|
|
5489
|
+
const computed = getComputedStyle(probe).color;
|
|
5490
|
+
probe.remove();
|
|
5491
|
+
if (/^rgba?\(/.test(computed)) {
|
|
5492
|
+
const parts = computed.match(/[\d.]+/g)?.map(Number);
|
|
5493
|
+
if (parts && parts.length >= 3) return [
|
|
5494
|
+
parts[0],
|
|
5495
|
+
parts[1],
|
|
5496
|
+
parts[2]
|
|
5497
|
+
];
|
|
5498
|
+
}
|
|
5499
|
+
try {
|
|
5500
|
+
const canvas = document.createElement("canvas");
|
|
5501
|
+
canvas.width = canvas.height = 1;
|
|
5502
|
+
const ctx = canvas.getContext("2d");
|
|
5503
|
+
if (!ctx) return null;
|
|
5504
|
+
ctx.fillStyle = color;
|
|
5505
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
5506
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
5507
|
+
return [
|
|
5508
|
+
r,
|
|
5509
|
+
g,
|
|
5510
|
+
b
|
|
5511
|
+
];
|
|
5512
|
+
} catch {
|
|
5513
|
+
return null;
|
|
5514
|
+
}
|
|
5515
|
+
}
|
|
5516
|
+
/** Black or white text, whichever reads better on the color (WCAG). */
|
|
5517
|
+
function readableOn([r, g, b]) {
|
|
5518
|
+
const channel = (c) => {
|
|
5519
|
+
const v = c / 255;
|
|
5520
|
+
return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
5521
|
+
};
|
|
5522
|
+
const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
|
|
5523
|
+
return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
|
|
5524
|
+
}
|
|
5525
|
+
//#endregion
|
|
5526
|
+
//#region src/i18n.ts
|
|
5527
|
+
/**
|
|
5528
|
+
* Every word the widget shows. Override any of them with the `messages`
|
|
5529
|
+
* option; `locale` sets how times and counts are written.
|
|
5530
|
+
*
|
|
5531
|
+
* `{name}` is filled in. A message with forms (`one`, `other`, and `zero`,
|
|
5532
|
+
* `two`, `few`, `many` where a language has them) is picked by `{count}`.
|
|
5533
|
+
*/
|
|
5534
|
+
const EN = {
|
|
5535
|
+
toolbar: "Nuni comments",
|
|
5536
|
+
comment: "Comment",
|
|
5537
|
+
addComment: "Add a comment",
|
|
5538
|
+
addCommentKey: "Add a comment ({key})",
|
|
5539
|
+
cancelAdding: "Cancel adding a comment",
|
|
5540
|
+
pickElement: "Pick an element",
|
|
5541
|
+
allComments: "All comments",
|
|
5542
|
+
openCount: "Comments on this page: {count} open",
|
|
5543
|
+
commentSelection: "Comment on the selected text",
|
|
5544
|
+
commentSelectionKey: "Comment on the selected text ({key})",
|
|
5545
|
+
pickHint: "Click anything to comment, or drag to mark an area",
|
|
5546
|
+
pickHintMobile: "Tap anything to comment",
|
|
5547
|
+
pickHintKeys: "Arrow keys move, Enter comments, Esc cancels",
|
|
5548
|
+
picked: "Selected {element}",
|
|
5549
|
+
comments: "Comments",
|
|
5550
|
+
close: "Close",
|
|
5551
|
+
tabOpen: "Open ({count})",
|
|
5552
|
+
tabResolved: "Resolved ({count})",
|
|
5553
|
+
loading: "Loading…",
|
|
5554
|
+
noOpen: "No open comments on this page.",
|
|
5555
|
+
noOpenKey: "No open comments on this page. Press {key} to add one.",
|
|
5556
|
+
noResolved: "Nothing resolved yet.",
|
|
5557
|
+
notFoundHere: "Couldn't find on this page",
|
|
5558
|
+
otherPages: "Other pages",
|
|
5559
|
+
openOnPage: "{count} open",
|
|
5560
|
+
replyCount: {
|
|
5561
|
+
one: "{count} reply",
|
|
5562
|
+
other: "{count} replies"
|
|
5563
|
+
},
|
|
5564
|
+
edited: "edited",
|
|
5565
|
+
ownSite: "Own this website?",
|
|
5566
|
+
claim: "Claim Nuni",
|
|
5567
|
+
yourName: "Your name",
|
|
5568
|
+
leaveComment: "Leave a comment",
|
|
5569
|
+
notYou: "Not you?",
|
|
5570
|
+
screenshotAlt: "Screenshot that will be attached",
|
|
5571
|
+
screenshotNote: "Screenshot attached. Only the site owner sees it.",
|
|
5572
|
+
remove: "Remove",
|
|
5573
|
+
cancel: "Cancel",
|
|
5574
|
+
post: "Post",
|
|
5575
|
+
posting: "Posting…",
|
|
5576
|
+
save: "Save",
|
|
5577
|
+
send: "{key} + Enter",
|
|
5578
|
+
pinLabel: "Comment by {name}: {body}",
|
|
5579
|
+
commentBy: "Comment by {name}",
|
|
5580
|
+
openScreenshot: "Open the screenshot",
|
|
5581
|
+
screenshotTaken: "Screenshot taken when the comment was left",
|
|
5582
|
+
resolved: "Resolved",
|
|
5583
|
+
leftOn: "Left on {origin}",
|
|
5584
|
+
approximate: "Approximate",
|
|
5585
|
+
approximateHint: "The page changed; this pin is a best guess",
|
|
5586
|
+
notFound: "Element not found",
|
|
5587
|
+
originallyOn: "Originally on: {text}",
|
|
5588
|
+
resolve: "Resolve",
|
|
5589
|
+
reopen: "Reopen",
|
|
5590
|
+
copyForAgent: "Copy for agent",
|
|
5591
|
+
copyForAgentHint: "Copy for your coding agent",
|
|
5592
|
+
loadingContext: "Loading page context…",
|
|
5593
|
+
edit: "Edit",
|
|
5594
|
+
delete: "Delete",
|
|
5595
|
+
owner: "Owner",
|
|
5596
|
+
addReaction: "Add a reaction",
|
|
5597
|
+
reactions: "Reactions",
|
|
5598
|
+
reactWith: "React with {emoji}",
|
|
5599
|
+
reply: "Reply",
|
|
5600
|
+
replyFirst: "Reply to start a thread",
|
|
5601
|
+
sendReply: "Send reply",
|
|
5602
|
+
editReply: "Edit reply",
|
|
5603
|
+
deleteReply: "Delete reply",
|
|
5604
|
+
confirmDeleteComment: "Delete this comment? This can't be undone.",
|
|
5605
|
+
confirmDeleteReply: "Delete this reply?",
|
|
5606
|
+
added: "Comment added",
|
|
5607
|
+
saved: "Saved",
|
|
5608
|
+
deleted: "Deleted",
|
|
5609
|
+
replyDeleted: "Reply deleted",
|
|
5610
|
+
reopened: "Reopened",
|
|
5611
|
+
copied: "Copied for your coding agent",
|
|
5612
|
+
copyFailed: "Couldn't copy",
|
|
5613
|
+
signedIn: "You're signed in as the owner",
|
|
5614
|
+
linkGone: "That comment was deleted or is on another page",
|
|
5615
|
+
enterName: "Enter your name",
|
|
5616
|
+
writeFirst: "Write a comment first",
|
|
5617
|
+
commentTooLong: "Comments are limited to {count} characters",
|
|
5618
|
+
replyTooLong: "Replies are limited to {count} characters",
|
|
5619
|
+
postFailed: "Couldn't post the comment",
|
|
5620
|
+
replyFailed: "Couldn't post the reply",
|
|
5621
|
+
reactFailed: "Couldn't react",
|
|
5622
|
+
failed: "Something went wrong",
|
|
5623
|
+
justNow: "just now"
|
|
5624
|
+
};
|
|
5625
|
+
function isApple() {
|
|
5626
|
+
if (typeof navigator === "undefined") return false;
|
|
5627
|
+
const platform = navigator.userAgentData?.platform ?? navigator.platform ?? "";
|
|
5628
|
+
return /mac|iphone|ipad|ipod/i.test(platform || navigator.userAgent);
|
|
5629
|
+
}
|
|
5630
|
+
function createI18n(locale, overrides = {}) {
|
|
5631
|
+
const messages = {
|
|
5632
|
+
...EN,
|
|
5633
|
+
...overrides
|
|
5634
|
+
};
|
|
5635
|
+
let plural = null;
|
|
5636
|
+
let relative = null;
|
|
5637
|
+
try {
|
|
5638
|
+
plural = new Intl.PluralRules(locale);
|
|
5639
|
+
relative = new Intl.RelativeTimeFormat(locale, {
|
|
5640
|
+
numeric: "auto",
|
|
5641
|
+
style: "narrow"
|
|
5642
|
+
});
|
|
5643
|
+
} catch {
|
|
5644
|
+
plural = new Intl.PluralRules();
|
|
5645
|
+
relative = new Intl.RelativeTimeFormat(void 0, {
|
|
5646
|
+
numeric: "auto",
|
|
5647
|
+
style: "narrow"
|
|
5648
|
+
});
|
|
5649
|
+
}
|
|
5650
|
+
const t = (key, vars = {}) => {
|
|
5651
|
+
const message = messages[key] ?? EN[key];
|
|
5652
|
+
return (typeof message === "string" ? message : message[plural.select(Number(vars.count ?? 0))] ?? message.other).replace(/\{(\w+)\}/g, (match, name) => name in vars ? String(vars[name]) : match);
|
|
5653
|
+
};
|
|
5654
|
+
const timeAgo = (ts, now = Date.now()) => {
|
|
5655
|
+
const s = Math.max(0, Math.round((now - ts) / 1e3));
|
|
5656
|
+
if (s < 45) return t("justNow");
|
|
5657
|
+
const m = Math.round(s / 60);
|
|
5658
|
+
if (m < 60) return relative.format(-m, "minute");
|
|
5659
|
+
const hr = Math.round(m / 60);
|
|
5660
|
+
if (hr < 24) return relative.format(-hr, "hour");
|
|
5661
|
+
const d = Math.round(hr / 24);
|
|
5662
|
+
if (d < 30) return relative.format(-d, "day");
|
|
5663
|
+
try {
|
|
5664
|
+
return new Date(ts).toLocaleDateString(locale);
|
|
5665
|
+
} catch {
|
|
5666
|
+
return new Date(ts).toLocaleDateString();
|
|
5667
|
+
}
|
|
5668
|
+
};
|
|
5669
|
+
return {
|
|
5670
|
+
locale,
|
|
5671
|
+
t,
|
|
5672
|
+
timeAgo,
|
|
5673
|
+
mod: isApple() ? "⌘" : "Ctrl"
|
|
5674
|
+
};
|
|
4389
5675
|
}
|
|
4390
5676
|
//#endregion
|
|
4391
5677
|
//#region src/icons.ts
|
|
@@ -4399,6 +5685,8 @@ const ICONS = {
|
|
|
4399
5685
|
trash: s("<path d=\"M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6\"/>"),
|
|
4400
5686
|
edit: s("<path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z\"/>"),
|
|
4401
5687
|
send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
|
|
5688
|
+
bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
|
|
5689
|
+
smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
|
|
4402
5690
|
target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
|
|
4403
5691
|
github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
|
|
4404
5692
|
link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
|
|
@@ -4635,7 +5923,7 @@ const STYLES = `
|
|
|
4635
5923
|
-moz-osx-font-smoothing: grayscale;
|
|
4636
5924
|
}
|
|
4637
5925
|
@media (prefers-color-scheme: dark) {
|
|
4638
|
-
:host {
|
|
5926
|
+
:host(:not([data-theme="light"])) {
|
|
4639
5927
|
--n-bg: #1d1b20;
|
|
4640
5928
|
--n-bg-subtle: #2a272e;
|
|
4641
5929
|
--n-fg: #f4f2f6;
|
|
@@ -4646,6 +5934,18 @@ const STYLES = `
|
|
|
4646
5934
|
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
4647
5935
|
}
|
|
4648
5936
|
}
|
|
5937
|
+
:host([data-theme="dark"]) {
|
|
5938
|
+
--n-bg: #1d1b20;
|
|
5939
|
+
--n-bg-subtle: #2a272e;
|
|
5940
|
+
--n-fg: #f4f2f6;
|
|
5941
|
+
--n-fg-muted: #a8a2ae;
|
|
5942
|
+
--n-border: rgba(255, 255, 255, 0.12);
|
|
5943
|
+
--n-accent: #ff4d94;
|
|
5944
|
+
--n-accent-soft: rgba(255, 77, 148, 0.18);
|
|
5945
|
+
--n-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
|
|
5946
|
+
}
|
|
5947
|
+
:host([data-theme="light"]) { color-scheme: light; }
|
|
5948
|
+
:host([data-theme="dark"]) { color-scheme: dark; }
|
|
4649
5949
|
*, *::before, *::after { box-sizing: border-box; }
|
|
4650
5950
|
button, input, textarea { font: inherit; color: inherit; }
|
|
4651
5951
|
button { cursor: pointer; }
|
|
@@ -4699,6 +5999,10 @@ button { cursor: pointer; }
|
|
|
4699
5999
|
|
|
4700
6000
|
/* Picking */
|
|
4701
6001
|
.pick-hint {
|
|
6002
|
+
display: flex;
|
|
6003
|
+
gap: 8px;
|
|
6004
|
+
align-items: center;
|
|
6005
|
+
max-width: calc(100vw - 24px);
|
|
4702
6006
|
position: fixed;
|
|
4703
6007
|
top: 12px;
|
|
4704
6008
|
left: 50%;
|
|
@@ -4735,6 +6039,94 @@ button { cursor: pointer; }
|
|
|
4735
6039
|
text-overflow: ellipsis;
|
|
4736
6040
|
}
|
|
4737
6041
|
|
|
6042
|
+
.drag-box { transition: none; border-style: dashed; }
|
|
6043
|
+
|
|
6044
|
+
/* Text and area comments */
|
|
6045
|
+
.marks { position: fixed; inset: 0; pointer-events: none; }
|
|
6046
|
+
.mark {
|
|
6047
|
+
position: fixed;
|
|
6048
|
+
border-bottom: 2px solid var(--n-accent);
|
|
6049
|
+
background: var(--n-accent-soft);
|
|
6050
|
+
opacity: 0.7;
|
|
6051
|
+
pointer-events: none;
|
|
6052
|
+
}
|
|
6053
|
+
.mark-active { opacity: 1; background: color-mix(in srgb, var(--n-accent) 26%, transparent); }
|
|
6054
|
+
.area {
|
|
6055
|
+
position: fixed;
|
|
6056
|
+
border: 2px dashed var(--n-accent);
|
|
6057
|
+
border-radius: 4px;
|
|
6058
|
+
background: var(--n-accent-soft);
|
|
6059
|
+
pointer-events: none;
|
|
6060
|
+
}
|
|
6061
|
+
.select-btn {
|
|
6062
|
+
position: fixed;
|
|
6063
|
+
left: 0;
|
|
6064
|
+
top: 0;
|
|
6065
|
+
display: inline-flex;
|
|
6066
|
+
align-items: center;
|
|
6067
|
+
gap: 6px;
|
|
6068
|
+
height: 32px;
|
|
6069
|
+
padding: 0 12px;
|
|
6070
|
+
border: 0;
|
|
6071
|
+
border-radius: 999px;
|
|
6072
|
+
background: var(--n-fg);
|
|
6073
|
+
color: var(--n-bg);
|
|
6074
|
+
font-weight: 550;
|
|
6075
|
+
font-size: 13px;
|
|
6076
|
+
box-shadow: var(--n-shadow);
|
|
6077
|
+
pointer-events: auto;
|
|
6078
|
+
}
|
|
6079
|
+
.select-btn[hidden] { display: none; }
|
|
6080
|
+
.quote {
|
|
6081
|
+
margin: 0;
|
|
6082
|
+
padding: 2px 0 2px 10px;
|
|
6083
|
+
border-left: 3px solid var(--n-accent);
|
|
6084
|
+
color: var(--n-fg-muted);
|
|
6085
|
+
font-style: italic;
|
|
6086
|
+
white-space: pre-wrap;
|
|
6087
|
+
overflow-wrap: anywhere;
|
|
6088
|
+
max-height: 4.5em;
|
|
6089
|
+
overflow: hidden;
|
|
6090
|
+
}
|
|
6091
|
+
.item-quote {
|
|
6092
|
+
margin-top: 4px;
|
|
6093
|
+
padding-left: 8px;
|
|
6094
|
+
border-left: 2px solid var(--n-accent);
|
|
6095
|
+
color: var(--n-fg-muted);
|
|
6096
|
+
font-size: 12px;
|
|
6097
|
+
font-style: italic;
|
|
6098
|
+
white-space: nowrap;
|
|
6099
|
+
overflow: hidden;
|
|
6100
|
+
text-overflow: ellipsis;
|
|
6101
|
+
}
|
|
6102
|
+
|
|
6103
|
+
.pick-keys { opacity: 0.7; }
|
|
6104
|
+
.pick-keys::before { content: "·"; margin-right: 8px; }
|
|
6105
|
+
@media (max-width: 900px) { .pick-keys { display: none; } }
|
|
6106
|
+
|
|
6107
|
+
/* Confirm dialog */
|
|
6108
|
+
.confirm-backdrop {
|
|
6109
|
+
position: fixed;
|
|
6110
|
+
inset: 0;
|
|
6111
|
+
display: grid;
|
|
6112
|
+
place-items: center;
|
|
6113
|
+
background: rgba(0, 0, 0, 0.32);
|
|
6114
|
+
pointer-events: auto;
|
|
6115
|
+
}
|
|
6116
|
+
.confirm {
|
|
6117
|
+
width: min(360px, calc(100vw - 32px));
|
|
6118
|
+
padding: 16px;
|
|
6119
|
+
border-radius: var(--n-radius);
|
|
6120
|
+
background: var(--n-bg);
|
|
6121
|
+
color: var(--n-fg);
|
|
6122
|
+
border: 1px solid var(--n-border);
|
|
6123
|
+
box-shadow: var(--n-shadow);
|
|
6124
|
+
display: grid;
|
|
6125
|
+
gap: 12px;
|
|
6126
|
+
}
|
|
6127
|
+
.confirm p { margin: 0; }
|
|
6128
|
+
.btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
|
|
6129
|
+
|
|
4738
6130
|
/* Pins */
|
|
4739
6131
|
.pin {
|
|
4740
6132
|
position: fixed;
|
|
@@ -4760,6 +6152,22 @@ button { cursor: pointer; }
|
|
|
4760
6152
|
.pin:hover, .pin[data-active="true"] { z-index: 2; }
|
|
4761
6153
|
.pin[data-active="true"] { box-shadow: 0 0 0 3px var(--n-accent), 0 2px 8px rgba(0,0,0,0.25); }
|
|
4762
6154
|
.pin[data-status="resolved"] { opacity: 0.55; filter: grayscale(0.6); }
|
|
6155
|
+
.pin[data-replies]::after {
|
|
6156
|
+
content: attr(data-replies);
|
|
6157
|
+
position: absolute;
|
|
6158
|
+
top: -6px;
|
|
6159
|
+
right: -8px;
|
|
6160
|
+
min-width: 16px;
|
|
6161
|
+
height: 16px;
|
|
6162
|
+
padding: 0 4px;
|
|
6163
|
+
border-radius: 999px;
|
|
6164
|
+
background: var(--n-bg);
|
|
6165
|
+
color: var(--n-fg);
|
|
6166
|
+
border: 1px solid var(--n-border);
|
|
6167
|
+
font-size: 10px;
|
|
6168
|
+
line-height: 14px;
|
|
6169
|
+
text-align: center;
|
|
6170
|
+
}
|
|
4763
6171
|
.pin[data-confidence="low"] { border-style: dashed; }
|
|
4764
6172
|
.pin[hidden] { display: none; }
|
|
4765
6173
|
.pin-pending { opacity: 0.7; }
|
|
@@ -4778,6 +6186,43 @@ button { cursor: pointer; }
|
|
|
4778
6186
|
overflow: hidden;
|
|
4779
6187
|
}
|
|
4780
6188
|
.card-body { padding: 12px; display: grid; gap: 10px; }
|
|
6189
|
+
.card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
|
|
6190
|
+
.thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
|
|
6191
|
+
.replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
|
|
6192
|
+
.reply { display: grid; gap: 6px; }
|
|
6193
|
+
.avatar-sm { width: 20px; height: 20px; font-size: 9px; }
|
|
6194
|
+
.badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
|
|
6195
|
+
.reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
|
|
6196
|
+
.reply-form .field { flex: 1 1 100%; }
|
|
6197
|
+
/* A fixed size: growing on focus would move the Send button mid-click. */
|
|
6198
|
+
.reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
|
|
6199
|
+
.reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
|
|
6200
|
+
.reaction {
|
|
6201
|
+
display: inline-flex;
|
|
6202
|
+
align-items: center;
|
|
6203
|
+
gap: 4px;
|
|
6204
|
+
height: 24px;
|
|
6205
|
+
padding: 0 8px;
|
|
6206
|
+
border: 1px solid var(--n-border);
|
|
6207
|
+
border-radius: 999px;
|
|
6208
|
+
background: var(--n-bg);
|
|
6209
|
+
color: var(--n-fg);
|
|
6210
|
+
font-size: 12px;
|
|
6211
|
+
}
|
|
6212
|
+
.reaction:hover { background: var(--n-bg-subtle); }
|
|
6213
|
+
.reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
|
|
6214
|
+
.reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
|
|
6215
|
+
.reaction-picker {
|
|
6216
|
+
display: flex;
|
|
6217
|
+
gap: 2px;
|
|
6218
|
+
padding: 4px;
|
|
6219
|
+
border: 1px solid var(--n-border);
|
|
6220
|
+
border-radius: 999px;
|
|
6221
|
+
background: var(--n-bg);
|
|
6222
|
+
box-shadow: var(--n-shadow);
|
|
6223
|
+
}
|
|
6224
|
+
.reaction-picker .reaction { border-color: transparent; font-size: 14px; }
|
|
6225
|
+
.btn-xs { width: 24px; height: 24px; }
|
|
4781
6226
|
.card-head { display: flex; align-items: center; gap: 8px; }
|
|
4782
6227
|
.card-head .spacer { flex: 1; }
|
|
4783
6228
|
.avatar {
|
|
@@ -4810,6 +6255,26 @@ button { cursor: pointer; }
|
|
|
4810
6255
|
}
|
|
4811
6256
|
.badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
|
|
4812
6257
|
.comment-body { white-space: pre-wrap; word-break: break-word; }
|
|
6258
|
+
.shot {
|
|
6259
|
+
display: block;
|
|
6260
|
+
border: 1px solid var(--n-border);
|
|
6261
|
+
border-radius: 8px;
|
|
6262
|
+
overflow: hidden;
|
|
6263
|
+
background: var(--n-bg-subtle);
|
|
6264
|
+
}
|
|
6265
|
+
.shot img { display: block; width: 100%; max-height: 160px; object-fit: cover; object-position: top; }
|
|
6266
|
+
.shot-preview { display: grid; gap: 4px; }
|
|
6267
|
+
.shot-preview img {
|
|
6268
|
+
display: block;
|
|
6269
|
+
width: 100%;
|
|
6270
|
+
max-height: 200px;
|
|
6271
|
+
/* The whole image, so the commenter sees everything that is attached. */
|
|
6272
|
+
object-fit: contain;
|
|
6273
|
+
border: 1px solid var(--n-border);
|
|
6274
|
+
border-radius: 8px;
|
|
6275
|
+
background: var(--n-bg-subtle);
|
|
6276
|
+
}
|
|
6277
|
+
.shot-note { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--n-fg-muted); }
|
|
4813
6278
|
.field {
|
|
4814
6279
|
width: 100%;
|
|
4815
6280
|
padding: 8px 10px;
|
|
@@ -4932,14 +6397,74 @@ a.item { color: inherit; text-decoration: none; }
|
|
|
4932
6397
|
@media (prefers-reduced-motion: reduce) {
|
|
4933
6398
|
* { transition: none !important; animation: none !important; }
|
|
4934
6399
|
}
|
|
6400
|
+
|
|
6401
|
+
/* The toolbar's corner (the position option) */
|
|
6402
|
+
:host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
|
|
6403
|
+
:host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
|
|
6404
|
+
:host([data-position$="left"]) .panel { right: auto; left: 12px; }
|
|
6405
|
+
:host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
|
|
6406
|
+
:host([data-position^="top"]) .toast { bottom: auto; top: 72px; }
|
|
6407
|
+
@media (max-width: 640px) {
|
|
6408
|
+
:host([data-position$="left"]) .toolbar { left: 12px; }
|
|
6409
|
+
:host([data-position^="top"]) .toolbar { top: 12px; }
|
|
6410
|
+
}
|
|
4935
6411
|
`;
|
|
4936
6412
|
//#endregion
|
|
4937
6413
|
//#region src/widget.ts
|
|
4938
6414
|
const PICKING_CLASS = "nuni-picking";
|
|
6415
|
+
const PICK_EVENTS = [
|
|
6416
|
+
"pointerdown",
|
|
6417
|
+
"mousedown",
|
|
6418
|
+
"pointerup",
|
|
6419
|
+
"mouseup"
|
|
6420
|
+
];
|
|
6421
|
+
/** How far the pointer moves before a press becomes an area drag. */
|
|
6422
|
+
const DRAG_THRESHOLD = 6;
|
|
6423
|
+
/** The rectangles of a range that have a size (lines of text). */
|
|
6424
|
+
function lineRects(range) {
|
|
6425
|
+
return Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
6426
|
+
}
|
|
6427
|
+
/** An area comment's box on screen, from its element's box. */
|
|
6428
|
+
function regionBox(anchor, el) {
|
|
6429
|
+
const region = anchor.region;
|
|
6430
|
+
if (!region) return null;
|
|
6431
|
+
const r = viewportRect(el, document);
|
|
6432
|
+
return {
|
|
6433
|
+
left: r.left + region.x * r.width,
|
|
6434
|
+
top: r.top + region.y * r.height,
|
|
6435
|
+
width: region.w * r.width,
|
|
6436
|
+
height: region.h * r.height
|
|
6437
|
+
};
|
|
6438
|
+
}
|
|
6439
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
6440
|
+
/** Run when the browser is idle, or soon. */
|
|
6441
|
+
function whenIdle(fn) {
|
|
6442
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(fn, { timeout: 100 });
|
|
6443
|
+
else setTimeout(fn, 0);
|
|
6444
|
+
}
|
|
6445
|
+
/** Same-origin iframe documents on the page, nested ones too. */
|
|
6446
|
+
function frameDocuments(doc, depth = 0) {
|
|
6447
|
+
if (depth > 3) return [];
|
|
6448
|
+
const out = [];
|
|
6449
|
+
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
|
|
6450
|
+
const inner = frameDocument(frame);
|
|
6451
|
+
if (inner?.documentElement) out.push(inner, ...frameDocuments(inner, depth + 1));
|
|
6452
|
+
}
|
|
6453
|
+
return out;
|
|
6454
|
+
}
|
|
4939
6455
|
const MOBILE_QUERY = "(max-width: 640px)";
|
|
4940
6456
|
function isTypingTarget(target) {
|
|
4941
|
-
if (!
|
|
4942
|
-
|
|
6457
|
+
if (!target || target.nodeType !== 1) return false;
|
|
6458
|
+
const el = target;
|
|
6459
|
+
return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
|
6460
|
+
}
|
|
6461
|
+
function dataUrl(blob) {
|
|
6462
|
+
return new Promise((resolve, reject) => {
|
|
6463
|
+
const reader = new FileReader();
|
|
6464
|
+
reader.onload = () => resolve(String(reader.result));
|
|
6465
|
+
reader.onerror = () => reject(reader.error);
|
|
6466
|
+
reader.readAsDataURL(blob);
|
|
6467
|
+
});
|
|
4943
6468
|
}
|
|
4944
6469
|
function describeElement(el) {
|
|
4945
6470
|
const tag = el.tagName.toLowerCase();
|
|
@@ -4949,12 +6474,15 @@ function describeElement(el) {
|
|
|
4949
6474
|
return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
|
|
4950
6475
|
}
|
|
4951
6476
|
var NuniWidget = class {
|
|
4952
|
-
constructor(config) {
|
|
6477
|
+
constructor(config, runtime) {
|
|
4953
6478
|
this.config = config;
|
|
6479
|
+
this.runtime = runtime;
|
|
4954
6480
|
_defineProperty(this, "host", void 0);
|
|
4955
6481
|
_defineProperty(this, "root", void 0);
|
|
6482
|
+
_defineProperty(this, "markLayer", void 0);
|
|
4956
6483
|
_defineProperty(this, "pinLayer", void 0);
|
|
4957
6484
|
_defineProperty(this, "overlayLayer", void 0);
|
|
6485
|
+
_defineProperty(this, "selectButton", void 0);
|
|
4958
6486
|
_defineProperty(this, "uiLayer", void 0);
|
|
4959
6487
|
_defineProperty(this, "api", void 0);
|
|
4960
6488
|
_defineProperty(this, "cleanups", []);
|
|
@@ -4982,10 +6510,32 @@ var NuniWidget = class {
|
|
|
4982
6510
|
_defineProperty(this, "ownerName", null);
|
|
4983
6511
|
_defineProperty(this, "deepLinkId", null);
|
|
4984
6512
|
_defineProperty(this, "extraComment", null);
|
|
6513
|
+
_defineProperty(this, "ownerDetail", null);
|
|
6514
|
+
_defineProperty(this, "ownerDetailId", null);
|
|
6515
|
+
_defineProperty(this, "ownerDetailLoaded", false);
|
|
6516
|
+
_defineProperty(this, "ownerDetailUnsub", null);
|
|
6517
|
+
_defineProperty(this, "thread", null);
|
|
6518
|
+
_defineProperty(this, "threadId", null);
|
|
6519
|
+
_defineProperty(this, "threadUnsub", null);
|
|
6520
|
+
_defineProperty(this, "resolveRun", 0);
|
|
6521
|
+
_defineProperty(this, "scopeWatchers", /* @__PURE__ */ new Map());
|
|
6522
|
+
_defineProperty(this, "pickDocs", []);
|
|
6523
|
+
_defineProperty(this, "dragStart", null);
|
|
6524
|
+
_defineProperty(this, "dragBox", null);
|
|
6525
|
+
_defineProperty(this, "dragEnded", false);
|
|
6526
|
+
_defineProperty(this, "selection", null);
|
|
6527
|
+
_defineProperty(this, "pointerDown", false);
|
|
6528
|
+
_defineProperty(this, "selectionFrame", 0);
|
|
6529
|
+
_defineProperty(this, "returnFocus", null);
|
|
6530
|
+
_defineProperty(this, "focusNext", null);
|
|
6531
|
+
_defineProperty(this, "confirming", null);
|
|
6532
|
+
_defineProperty(this, "live", void 0);
|
|
4985
6533
|
_defineProperty(this, "layoutFrame", 0);
|
|
4986
6534
|
_defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
|
|
4987
6535
|
_defineProperty(this, "resolveTimer", 0);
|
|
6536
|
+
_defineProperty(this, "i18n", void 0);
|
|
4988
6537
|
_defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
|
|
6538
|
+
_defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
|
|
4989
6539
|
_defineProperty(this, "swallow", (e) => {
|
|
4990
6540
|
if (!this.fromPage(e)) return;
|
|
4991
6541
|
if (e.type === "pointerdown" && e instanceof PointerEvent) {
|
|
@@ -4994,11 +6544,41 @@ var NuniWidget = class {
|
|
|
4994
6544
|
this.hoverEl = pickTarget(target);
|
|
4995
6545
|
this.renderHighlight();
|
|
4996
6546
|
}
|
|
6547
|
+
this.dragEnded = false;
|
|
6548
|
+
this.dragStart = e.pointerType !== "touch" && e.button === 0 && (target?.ownerDocument ?? null) === document ? {
|
|
6549
|
+
x: e.clientX,
|
|
6550
|
+
y: e.clientY
|
|
6551
|
+
} : null;
|
|
4997
6552
|
}
|
|
6553
|
+
if (e.type === "pointerup" && this.dragBox) {
|
|
6554
|
+
const box = this.dragBox;
|
|
6555
|
+
this.dragStart = null;
|
|
6556
|
+
this.dragBox = null;
|
|
6557
|
+
this.dragEnded = true;
|
|
6558
|
+
setTimeout(() => {
|
|
6559
|
+
this.dragEnded = false;
|
|
6560
|
+
if (this.picking) this.commentOnArea(box);
|
|
6561
|
+
}, 0);
|
|
6562
|
+
} else if (e.type === "pointerup") this.dragStart = null;
|
|
4998
6563
|
e.preventDefault();
|
|
4999
6564
|
e.stopPropagation();
|
|
5000
6565
|
});
|
|
5001
6566
|
_defineProperty(this, "onPickMove", (e) => {
|
|
6567
|
+
const start = this.dragStart;
|
|
6568
|
+
if (start) {
|
|
6569
|
+
const dx = e.clientX - start.x;
|
|
6570
|
+
const dy = e.clientY - start.y;
|
|
6571
|
+
if (this.dragBox || Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
6572
|
+
this.dragBox = {
|
|
6573
|
+
left: Math.min(start.x, e.clientX),
|
|
6574
|
+
top: Math.min(start.y, e.clientY),
|
|
6575
|
+
width: Math.abs(dx),
|
|
6576
|
+
height: Math.abs(dy)
|
|
6577
|
+
};
|
|
6578
|
+
this.renderHighlight();
|
|
6579
|
+
return;
|
|
6580
|
+
}
|
|
6581
|
+
}
|
|
5002
6582
|
const target = this.fromPage(e);
|
|
5003
6583
|
const next = target ? pickTarget(target) : null;
|
|
5004
6584
|
if (next === this.hoverEl) return;
|
|
@@ -5011,22 +6591,21 @@ var NuniWidget = class {
|
|
|
5011
6591
|
e.preventDefault();
|
|
5012
6592
|
e.stopPropagation();
|
|
5013
6593
|
e.stopImmediatePropagation();
|
|
6594
|
+
if (this.dragEnded) return;
|
|
5014
6595
|
const element = pickTarget(target);
|
|
5015
6596
|
const anchor = captureAnchor(element, {
|
|
5016
|
-
x: e.clientX,
|
|
5017
|
-
y: e.clientY
|
|
5018
|
-
}, {
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
};
|
|
5027
|
-
this.render();
|
|
5028
|
-
this.focusComposer();
|
|
6597
|
+
x: e.clientX,
|
|
6598
|
+
y: e.clientY
|
|
6599
|
+
}, {
|
|
6600
|
+
ignoreAttributePrefixes: [],
|
|
6601
|
+
document
|
|
6602
|
+
});
|
|
6603
|
+
this.openComposer({
|
|
6604
|
+
anchor,
|
|
6605
|
+
element
|
|
6606
|
+
});
|
|
5029
6607
|
});
|
|
6608
|
+
this.i18n = createI18n(config.locale, config.messages);
|
|
5030
6609
|
let secret = read(KEYS.secret);
|
|
5031
6610
|
if (!secret) {
|
|
5032
6611
|
secret = generateSecret();
|
|
@@ -5037,15 +6616,56 @@ var NuniWidget = class {
|
|
|
5037
6616
|
this.host = document.createElement("div");
|
|
5038
6617
|
this.host.id = "nuni-root";
|
|
5039
6618
|
this.host.setAttribute("data-nuni", "");
|
|
6619
|
+
this.applyAppearance();
|
|
5040
6620
|
this.root = this.host.attachShadow({ mode: "open" });
|
|
5041
6621
|
const style = document.createElement("style");
|
|
5042
6622
|
style.textContent = STYLES;
|
|
6623
|
+
this.markLayer = h("div", {
|
|
6624
|
+
class: "marks",
|
|
6625
|
+
"aria-hidden": "true"
|
|
6626
|
+
});
|
|
5043
6627
|
this.pinLayer = h("div", { class: "pins" });
|
|
5044
6628
|
this.overlayLayer = h("div", { class: "overlay" });
|
|
5045
6629
|
this.uiLayer = h("div", { class: "ui" });
|
|
5046
|
-
this.
|
|
6630
|
+
this.selectButton = h("button", {
|
|
6631
|
+
class: "select-btn",
|
|
6632
|
+
type: "button",
|
|
6633
|
+
hidden: true,
|
|
6634
|
+
title: this.withKey("commentSelection", "commentSelectionKey"),
|
|
6635
|
+
onmousedown: (e) => e.preventDefault(),
|
|
6636
|
+
onpointerdown: (e) => e.preventDefault(),
|
|
6637
|
+
onclick: () => this.commentOnSelection()
|
|
6638
|
+
}, icon(ICONS.comment), this.t("comment"));
|
|
6639
|
+
this.live = h("div", {
|
|
6640
|
+
class: "sr",
|
|
6641
|
+
role: "status",
|
|
6642
|
+
"aria-live": "polite"
|
|
6643
|
+
});
|
|
6644
|
+
this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
|
|
6645
|
+
this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
|
|
5047
6646
|
this.api = new NuniApi(config);
|
|
5048
6647
|
}
|
|
6648
|
+
t(key, vars) {
|
|
6649
|
+
return this.i18n.t(key, vars);
|
|
6650
|
+
}
|
|
6651
|
+
/** A message that names the hotkey, or the plain one when it is off. */
|
|
6652
|
+
withKey(plain, keyed) {
|
|
6653
|
+
const key = this.config.hotkey;
|
|
6654
|
+
return key ? this.t(keyed, { key: key.toUpperCase() }) : this.t(plain);
|
|
6655
|
+
}
|
|
6656
|
+
/** Position, theme, brand color and stacking order from the options. */
|
|
6657
|
+
applyAppearance() {
|
|
6658
|
+
const { position, theme, accentColor, zIndex } = this.config;
|
|
6659
|
+
this.host.dataset.position = position;
|
|
6660
|
+
this.host.dataset.theme = theme;
|
|
6661
|
+
if (zIndex !== null) this.host.style.zIndex = String(zIndex);
|
|
6662
|
+
const rgb = accentColor ? parseColor(accentColor) : null;
|
|
6663
|
+
if (!rgb) return;
|
|
6664
|
+
const [r, g, b] = rgb;
|
|
6665
|
+
this.host.style.setProperty("--n-accent", `rgb(${r}, ${g}, ${b})`);
|
|
6666
|
+
this.host.style.setProperty("--n-accent-soft", `rgba(${r}, ${g}, ${b}, 0.14)`);
|
|
6667
|
+
this.host.style.setProperty("--n-accent-fg", readableOn(rgb));
|
|
6668
|
+
}
|
|
5049
6669
|
start() {
|
|
5050
6670
|
document.body.appendChild(this.host);
|
|
5051
6671
|
sha256Hex(this.secret).then((hash) => {
|
|
@@ -5078,6 +6698,25 @@ var NuniWidget = class {
|
|
|
5078
6698
|
});
|
|
5079
6699
|
window.addEventListener("resize", onScroll, { passive: true });
|
|
5080
6700
|
window.addEventListener("message", onMessage);
|
|
6701
|
+
const onSelection = () => this.scheduleSelection();
|
|
6702
|
+
const onDown = (e) => {
|
|
6703
|
+
if (e.composedPath().includes(this.host)) return;
|
|
6704
|
+
this.pointerDown = true;
|
|
6705
|
+
this.scheduleSelection();
|
|
6706
|
+
};
|
|
6707
|
+
const onUp = () => {
|
|
6708
|
+
this.pointerDown = false;
|
|
6709
|
+
this.scheduleSelection();
|
|
6710
|
+
};
|
|
6711
|
+
document.addEventListener("selectionchange", onSelection);
|
|
6712
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
6713
|
+
document.addEventListener("pointerup", onUp, true);
|
|
6714
|
+
this.cleanups.push(() => {
|
|
6715
|
+
document.removeEventListener("selectionchange", onSelection);
|
|
6716
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
6717
|
+
document.removeEventListener("pointerup", onUp, true);
|
|
6718
|
+
cancelAnimationFrame(this.selectionFrame);
|
|
6719
|
+
});
|
|
5081
6720
|
this.cleanups.push(() => {
|
|
5082
6721
|
window.removeEventListener("keydown", onKey, true);
|
|
5083
6722
|
window.removeEventListener("scroll", onScroll, true);
|
|
@@ -5113,6 +6752,11 @@ var NuniWidget = class {
|
|
|
5113
6752
|
this.setPicking(false);
|
|
5114
6753
|
this.pageUnsub?.();
|
|
5115
6754
|
this.sessionUnsub?.();
|
|
6755
|
+
this.ownerDetailUnsub?.();
|
|
6756
|
+
this.threadUnsub?.();
|
|
6757
|
+
this.resolveRun++;
|
|
6758
|
+
for (const stop of this.scopeWatchers.values()) stop();
|
|
6759
|
+
this.scopeWatchers.clear();
|
|
5116
6760
|
for (const cleanup of this.cleanups) cleanup();
|
|
5117
6761
|
cancelAnimationFrame(this.layoutFrame);
|
|
5118
6762
|
clearTimeout(this.resolveTimer);
|
|
@@ -5122,7 +6766,7 @@ var NuniWidget = class {
|
|
|
5122
6766
|
}
|
|
5123
6767
|
currentPageKey() {
|
|
5124
6768
|
const url = new URL(location.href);
|
|
5125
|
-
return this.config.getPageKey
|
|
6769
|
+
return this.config.getPageKey(url);
|
|
5126
6770
|
}
|
|
5127
6771
|
subscribePage() {
|
|
5128
6772
|
const key = this.currentPageKey();
|
|
@@ -5140,7 +6784,7 @@ var NuniWidget = class {
|
|
|
5140
6784
|
const merged = extra && !comments.some((c) => c._id === extra._id) ? [...comments, extra] : comments;
|
|
5141
6785
|
this.comments = merged.sort((a, b) => a.createdAt - b.createdAt);
|
|
5142
6786
|
this.loaded = true;
|
|
5143
|
-
this.resolvePlacements(
|
|
6787
|
+
this.resolvePlacements(this.placements.size > 0, this.deepLinkId ?? void 0);
|
|
5144
6788
|
this.openDeepLink();
|
|
5145
6789
|
this.render();
|
|
5146
6790
|
}, () => {});
|
|
@@ -5149,7 +6793,7 @@ var NuniWidget = class {
|
|
|
5149
6793
|
watchSession(token) {
|
|
5150
6794
|
this.sessionUnsub?.();
|
|
5151
6795
|
this.sessionUnsub = this.api.onSession(token, (result) => {
|
|
5152
|
-
if (result.valid) this.ownerName = result.ownerName ?? "
|
|
6796
|
+
if (result.valid) this.ownerName = result.ownerName ?? this.t("owner");
|
|
5153
6797
|
else {
|
|
5154
6798
|
this.ownerName = null;
|
|
5155
6799
|
this.ownerToken = null;
|
|
@@ -5160,6 +6804,39 @@ var NuniWidget = class {
|
|
|
5160
6804
|
this.render();
|
|
5161
6805
|
});
|
|
5162
6806
|
}
|
|
6807
|
+
/** Keep the owner-only details of the open thread subscribed. */
|
|
6808
|
+
syncOwnerDetail() {
|
|
6809
|
+
const token = this.ownerToken;
|
|
6810
|
+
const id = this.card?.kind === "thread" && this.isOwner && token ? this.card.id : null;
|
|
6811
|
+
if (id === this.ownerDetailId) return;
|
|
6812
|
+
this.ownerDetailUnsub?.();
|
|
6813
|
+
this.ownerDetailUnsub = null;
|
|
6814
|
+
this.ownerDetail = null;
|
|
6815
|
+
this.ownerDetailLoaded = false;
|
|
6816
|
+
this.ownerDetailId = id;
|
|
6817
|
+
if (!id || !token) return;
|
|
6818
|
+
this.ownerDetailUnsub = this.api.onOwnerComment(id, token, (comment) => {
|
|
6819
|
+
if (this.ownerDetailId !== id) return;
|
|
6820
|
+
this.ownerDetail = comment;
|
|
6821
|
+
this.ownerDetailLoaded = true;
|
|
6822
|
+
this.render();
|
|
6823
|
+
});
|
|
6824
|
+
}
|
|
6825
|
+
/** Keep the open thread's replies and reactions subscribed. */
|
|
6826
|
+
syncThread() {
|
|
6827
|
+
const id = this.card?.kind === "thread" ? this.card.id : null;
|
|
6828
|
+
if (id === this.threadId) return;
|
|
6829
|
+
this.threadUnsub?.();
|
|
6830
|
+
this.threadUnsub = null;
|
|
6831
|
+
this.thread = null;
|
|
6832
|
+
this.threadId = id;
|
|
6833
|
+
if (!id) return;
|
|
6834
|
+
this.threadUnsub = this.api.onThread(id, (thread) => {
|
|
6835
|
+
if (this.threadId !== id) return;
|
|
6836
|
+
this.thread = thread;
|
|
6837
|
+
this.render();
|
|
6838
|
+
});
|
|
6839
|
+
}
|
|
5163
6840
|
get isOwner() {
|
|
5164
6841
|
return Boolean(this.ownerToken && this.ownerName);
|
|
5165
6842
|
}
|
|
@@ -5173,38 +6850,165 @@ var NuniWidget = class {
|
|
|
5173
6850
|
clearTimeout(this.resolveTimer);
|
|
5174
6851
|
this.resolveTimer = window.setTimeout(() => this.resolvePlacements(true), 200);
|
|
5175
6852
|
}
|
|
6853
|
+
stillPlaced(comment) {
|
|
6854
|
+
const current = this.placements.get(comment._id);
|
|
6855
|
+
if (!current?.element?.isConnected) return false;
|
|
6856
|
+
const quote = comment.anchor.quote;
|
|
6857
|
+
if (quote) return current.range !== void 0 && collapse(current.range.toString()) === quote.exact;
|
|
6858
|
+
const text = elementText(current.element);
|
|
6859
|
+
const expected = comment.anchor.text;
|
|
6860
|
+
return !expected || text === expected || textSimilarity(text, expected) > .8;
|
|
6861
|
+
}
|
|
5176
6862
|
/**
|
|
5177
6863
|
* Find each comment's element. With `onlyStale`, keep placements whose
|
|
5178
6864
|
* element is still attached and still looks like what was captured.
|
|
6865
|
+
* Works in slices of about 8 ms, so a page with many pins stays
|
|
6866
|
+
* responsive; `first` (a deep-linked comment) is found right away.
|
|
5179
6867
|
*/
|
|
5180
|
-
resolvePlacements(onlyStale) {
|
|
6868
|
+
resolvePlacements(onlyStale, first) {
|
|
6869
|
+
const run = ++this.resolveRun;
|
|
5181
6870
|
const ids = new Set(this.comments.map((c) => c._id));
|
|
5182
6871
|
for (const id of this.placements.keys()) if (!ids.has(id)) this.placements.delete(id);
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
6872
|
+
const queue = this.comments.filter((c) => !(onlyStale && this.stillPlaced(c)));
|
|
6873
|
+
const index = first ? queue.findIndex((c) => c._id === first) : -1;
|
|
6874
|
+
if (index > 0) queue.unshift(...queue.splice(index, 1));
|
|
6875
|
+
const cache = createResolveCache();
|
|
6876
|
+
const options = {
|
|
6877
|
+
isIgnored: this.isIgnored,
|
|
6878
|
+
document
|
|
6879
|
+
};
|
|
6880
|
+
const slice = () => {
|
|
6881
|
+
if (run !== this.resolveRun) return;
|
|
6882
|
+
const started = performance.now();
|
|
6883
|
+
while (queue.length) {
|
|
6884
|
+
const comment = queue.shift();
|
|
6885
|
+
const result = resolveAnchor(comment.anchor, document, options, cache);
|
|
6886
|
+
this.placements.set(comment._id, {
|
|
6887
|
+
element: result.element,
|
|
6888
|
+
confidence: result.confidence,
|
|
6889
|
+
range: result.range
|
|
6890
|
+
});
|
|
6891
|
+
if (performance.now() - started > 8) break;
|
|
6892
|
+
}
|
|
6893
|
+
this.watchScopes();
|
|
6894
|
+
this.renderPins();
|
|
6895
|
+
this.layout();
|
|
6896
|
+
if (queue.length) whenIdle(slice);
|
|
6897
|
+
else if ((onlyStale || run > 1) && this.panelOpen) this.render();
|
|
6898
|
+
};
|
|
6899
|
+
slice();
|
|
6900
|
+
}
|
|
6901
|
+
/**
|
|
6902
|
+
* Pins inside shadow roots and iframes: the page's observers don't see
|
|
6903
|
+
* changes or scrolling in there, so watch each of those scopes too.
|
|
6904
|
+
*/
|
|
6905
|
+
watchScopes() {
|
|
6906
|
+
const roots = /* @__PURE__ */ new Set();
|
|
6907
|
+
for (const { element } of this.placements.values()) {
|
|
6908
|
+
let node = element;
|
|
6909
|
+
for (let depth = 0; node && depth < 8; depth++) {
|
|
6910
|
+
const root = scopeRootOf(node);
|
|
6911
|
+
if (root === document) break;
|
|
6912
|
+
roots.add(root);
|
|
6913
|
+
node = isShadowRoot(root) ? root.host : frameElementOf(root);
|
|
5189
6914
|
}
|
|
5190
|
-
const result = resolveAnchor(comment.anchor, document, { isIgnored: this.isIgnored });
|
|
5191
|
-
this.placements.set(comment._id, {
|
|
5192
|
-
element: result.element,
|
|
5193
|
-
confidence: result.confidence
|
|
5194
|
-
});
|
|
5195
6915
|
}
|
|
5196
|
-
this.
|
|
5197
|
-
|
|
5198
|
-
|
|
6916
|
+
for (const [root, stop] of this.scopeWatchers) if (!roots.has(root)) {
|
|
6917
|
+
stop();
|
|
6918
|
+
this.scopeWatchers.delete(root);
|
|
6919
|
+
}
|
|
6920
|
+
for (const root of roots) if (!this.scopeWatchers.has(root)) this.scopeWatchers.set(root, this.watchScope(root));
|
|
6921
|
+
}
|
|
6922
|
+
watchScope(root) {
|
|
6923
|
+
const observer = new MutationObserver(() => this.scheduleResolve());
|
|
6924
|
+
const doc = isShadowRoot(root) ? null : root;
|
|
6925
|
+
observer.observe(doc ? doc.body ?? doc.documentElement : root, {
|
|
6926
|
+
childList: true,
|
|
6927
|
+
subtree: true,
|
|
6928
|
+
characterData: true,
|
|
6929
|
+
attributes: true,
|
|
6930
|
+
attributeFilter: [
|
|
6931
|
+
"class",
|
|
6932
|
+
"style",
|
|
6933
|
+
"hidden",
|
|
6934
|
+
"open",
|
|
6935
|
+
"id"
|
|
6936
|
+
]
|
|
6937
|
+
});
|
|
6938
|
+
const onScroll = () => this.scheduleLayout();
|
|
6939
|
+
const target = doc?.defaultView ?? root;
|
|
6940
|
+
target.addEventListener("scroll", onScroll, {
|
|
6941
|
+
capture: true,
|
|
6942
|
+
passive: true
|
|
6943
|
+
});
|
|
6944
|
+
doc?.defaultView?.addEventListener("resize", onScroll, { passive: true });
|
|
6945
|
+
return () => {
|
|
6946
|
+
observer.disconnect();
|
|
6947
|
+
target.removeEventListener("scroll", onScroll, true);
|
|
6948
|
+
doc?.defaultView?.removeEventListener("resize", onScroll);
|
|
6949
|
+
};
|
|
5199
6950
|
}
|
|
5200
6951
|
pinPoint(comment) {
|
|
5201
|
-
const
|
|
6952
|
+
const placement = this.placements.get(comment._id);
|
|
6953
|
+
const el = placement?.element;
|
|
5202
6954
|
if (!el || !isRendered(el)) return null;
|
|
5203
|
-
|
|
6955
|
+
if (viewportRect(el, document).clipped) return null;
|
|
6956
|
+
return this.anchorPoint(comment.anchor, el, placement.range);
|
|
6957
|
+
}
|
|
6958
|
+
/**
|
|
6959
|
+
* Where a pin goes: after the last selected word for a text comment, the
|
|
6960
|
+
* top-right corner of an area, or where the element was clicked.
|
|
6961
|
+
*/
|
|
6962
|
+
anchorPoint(anchor, el, range) {
|
|
6963
|
+
if (range && range.startContainer.ownerDocument === document) {
|
|
6964
|
+
const last = lineRects(range).at(-1);
|
|
6965
|
+
if (last) return {
|
|
6966
|
+
x: last.right,
|
|
6967
|
+
y: last.top + last.height / 2
|
|
6968
|
+
};
|
|
6969
|
+
}
|
|
6970
|
+
const r = viewportRect(el, document);
|
|
5204
6971
|
return {
|
|
5205
|
-
x: r.left +
|
|
5206
|
-
y: r.top +
|
|
6972
|
+
x: r.left + anchor.offset.x * r.width,
|
|
6973
|
+
y: r.top + anchor.offset.y * r.height
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
6976
|
+
/**
|
|
6977
|
+
* Text comments underline their words, and the open comment (or the one
|
|
6978
|
+
* being written) also shows its words or its area strongly.
|
|
6979
|
+
*/
|
|
6980
|
+
renderMarks() {
|
|
6981
|
+
const marks = [];
|
|
6982
|
+
const add = (box, cls) => {
|
|
6983
|
+
const mark = h("div", { class: cls });
|
|
6984
|
+
Object.assign(mark.style, {
|
|
6985
|
+
left: `${box.left}px`,
|
|
6986
|
+
top: `${box.top}px`,
|
|
6987
|
+
width: `${box.width}px`,
|
|
6988
|
+
height: `${box.height}px`
|
|
6989
|
+
});
|
|
6990
|
+
marks.push(mark);
|
|
5207
6991
|
};
|
|
6992
|
+
const show = (anchor, el, range, active) => {
|
|
6993
|
+
if (!el) return;
|
|
6994
|
+
if (range && range.startContainer.ownerDocument === document) for (const r of lineRects(range)) add(r, active ? "mark mark-active" : "mark");
|
|
6995
|
+
if (active) {
|
|
6996
|
+
const box = regionBox(anchor, el);
|
|
6997
|
+
if (box) add(box, "area");
|
|
6998
|
+
}
|
|
6999
|
+
};
|
|
7000
|
+
if (this.card?.kind === "composer") {
|
|
7001
|
+
const { draft } = this.card;
|
|
7002
|
+
show(draft.anchor, draft.element, draft.range, true);
|
|
7003
|
+
}
|
|
7004
|
+
for (const comment of this.visibleComments()) {
|
|
7005
|
+
const placement = this.placements.get(comment._id);
|
|
7006
|
+
const active = this.activeId === comment._id;
|
|
7007
|
+
if (!active && (comment.status !== "open" || !comment.anchor.quote)) continue;
|
|
7008
|
+
if (this.pins.get(comment._id)?.hidden) continue;
|
|
7009
|
+
show(comment.anchor, placement?.element, placement?.range, active);
|
|
7010
|
+
}
|
|
7011
|
+
this.markLayer.replaceChildren(...marks);
|
|
5208
7012
|
}
|
|
5209
7013
|
scheduleLayout() {
|
|
5210
7014
|
if (this.layoutFrame) return;
|
|
@@ -5237,63 +7041,224 @@ var NuniWidget = class {
|
|
|
5237
7041
|
});
|
|
5238
7042
|
pin.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
5239
7043
|
}
|
|
7044
|
+
this.renderMarks();
|
|
5240
7045
|
this.positionCard();
|
|
5241
|
-
if (this.picking && this.hoverEl) this.renderHighlight();
|
|
7046
|
+
if (this.picking && (this.hoverEl || this.dragBox)) this.renderHighlight();
|
|
7047
|
+
this.placeSelectButton();
|
|
5242
7048
|
}
|
|
5243
7049
|
setPicking(on) {
|
|
5244
7050
|
if (this.picking === on) return;
|
|
5245
7051
|
this.picking = on;
|
|
5246
7052
|
const html = document.documentElement;
|
|
7053
|
+
this.dragStart = null;
|
|
7054
|
+
this.dragBox = null;
|
|
5247
7055
|
if (on) {
|
|
5248
7056
|
this.card = null;
|
|
5249
7057
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5250
|
-
|
|
5251
|
-
this.
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
"
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
});
|
|
7058
|
+
this.pickDocs = [document, ...frameDocuments(document)];
|
|
7059
|
+
for (const doc of this.pickDocs) {
|
|
7060
|
+
doc.documentElement.classList.add(PICKING_CLASS);
|
|
7061
|
+
this.injectCursorStyle(doc);
|
|
7062
|
+
doc.addEventListener("pointermove", this.onPickMove, true);
|
|
7063
|
+
doc.addEventListener("click", this.onPickClick, true);
|
|
7064
|
+
for (const type of PICK_EVENTS) doc.addEventListener(type, this.swallow, {
|
|
7065
|
+
capture: true,
|
|
7066
|
+
passive: false
|
|
7067
|
+
});
|
|
7068
|
+
if (doc !== document) doc.addEventListener("keydown", this.onFrameKey, true);
|
|
7069
|
+
}
|
|
5263
7070
|
} else {
|
|
5264
7071
|
html.classList.remove(PICKING_CLASS);
|
|
5265
7072
|
this.hoverEl = null;
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
"
|
|
5270
|
-
|
|
5271
|
-
"
|
|
5272
|
-
|
|
5273
|
-
|
|
7073
|
+
for (const doc of this.pickDocs) {
|
|
7074
|
+
doc.documentElement?.classList.remove(PICKING_CLASS);
|
|
7075
|
+
doc.removeEventListener("pointermove", this.onPickMove, true);
|
|
7076
|
+
doc.removeEventListener("click", this.onPickClick, true);
|
|
7077
|
+
for (const type of PICK_EVENTS) doc.removeEventListener(type, this.swallow, true);
|
|
7078
|
+
doc.removeEventListener("keydown", this.onFrameKey, true);
|
|
7079
|
+
}
|
|
7080
|
+
this.pickDocs = [];
|
|
5274
7081
|
}
|
|
5275
7082
|
this.render();
|
|
5276
7083
|
}
|
|
5277
|
-
injectCursorStyle() {
|
|
5278
|
-
if (
|
|
5279
|
-
const style =
|
|
7084
|
+
injectCursorStyle(doc) {
|
|
7085
|
+
if (doc.getElementById("nuni-cursor-style")) return;
|
|
7086
|
+
const style = doc.createElement("style");
|
|
5280
7087
|
style.id = "nuni-cursor-style";
|
|
5281
7088
|
style.textContent = `html.${PICKING_CLASS}, html.${PICKING_CLASS} * { cursor: crosshair !important; }`;
|
|
5282
|
-
|
|
7089
|
+
(doc.head ?? doc.documentElement).appendChild(style);
|
|
5283
7090
|
this.cleanups.push(() => style.remove());
|
|
5284
7091
|
}
|
|
7092
|
+
/**
|
|
7093
|
+
* The element the event happened on, inside open shadow roots too
|
|
7094
|
+
* (composedPath), and never Nuni's own UI.
|
|
7095
|
+
*/
|
|
5285
7096
|
fromPage(e) {
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
7097
|
+
const path = e.composedPath();
|
|
7098
|
+
if (path.includes(this.host)) return null;
|
|
7099
|
+
const target = path[0] ?? e.target;
|
|
7100
|
+
return target && target.nodeType === 1 ? target : null;
|
|
7101
|
+
}
|
|
7102
|
+
/** An area drawn while picking: anchored to the element that covers it. */
|
|
7103
|
+
commentOnArea(box) {
|
|
7104
|
+
const element = areaContainer(document, box, this.isIgnored);
|
|
7105
|
+
const anchor = captureArea(element, box, {
|
|
7106
|
+
ignoreAttributePrefixes: [],
|
|
7107
|
+
document
|
|
7108
|
+
});
|
|
7109
|
+
this.openComposer({
|
|
7110
|
+
anchor,
|
|
7111
|
+
element
|
|
7112
|
+
});
|
|
7113
|
+
}
|
|
7114
|
+
/** The page's selected text, if it can take a text comment. */
|
|
7115
|
+
pageSelection() {
|
|
7116
|
+
const selection = document.getSelection();
|
|
7117
|
+
if (!selection || selection.isCollapsed || !selection.rangeCount) return null;
|
|
7118
|
+
const range = selection.getRangeAt(0);
|
|
7119
|
+
const node = range.commonAncestorContainer;
|
|
7120
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
7121
|
+
if (!el || el === this.host || this.host.contains(el)) return null;
|
|
7122
|
+
if (el.closest("input, textarea, select, [contenteditable]")) return null;
|
|
7123
|
+
if (el.closest("[data-nuni]")) return null;
|
|
7124
|
+
return collapse(range.toString()) ? range : null;
|
|
7125
|
+
}
|
|
7126
|
+
commentOnSelection() {
|
|
7127
|
+
const range = this.pageSelection();
|
|
7128
|
+
if (!range) return;
|
|
7129
|
+
const captured = captureSelection(range, {
|
|
7130
|
+
ignoreAttributePrefixes: [],
|
|
7131
|
+
document
|
|
7132
|
+
});
|
|
7133
|
+
if (!captured) return;
|
|
7134
|
+
const kept = range.cloneRange();
|
|
7135
|
+
document.getSelection()?.removeAllRanges();
|
|
7136
|
+
this.selection = null;
|
|
7137
|
+
this.openComposer({
|
|
7138
|
+
...captured,
|
|
7139
|
+
range: kept
|
|
7140
|
+
});
|
|
7141
|
+
}
|
|
7142
|
+
scheduleSelection() {
|
|
7143
|
+
if (this.selectionFrame) return;
|
|
7144
|
+
this.selectionFrame = requestAnimationFrame(() => {
|
|
7145
|
+
this.selectionFrame = 0;
|
|
7146
|
+
this.selection = this.picking || this.pointerDown ? null : this.pageSelection();
|
|
7147
|
+
this.placeSelectButton();
|
|
7148
|
+
});
|
|
7149
|
+
}
|
|
7150
|
+
/** A small "Comment" button under the selected text. */
|
|
7151
|
+
placeSelectButton() {
|
|
7152
|
+
const button = this.selectButton;
|
|
7153
|
+
const range = this.selection;
|
|
7154
|
+
const last = range ? lineRects(range).at(-1) : void 0;
|
|
7155
|
+
const vw = window.innerWidth;
|
|
7156
|
+
const vh = window.innerHeight;
|
|
7157
|
+
if (!range || !last || last.bottom < 0 || last.top > vh) {
|
|
7158
|
+
button.hidden = true;
|
|
7159
|
+
return;
|
|
7160
|
+
}
|
|
7161
|
+
button.hidden = false;
|
|
7162
|
+
const width = button.offsetWidth || 96;
|
|
7163
|
+
const height = button.offsetHeight || 32;
|
|
7164
|
+
let top = last.bottom + 8;
|
|
7165
|
+
if (top + height > vh - 8) top = last.top - height - 8;
|
|
7166
|
+
const left = Math.max(8, Math.min(last.right - width / 2, vw - width - 8));
|
|
7167
|
+
button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
7168
|
+
}
|
|
7169
|
+
openComposer(draft) {
|
|
7170
|
+
const { element } = draft;
|
|
7171
|
+
this.setPicking(false);
|
|
7172
|
+
if (this.config.capture.dom) try {
|
|
7173
|
+
draft.dom = domContext(element);
|
|
7174
|
+
} catch {}
|
|
7175
|
+
this.card = {
|
|
7176
|
+
kind: "composer",
|
|
7177
|
+
draft
|
|
7178
|
+
};
|
|
7179
|
+
this.activeId = null;
|
|
7180
|
+
this.render();
|
|
7181
|
+
this.focusComposer();
|
|
7182
|
+
this.takeScreenshot(draft);
|
|
7183
|
+
}
|
|
7184
|
+
/** Capture while the person types, so posting stays instant. */
|
|
7185
|
+
async takeScreenshot(draft) {
|
|
7186
|
+
const load = this.runtime.loadScreenshot;
|
|
7187
|
+
if (!load || draft.element.ownerDocument !== document) return;
|
|
7188
|
+
const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
|
|
7189
|
+
try {
|
|
7190
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
7191
|
+
const capture = await load();
|
|
7192
|
+
const { range, anchor, element } = draft;
|
|
7193
|
+
const focus = range ? () => range.getBoundingClientRect() : anchor.region ? () => {
|
|
7194
|
+
const box = regionBox(anchor, element);
|
|
7195
|
+
return {
|
|
7196
|
+
left: box.left,
|
|
7197
|
+
top: box.top,
|
|
7198
|
+
right: box.left + box.width,
|
|
7199
|
+
bottom: box.top + box.height
|
|
7200
|
+
};
|
|
7201
|
+
} : void 0;
|
|
7202
|
+
const blob = capture ? await capture(element, {
|
|
7203
|
+
exclude: this.host,
|
|
7204
|
+
accent,
|
|
7205
|
+
focus
|
|
7206
|
+
}) : null;
|
|
7207
|
+
if (!blob || !this.isComposing(draft) || draft.sending) return;
|
|
7208
|
+
const preview = await dataUrl(blob);
|
|
7209
|
+
if (!this.isComposing(draft) || draft.sending) return;
|
|
7210
|
+
draft.screenshot = {
|
|
7211
|
+
blob,
|
|
7212
|
+
preview
|
|
7213
|
+
};
|
|
7214
|
+
this.render();
|
|
7215
|
+
} catch {}
|
|
7216
|
+
}
|
|
7217
|
+
isComposing(draft) {
|
|
7218
|
+
return this.card?.kind === "composer" && this.card.draft === draft;
|
|
7219
|
+
}
|
|
7220
|
+
collectContext(draft) {
|
|
7221
|
+
const { collectors } = this.runtime;
|
|
7222
|
+
const context = {};
|
|
7223
|
+
const logs = collectors.console();
|
|
7224
|
+
const failed = collectors.network();
|
|
7225
|
+
if (logs.length) context.console = logs;
|
|
7226
|
+
if (failed.length) context.network = failed;
|
|
7227
|
+
if (draft.dom) context.dom = draft.dom;
|
|
7228
|
+
return Object.keys(context).length ? context : void 0;
|
|
7229
|
+
}
|
|
7230
|
+
async attachScreenshot(id, image) {
|
|
7231
|
+
try {
|
|
7232
|
+
await this.api.uploadScreenshot(id, this.secret, image);
|
|
7233
|
+
} catch {}
|
|
7234
|
+
}
|
|
7235
|
+
copyForAgent(comment) {
|
|
7236
|
+
const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
|
|
7237
|
+
const replies = this.threadId === comment._id ? this.thread?.replies : void 0;
|
|
7238
|
+
copyText(buildCommentPrompt(detail ?? {
|
|
7239
|
+
...comment,
|
|
7240
|
+
replies
|
|
7241
|
+
}, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? this.t("copied") : this.t("copyFailed")));
|
|
5289
7242
|
}
|
|
5290
7243
|
renderHighlight() {
|
|
5291
7244
|
this.overlayLayer.replaceChildren();
|
|
5292
7245
|
if (!this.picking) return;
|
|
5293
|
-
|
|
7246
|
+
const mobile = window.matchMedia(MOBILE_QUERY).matches;
|
|
7247
|
+
this.overlayLayer.append(h("div", { class: "pick-hint" }, mobile ? this.t("pickHintMobile") : [h("span", {}, this.t("pickHint")), h("span", { class: "pick-keys" }, this.t("pickHintKeys"))]));
|
|
7248
|
+
if (this.dragBox) {
|
|
7249
|
+
const box = h("div", { class: "highlight drag-box" });
|
|
7250
|
+
Object.assign(box.style, {
|
|
7251
|
+
left: `${this.dragBox.left}px`,
|
|
7252
|
+
top: `${this.dragBox.top}px`,
|
|
7253
|
+
width: `${this.dragBox.width}px`,
|
|
7254
|
+
height: `${this.dragBox.height}px`
|
|
7255
|
+
});
|
|
7256
|
+
this.overlayLayer.append(box);
|
|
7257
|
+
return;
|
|
7258
|
+
}
|
|
5294
7259
|
const el = this.hoverEl;
|
|
5295
|
-
if (!el || el ===
|
|
5296
|
-
const r = el
|
|
7260
|
+
if (!el || el === el.ownerDocument.documentElement) return;
|
|
7261
|
+
const r = viewportRect(el, document);
|
|
5297
7262
|
const box = h("div", { class: "highlight" }, h("span", { class: "highlight-label" }, describeElement(el)));
|
|
5298
7263
|
Object.assign(box.style, {
|
|
5299
7264
|
left: `${r.left - 2}px`,
|
|
@@ -5306,19 +7271,171 @@ var NuniWidget = class {
|
|
|
5306
7271
|
onKeyDown(e) {
|
|
5307
7272
|
const inWidget = e.composedPath().includes(this.host);
|
|
5308
7273
|
if (e.key === "Escape") {
|
|
5309
|
-
if (this.
|
|
7274
|
+
if (this.confirming) this.answer(false);
|
|
7275
|
+
else if (this.picking) this.setPicking(false);
|
|
5310
7276
|
else if (this.card) this.closeCard();
|
|
5311
7277
|
else if (this.panelOpen) this.togglePanel(false);
|
|
5312
7278
|
else return;
|
|
5313
7279
|
e.stopPropagation();
|
|
5314
7280
|
return;
|
|
5315
7281
|
}
|
|
5316
|
-
if (
|
|
5317
|
-
if (
|
|
7282
|
+
if (isTypingTarget(e.composedPath()[0] ?? e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
7283
|
+
if (this.picking && !inWidget && this.onPickKey(e)) return;
|
|
7284
|
+
if (inWidget && this.confirming) return;
|
|
7285
|
+
if (e.repeat) return;
|
|
7286
|
+
const hotkey = this.config.hotkey;
|
|
7287
|
+
if (hotkey && e.key.toLowerCase() === hotkey) {
|
|
7288
|
+
e.preventDefault();
|
|
7289
|
+
if (!this.picking && this.pageSelection()) this.commentOnSelection();
|
|
7290
|
+
else this.setPicking(!this.picking);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
/**
|
|
7294
|
+
* Picking without a mouse: arrows move to the parent, first child and
|
|
7295
|
+
* siblings; Enter comments on the highlighted element.
|
|
7296
|
+
*/
|
|
7297
|
+
onPickKey(e) {
|
|
7298
|
+
if (![
|
|
7299
|
+
"ArrowUp",
|
|
7300
|
+
"ArrowDown",
|
|
7301
|
+
"ArrowLeft",
|
|
7302
|
+
"ArrowRight",
|
|
7303
|
+
"Enter"
|
|
7304
|
+
].includes(e.key)) return false;
|
|
7305
|
+
e.preventDefault();
|
|
7306
|
+
e.stopPropagation();
|
|
7307
|
+
let el = this.hoverEl;
|
|
7308
|
+
if (!el || !el.isConnected) {
|
|
7309
|
+
this.movePickTo(this.keyboardStart());
|
|
7310
|
+
return true;
|
|
7311
|
+
}
|
|
7312
|
+
if (e.key === "Enter") {
|
|
7313
|
+
const r = el.getBoundingClientRect();
|
|
7314
|
+
const anchor = captureAnchor(el, {
|
|
7315
|
+
x: r.left + r.width / 2,
|
|
7316
|
+
y: r.top + r.height / 2
|
|
7317
|
+
}, {
|
|
7318
|
+
ignoreAttributePrefixes: [],
|
|
7319
|
+
document
|
|
7320
|
+
});
|
|
7321
|
+
this.openComposer({
|
|
7322
|
+
anchor,
|
|
7323
|
+
element: el
|
|
7324
|
+
});
|
|
7325
|
+
return true;
|
|
7326
|
+
}
|
|
7327
|
+
const visible = (n) => {
|
|
7328
|
+
while (n && (n === this.host || !isRendered(n))) n = e.key === "ArrowLeft" ? n.previousElementSibling : n.nextElementSibling;
|
|
7329
|
+
return n;
|
|
7330
|
+
};
|
|
7331
|
+
const body = el.ownerDocument.body;
|
|
7332
|
+
if (e.key === "ArrowUp") {
|
|
7333
|
+
if (el.parentElement && el !== body) el = el.parentElement;
|
|
7334
|
+
} else if (e.key === "ArrowDown") el = visible(el.firstElementChild) ?? el;
|
|
7335
|
+
else if (e.key === "ArrowLeft") el = visible(el.previousElementSibling) ?? el;
|
|
7336
|
+
else el = visible(el.nextElementSibling) ?? el;
|
|
7337
|
+
this.movePickTo(el);
|
|
7338
|
+
return true;
|
|
7339
|
+
}
|
|
7340
|
+
/** Keyboard picking starts at the focused element, or mid-screen. */
|
|
7341
|
+
keyboardStart() {
|
|
7342
|
+
const focused = document.activeElement;
|
|
7343
|
+
if (focused && focused !== document.body && focused !== this.host) return focused;
|
|
7344
|
+
const middle = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2).find((el) => !this.isIgnored(el));
|
|
7345
|
+
return middle ? pickTarget(middle) : document.body;
|
|
7346
|
+
}
|
|
7347
|
+
movePickTo(el) {
|
|
7348
|
+
this.hoverEl = el;
|
|
7349
|
+
el.scrollIntoView({
|
|
7350
|
+
block: "nearest",
|
|
7351
|
+
inline: "nearest"
|
|
7352
|
+
});
|
|
7353
|
+
this.live.textContent = this.t("picked", { element: describeElement(el) });
|
|
7354
|
+
this.renderHighlight();
|
|
7355
|
+
}
|
|
7356
|
+
/** Tab stays inside an open card, the panel or a dialog. */
|
|
7357
|
+
trapFocus(e) {
|
|
7358
|
+
if (e.key !== "Tab") return;
|
|
7359
|
+
const box = e.composedPath()[0]?.closest?.(".confirm, [data-card], .panel");
|
|
7360
|
+
if (!box) return;
|
|
7361
|
+
const items = Array.from(box.querySelectorAll("button:not([disabled]), input, textarea, a[href], [tabindex=\"0\"]")).filter((el) => !el.closest("[hidden]"));
|
|
7362
|
+
if (!items.length) return;
|
|
7363
|
+
const first = items[0];
|
|
7364
|
+
const last = items[items.length - 1];
|
|
7365
|
+
const active = this.root.activeElement;
|
|
7366
|
+
if (e.shiftKey && active === first) {
|
|
7367
|
+
e.preventDefault();
|
|
7368
|
+
last.focus();
|
|
7369
|
+
} else if (!e.shiftKey && active === last) {
|
|
5318
7370
|
e.preventDefault();
|
|
5319
|
-
|
|
7371
|
+
first.focus();
|
|
5320
7372
|
}
|
|
5321
7373
|
}
|
|
7374
|
+
/** Remember where focus was, before moving it into the widget. */
|
|
7375
|
+
keepFocus() {
|
|
7376
|
+
if (this.returnFocus) return;
|
|
7377
|
+
const inside = this.root.activeElement;
|
|
7378
|
+
if (inside) {
|
|
7379
|
+
this.returnFocus = inside.dataset.focusKey ?? inside;
|
|
7380
|
+
return;
|
|
7381
|
+
}
|
|
7382
|
+
const active = document.activeElement;
|
|
7383
|
+
this.returnFocus = active && active !== document.body && active !== this.host ? active : null;
|
|
7384
|
+
}
|
|
7385
|
+
/** Back to where focus was before (when that is still there). */
|
|
7386
|
+
restoreFocus() {
|
|
7387
|
+
const back = this.returnFocus;
|
|
7388
|
+
this.returnFocus = null;
|
|
7389
|
+
const target = typeof back === "string" ? this.uiLayer.querySelector(`[data-focus-key="${back}"]`) : back;
|
|
7390
|
+
if (target?.isConnected) target.focus({ preventScroll: true });
|
|
7391
|
+
}
|
|
7392
|
+
/** An "are you sure?" dialog inside the widget. */
|
|
7393
|
+
ask(message, action) {
|
|
7394
|
+
this.confirming?.done(false);
|
|
7395
|
+
const from = this.root.activeElement?.dataset.focusKey ?? null;
|
|
7396
|
+
return new Promise((resolve) => {
|
|
7397
|
+
this.confirming = {
|
|
7398
|
+
message,
|
|
7399
|
+
action,
|
|
7400
|
+
done: resolve,
|
|
7401
|
+
from
|
|
7402
|
+
};
|
|
7403
|
+
this.focusNext = "[data-focus-key=\"confirm-cancel\"]";
|
|
7404
|
+
this.render();
|
|
7405
|
+
});
|
|
7406
|
+
}
|
|
7407
|
+
answer(ok) {
|
|
7408
|
+
const confirming = this.confirming;
|
|
7409
|
+
if (!confirming) return;
|
|
7410
|
+
this.confirming = null;
|
|
7411
|
+
if (!ok && confirming.from) this.focusNext = `[data-focus-key="${confirming.from}"]`;
|
|
7412
|
+
this.render();
|
|
7413
|
+
confirming.done(ok);
|
|
7414
|
+
}
|
|
7415
|
+
renderConfirm() {
|
|
7416
|
+
const c = this.confirming;
|
|
7417
|
+
if (!c) return null;
|
|
7418
|
+
return h("div", {
|
|
7419
|
+
class: "confirm-backdrop",
|
|
7420
|
+
onclick: () => this.answer(false)
|
|
7421
|
+
}, h("div", {
|
|
7422
|
+
class: "confirm",
|
|
7423
|
+
role: "alertdialog",
|
|
7424
|
+
"aria-modal": "true",
|
|
7425
|
+
"aria-labelledby": "nuni-confirm-text",
|
|
7426
|
+
onclick: (e) => e.stopPropagation()
|
|
7427
|
+
}, h("p", { id: "nuni-confirm-text" }, c.message), h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
7428
|
+
class: "btn btn-ghost",
|
|
7429
|
+
type: "button",
|
|
7430
|
+
"data-focus-key": "confirm-cancel",
|
|
7431
|
+
onclick: () => this.answer(false)
|
|
7432
|
+
}, this.t("cancel")), h("button", {
|
|
7433
|
+
class: "btn btn-danger-solid",
|
|
7434
|
+
type: "button",
|
|
7435
|
+
"data-focus-key": "confirm-ok",
|
|
7436
|
+
onclick: () => this.answer(true)
|
|
7437
|
+
}, c.action))));
|
|
7438
|
+
}
|
|
5322
7439
|
openClaim() {
|
|
5323
7440
|
const url = new URL(`${this.config.appUrl}/dashboard/claim`);
|
|
5324
7441
|
url.searchParams.set("project", this.config.project);
|
|
@@ -5339,7 +7456,7 @@ var NuniWidget = class {
|
|
|
5339
7456
|
this.ownerToken = data.token;
|
|
5340
7457
|
write(KEYS.session(this.config.project), data.token);
|
|
5341
7458
|
this.watchSession(data.token);
|
|
5342
|
-
this.showToast("
|
|
7459
|
+
this.showToast(this.t("signedIn"));
|
|
5343
7460
|
}
|
|
5344
7461
|
readDeepLink() {
|
|
5345
7462
|
const url = new URL(location.href);
|
|
@@ -5360,12 +7477,12 @@ var NuniWidget = class {
|
|
|
5360
7477
|
}
|
|
5361
7478
|
this.api.getComment(id).then((found) => {
|
|
5362
7479
|
if (!found || found.page.path !== this.pageKey) {
|
|
5363
|
-
this.showToast("
|
|
7480
|
+
this.showToast(this.t("linkGone"));
|
|
5364
7481
|
return;
|
|
5365
7482
|
}
|
|
5366
7483
|
this.extraComment = found;
|
|
5367
7484
|
this.comments = [...this.comments.filter((c) => c._id !== found._id), found];
|
|
5368
|
-
this.resolvePlacements(false);
|
|
7485
|
+
this.resolvePlacements(false, found._id);
|
|
5369
7486
|
this.focusDeepLinked(found);
|
|
5370
7487
|
});
|
|
5371
7488
|
}
|
|
@@ -5377,11 +7494,7 @@ var NuniWidget = class {
|
|
|
5377
7494
|
block: "center",
|
|
5378
7495
|
behavior: "smooth"
|
|
5379
7496
|
});
|
|
5380
|
-
this.
|
|
5381
|
-
this.card = {
|
|
5382
|
-
kind: "thread",
|
|
5383
|
-
id
|
|
5384
|
-
};
|
|
7497
|
+
this.openThread(id);
|
|
5385
7498
|
if (window.matchMedia(MOBILE_QUERY).matches) this.panelOpen = false;
|
|
5386
7499
|
this.render();
|
|
5387
7500
|
setTimeout(() => this.scheduleLayout(), 400);
|
|
@@ -5389,15 +7502,16 @@ var NuniWidget = class {
|
|
|
5389
7502
|
async submitDraft(draft, body, name) {
|
|
5390
7503
|
const cleanName = name.trim().slice(0, LIMITS.nameMaxLength);
|
|
5391
7504
|
const cleanBody = body.trim();
|
|
5392
|
-
if (!cleanName) return this.setDraftError(draft, "
|
|
5393
|
-
if (!cleanBody) return this.setDraftError(draft, "
|
|
5394
|
-
if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft,
|
|
7505
|
+
if (!cleanName) return this.setDraftError(draft, this.t("enterName"));
|
|
7506
|
+
if (!cleanBody) return this.setDraftError(draft, this.t("writeFirst"));
|
|
7507
|
+
if (cleanBody.length > LIMITS.bodyMaxLength) return this.setDraftError(draft, this.t("commentTooLong", { count: LIMITS.bodyMaxLength }));
|
|
5395
7508
|
this.name = cleanName;
|
|
5396
7509
|
write(KEYS.name, cleanName);
|
|
5397
7510
|
draft.sending = true;
|
|
5398
7511
|
draft.error = void 0;
|
|
5399
7512
|
this.render();
|
|
5400
7513
|
const loc = describeLocation(location.href);
|
|
7514
|
+
const shot = draft.screenshot;
|
|
5401
7515
|
try {
|
|
5402
7516
|
const id = await this.api.createComment({
|
|
5403
7517
|
body: cleanBody,
|
|
@@ -5413,17 +7527,20 @@ var NuniWidget = class {
|
|
|
5413
7527
|
w: window.innerWidth,
|
|
5414
7528
|
h: window.innerHeight,
|
|
5415
7529
|
dpr: window.devicePixelRatio || 1
|
|
5416
|
-
}
|
|
7530
|
+
},
|
|
7531
|
+
context: this.collectContext(draft)
|
|
5417
7532
|
});
|
|
7533
|
+
if (shot) this.attachScreenshot(id, shot.blob);
|
|
5418
7534
|
this.placements.set(id, {
|
|
5419
7535
|
element: draft.element,
|
|
5420
|
-
confidence: "exact"
|
|
7536
|
+
confidence: "exact",
|
|
7537
|
+
range: draft.range
|
|
5421
7538
|
});
|
|
5422
7539
|
if (this.card?.kind === "composer" && this.card.draft === draft) this.card = null;
|
|
5423
|
-
this.showToast("
|
|
7540
|
+
this.showToast(this.t("added"));
|
|
5424
7541
|
} catch (error) {
|
|
5425
7542
|
draft.sending = false;
|
|
5426
|
-
draft.error = error instanceof NuniApiError ? error.message :
|
|
7543
|
+
draft.error = error instanceof NuniApiError ? error.message : this.t("postFailed");
|
|
5427
7544
|
}
|
|
5428
7545
|
this.render();
|
|
5429
7546
|
}
|
|
@@ -5451,7 +7568,7 @@ var NuniWidget = class {
|
|
|
5451
7568
|
} catch (error) {
|
|
5452
7569
|
if (this.card?.kind === "thread" && this.card.id === id) {
|
|
5453
7570
|
this.card.busy = false;
|
|
5454
|
-
this.card.error = error instanceof Error ? error.message : "
|
|
7571
|
+
this.card.error = error instanceof Error ? error.message : this.t("failed");
|
|
5455
7572
|
}
|
|
5456
7573
|
this.render();
|
|
5457
7574
|
return false;
|
|
@@ -5460,21 +7577,21 @@ var NuniWidget = class {
|
|
|
5460
7577
|
resolveComment(c) {
|
|
5461
7578
|
const token = this.ownerToken;
|
|
5462
7579
|
if (!token) return;
|
|
5463
|
-
this.act(c._id, () => this.api.resolve(c._id, token), "
|
|
7580
|
+
this.act(c._id, () => this.api.resolve(c._id, token), this.t("resolved")).then((ok) => {
|
|
5464
7581
|
if (ok && !(this.panelOpen && this.tab === "resolved") && this.card?.kind === "thread" && this.card.id === c._id) this.closeCard();
|
|
5465
7582
|
});
|
|
5466
7583
|
}
|
|
5467
7584
|
reopenComment(c) {
|
|
5468
7585
|
const token = this.ownerToken;
|
|
5469
7586
|
if (!token) return;
|
|
5470
|
-
this.act(c._id, () => this.api.reopen(c._id, token), "
|
|
7587
|
+
this.act(c._id, () => this.api.reopen(c._id, token), this.t("reopened"));
|
|
5471
7588
|
}
|
|
5472
|
-
deleteComment(c) {
|
|
5473
|
-
if (!
|
|
7589
|
+
async deleteComment(c) {
|
|
7590
|
+
if (!await this.ask(this.t("confirmDeleteComment"), this.t("delete"))) return;
|
|
5474
7591
|
const token = this.ownerToken;
|
|
5475
7592
|
const action = this.isMine(c) ? () => this.api.deleteOwn(c._id, this.secret) : token ? () => this.api.remove(c._id, token) : null;
|
|
5476
7593
|
if (!action) return;
|
|
5477
|
-
this.act(c._id, action, "
|
|
7594
|
+
this.act(c._id, action, this.t("deleted")).then((ok) => {
|
|
5478
7595
|
if (!ok) return;
|
|
5479
7596
|
if (this.extraComment?._id === c._id) this.extraComment = null;
|
|
5480
7597
|
this.comments = this.comments.filter((x) => x._id !== c._id);
|
|
@@ -5482,8 +7599,72 @@ var NuniWidget = class {
|
|
|
5482
7599
|
this.closeCard();
|
|
5483
7600
|
});
|
|
5484
7601
|
}
|
|
7602
|
+
isMyReply(reply) {
|
|
7603
|
+
return Boolean(this.secretHash) && reply.authorKeyHash === this.secretHash;
|
|
7604
|
+
}
|
|
7605
|
+
async sendReply(comment, body, name) {
|
|
7606
|
+
const card = this.card;
|
|
7607
|
+
if (card?.kind !== "thread" || card.id !== comment._id) return;
|
|
7608
|
+
const authorName = (this.isOwner ? this.ownerName : name)?.trim().slice(0, LIMITS.nameMaxLength);
|
|
7609
|
+
const clean = body.trim();
|
|
7610
|
+
if (!clean) return;
|
|
7611
|
+
if (!authorName) {
|
|
7612
|
+
card.error = this.t("enterName");
|
|
7613
|
+
this.render();
|
|
7614
|
+
return;
|
|
7615
|
+
}
|
|
7616
|
+
if (clean.length > LIMITS.bodyMaxLength) {
|
|
7617
|
+
card.error = this.t("replyTooLong", { count: LIMITS.bodyMaxLength });
|
|
7618
|
+
this.render();
|
|
7619
|
+
return;
|
|
7620
|
+
}
|
|
7621
|
+
if (!this.isOwner) {
|
|
7622
|
+
this.name = authorName;
|
|
7623
|
+
write(KEYS.name, authorName);
|
|
7624
|
+
}
|
|
7625
|
+
card.sendingReply = true;
|
|
7626
|
+
card.error = void 0;
|
|
7627
|
+
this.render();
|
|
7628
|
+
try {
|
|
7629
|
+
await this.api.createReply({
|
|
7630
|
+
commentId: comment._id,
|
|
7631
|
+
body: clean,
|
|
7632
|
+
authorName,
|
|
7633
|
+
authorSecret: this.secret,
|
|
7634
|
+
sessionToken: this.ownerToken ?? void 0
|
|
7635
|
+
});
|
|
7636
|
+
const box = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
7637
|
+
if (box) box.value = "";
|
|
7638
|
+
} catch (error) {
|
|
7639
|
+
card.error = error instanceof NuniApiError ? error.message : this.t("replyFailed");
|
|
7640
|
+
}
|
|
7641
|
+
card.sendingReply = false;
|
|
7642
|
+
this.render();
|
|
7643
|
+
}
|
|
7644
|
+
toggleReaction(comment, targetId, emoji) {
|
|
7645
|
+
this.api.toggleReaction(comment._id, targetId, emoji, this.secret).catch((error) => {
|
|
7646
|
+
if (this.card?.kind === "thread" && this.card.id === comment._id) {
|
|
7647
|
+
this.card.error = error instanceof Error ? error.message : this.t("reactFailed");
|
|
7648
|
+
this.render();
|
|
7649
|
+
}
|
|
7650
|
+
});
|
|
7651
|
+
}
|
|
7652
|
+
async deleteReply(comment, reply) {
|
|
7653
|
+
if (!await this.ask(this.t("confirmDeleteReply"), this.t("delete"))) return;
|
|
7654
|
+
const token = this.ownerToken;
|
|
7655
|
+
const action = this.isMyReply(reply) ? () => this.api.deleteReply(reply._id, this.secret) : token ? () => this.api.removeReply(reply._id, token) : null;
|
|
7656
|
+
if (action) this.act(comment._id, action, this.t("replyDeleted"));
|
|
7657
|
+
}
|
|
7658
|
+
saveReplyEdit(comment, reply, body) {
|
|
7659
|
+
this.act(comment._id, () => this.api.editReply(reply._id, this.secret, body), this.t("saved")).then((ok) => {
|
|
7660
|
+
if (ok && this.card?.kind === "thread") {
|
|
7661
|
+
this.card.replyEditing = void 0;
|
|
7662
|
+
this.render();
|
|
7663
|
+
}
|
|
7664
|
+
});
|
|
7665
|
+
}
|
|
5485
7666
|
saveEdit(c, body) {
|
|
5486
|
-
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), "
|
|
7667
|
+
this.act(c._id, () => this.api.editOwn(c._id, this.secret, body), this.t("saved"));
|
|
5487
7668
|
}
|
|
5488
7669
|
showToast(message) {
|
|
5489
7670
|
this.toast = message;
|
|
@@ -5495,16 +7676,36 @@ var NuniWidget = class {
|
|
|
5495
7676
|
this.render();
|
|
5496
7677
|
}
|
|
5497
7678
|
togglePanel(open = !this.panelOpen) {
|
|
7679
|
+
if (open === this.panelOpen) return;
|
|
5498
7680
|
this.panelOpen = open;
|
|
5499
|
-
if (open
|
|
7681
|
+
if (open) {
|
|
7682
|
+
this.keepFocus();
|
|
7683
|
+
this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
|
|
7684
|
+
if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
|
|
7685
|
+
}
|
|
5500
7686
|
this.render();
|
|
7687
|
+
if (!open && !this.card) this.restoreFocus();
|
|
5501
7688
|
}
|
|
5502
7689
|
closeCard() {
|
|
7690
|
+
const had = this.card !== null;
|
|
5503
7691
|
this.card = null;
|
|
5504
7692
|
this.activeId = null;
|
|
5505
7693
|
this.render();
|
|
7694
|
+
if (had && !this.panelOpen) this.restoreFocus();
|
|
7695
|
+
}
|
|
7696
|
+
/** Open a comment's thread and move focus into it. */
|
|
7697
|
+
openThread(id) {
|
|
7698
|
+
this.keepFocus();
|
|
7699
|
+
this.activeId = id;
|
|
7700
|
+
this.card = {
|
|
7701
|
+
kind: "thread",
|
|
7702
|
+
id
|
|
7703
|
+
};
|
|
7704
|
+
this.focusNext = "[data-card=\"thread\"] .card-head button";
|
|
5506
7705
|
}
|
|
5507
7706
|
render() {
|
|
7707
|
+
this.syncOwnerDetail();
|
|
7708
|
+
this.syncThread();
|
|
5508
7709
|
this.renderPins();
|
|
5509
7710
|
this.renderUi();
|
|
5510
7711
|
this.renderHighlight();
|
|
@@ -5530,11 +7731,7 @@ var NuniWidget = class {
|
|
|
5530
7731
|
e.stopPropagation();
|
|
5531
7732
|
if (this.card?.kind === "thread" && this.card.id === id) this.closeCard();
|
|
5532
7733
|
else {
|
|
5533
|
-
this.
|
|
5534
|
-
this.card = {
|
|
5535
|
-
kind: "thread",
|
|
5536
|
-
id
|
|
5537
|
-
};
|
|
7734
|
+
this.openThread(id);
|
|
5538
7735
|
this.render();
|
|
5539
7736
|
}
|
|
5540
7737
|
});
|
|
@@ -5543,7 +7740,12 @@ var NuniWidget = class {
|
|
|
5543
7740
|
}
|
|
5544
7741
|
pin.textContent = initials(comment.authorName);
|
|
5545
7742
|
pin.style.background = colorFor(comment.authorName);
|
|
5546
|
-
pin.setAttribute("aria-label",
|
|
7743
|
+
pin.setAttribute("aria-label", this.t("pinLabel", {
|
|
7744
|
+
name: comment.authorName,
|
|
7745
|
+
body: comment.body.slice(0, 80)
|
|
7746
|
+
}));
|
|
7747
|
+
if (comment.replyCount) pin.dataset.replies = String(comment.replyCount);
|
|
7748
|
+
else delete pin.dataset.replies;
|
|
5547
7749
|
pin.dataset.status = comment.status;
|
|
5548
7750
|
pin.dataset.active = String(this.activeId === comment._id);
|
|
5549
7751
|
pin.dataset.confidence = this.placements.get(comment._id)?.confidence ?? "lost";
|
|
@@ -5551,9 +7753,7 @@ var NuniWidget = class {
|
|
|
5551
7753
|
this.pinLayer.querySelector(".pin-draft")?.remove();
|
|
5552
7754
|
if (this.card?.kind === "composer") {
|
|
5553
7755
|
const { draft } = this.card;
|
|
5554
|
-
const
|
|
5555
|
-
const x = r.left + draft.anchor.offset.x * r.width;
|
|
5556
|
-
const y = r.top + draft.anchor.offset.y * r.height;
|
|
7756
|
+
const { x, y } = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
5557
7757
|
const pin = h("div", {
|
|
5558
7758
|
class: "pin pin-draft pin-pending",
|
|
5559
7759
|
"aria-hidden": "true"
|
|
@@ -5572,6 +7772,8 @@ var NuniWidget = class {
|
|
|
5572
7772
|
class: "toast",
|
|
5573
7773
|
role: "status"
|
|
5574
7774
|
}, this.toast));
|
|
7775
|
+
const confirm = this.renderConfirm();
|
|
7776
|
+
if (confirm) nodes.push(confirm);
|
|
5575
7777
|
const focused = this.root.activeElement;
|
|
5576
7778
|
const focusKey = focused?.dataset.focusKey;
|
|
5577
7779
|
const selection = focused instanceof HTMLTextAreaElement || focused instanceof HTMLInputElement ? {
|
|
@@ -5579,7 +7781,11 @@ var NuniWidget = class {
|
|
|
5579
7781
|
end: focused.selectionEnd
|
|
5580
7782
|
} : null;
|
|
5581
7783
|
this.uiLayer.replaceChildren(...nodes);
|
|
5582
|
-
|
|
7784
|
+
const next = this.focusNext;
|
|
7785
|
+
this.focusNext = null;
|
|
7786
|
+
const wanted = next ? this.uiLayer.querySelector(next) : null;
|
|
7787
|
+
if (wanted) wanted.focus();
|
|
7788
|
+
else if (focusKey) {
|
|
5583
7789
|
const next = this.uiLayer.querySelector(`[data-focus-key="${focusKey}"]`);
|
|
5584
7790
|
next?.focus();
|
|
5585
7791
|
if (selection && (next instanceof HTMLTextAreaElement || next instanceof HTMLInputElement)) next.setSelectionRange(selection.start, selection.end);
|
|
@@ -5591,20 +7797,22 @@ var NuniWidget = class {
|
|
|
5591
7797
|
return h("div", {
|
|
5592
7798
|
class: "toolbar",
|
|
5593
7799
|
role: "toolbar",
|
|
5594
|
-
"aria-label": "
|
|
7800
|
+
"aria-label": this.t("toolbar")
|
|
5595
7801
|
}, h("button", {
|
|
5596
7802
|
class: "tb-btn",
|
|
5597
7803
|
type: "button",
|
|
5598
7804
|
"aria-pressed": String(this.picking),
|
|
5599
|
-
"
|
|
5600
|
-
|
|
7805
|
+
"data-focus-key": "tb-comment",
|
|
7806
|
+
"aria-label": this.picking ? this.t("cancelAdding") : this.t("addComment"),
|
|
7807
|
+
title: this.withKey("addComment", "addCommentKey"),
|
|
5601
7808
|
onclick: () => this.setPicking(!this.picking)
|
|
5602
|
-
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? "
|
|
7809
|
+
}, icon(ICONS.comment), h("span", { class: "tb-label" }, this.picking ? this.t("pickElement") : this.config.label ?? this.t("comment"))), h("button", {
|
|
5603
7810
|
class: "tb-btn",
|
|
5604
7811
|
type: "button",
|
|
5605
7812
|
"aria-pressed": String(this.panelOpen),
|
|
5606
|
-
"
|
|
5607
|
-
|
|
7813
|
+
"data-focus-key": "tb-panel",
|
|
7814
|
+
"aria-label": this.t("openCount", { count: open }),
|
|
7815
|
+
title: this.t("allComments"),
|
|
5608
7816
|
onclick: () => this.togglePanel()
|
|
5609
7817
|
}, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
|
|
5610
7818
|
}
|
|
@@ -5618,72 +7826,87 @@ var NuniWidget = class {
|
|
|
5618
7826
|
const item = (c) => h("button", {
|
|
5619
7827
|
class: "item",
|
|
5620
7828
|
type: "button",
|
|
7829
|
+
"data-focus-key": `item-${c._id}`,
|
|
5621
7830
|
onclick: () => this.focusComment(c._id)
|
|
5622
7831
|
}, h("div", { class: "row" }, h("span", {
|
|
5623
7832
|
class: "avatar",
|
|
5624
7833
|
style: `background:${colorFor(c.authorName)}`
|
|
5625
|
-
}, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, timeAgo(c.createdAt)), c.page.origin !== location.origin ? h("span", {
|
|
7834
|
+
}, initials(c.authorName)), h("span", { class: "author" }, c.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(c.createdAt) + (c.replyCount ? ` · ${this.t("replyCount", { count: c.replyCount })}` : "")), c.page.origin !== location.origin ? h("span", {
|
|
5626
7835
|
class: "badge",
|
|
5627
7836
|
title: c.page.origin
|
|
5628
|
-
}, new URL(c.page.origin).host) : null), h("div", { class: "item-text" }, c.body));
|
|
7837
|
+
}, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
|
|
5629
7838
|
const children = [];
|
|
5630
|
-
if (!this.loaded) children.push(h("div", { class: "empty" }, "
|
|
5631
|
-
else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ?
|
|
7839
|
+
if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
|
|
7840
|
+
else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
|
|
5632
7841
|
else {
|
|
5633
7842
|
children.push(...placed.map(item));
|
|
5634
7843
|
if (lost.length) {
|
|
5635
|
-
children.push(h("div", { class: "section-label" },
|
|
7844
|
+
children.push(h("div", { class: "section-label" }, this.t("notFoundHere")));
|
|
5636
7845
|
children.push(...lost.map(item));
|
|
5637
7846
|
}
|
|
5638
7847
|
}
|
|
5639
7848
|
if (otherPages.length) {
|
|
5640
|
-
children.push(h("div", { class: "section-label" }, "
|
|
7849
|
+
children.push(h("div", { class: "section-label" }, this.t("otherPages")));
|
|
5641
7850
|
for (const p of otherPages.slice(0, 20)) {
|
|
5642
|
-
const href = p.path
|
|
7851
|
+
const href = /[?#]/.test(p.path) ? location.origin + p.path : location.origin + p.path + location.search;
|
|
5643
7852
|
children.push(h("a", {
|
|
5644
7853
|
class: "item",
|
|
5645
7854
|
href
|
|
5646
|
-
}, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" },
|
|
7855
|
+
}, h("div", { class: "row" }, h("span", { class: "author" }, p.path), h("span", { class: "spacer" }), h("span", { class: "badge" }, this.t("openOnPage", { count: p.count })))));
|
|
5647
7856
|
}
|
|
5648
7857
|
}
|
|
5649
7858
|
return h("div", {
|
|
5650
7859
|
class: "panel",
|
|
5651
7860
|
role: "dialog",
|
|
5652
|
-
"aria-label": "
|
|
5653
|
-
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, "
|
|
7861
|
+
"aria-label": this.t("comments")
|
|
7862
|
+
}, h("div", { class: "panel-head" }, h("div", { class: "panel-title" }, this.t("comments")), h("button", {
|
|
5654
7863
|
class: "btn btn-ghost btn-icon",
|
|
5655
7864
|
type: "button",
|
|
5656
|
-
"aria-label": "
|
|
7865
|
+
"aria-label": this.t("close"),
|
|
5657
7866
|
onclick: () => this.togglePanel(false)
|
|
5658
7867
|
}, icon(ICONS.close))), h("div", {
|
|
5659
7868
|
class: "tabs",
|
|
5660
|
-
role: "tablist"
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
7869
|
+
role: "tablist",
|
|
7870
|
+
"aria-label": this.t("comments"),
|
|
7871
|
+
onkeydown: (e) => {
|
|
7872
|
+
const key = e.key;
|
|
7873
|
+
if (![
|
|
7874
|
+
"ArrowLeft",
|
|
7875
|
+
"ArrowRight",
|
|
7876
|
+
"Home",
|
|
7877
|
+
"End"
|
|
7878
|
+
].includes(key)) return;
|
|
7879
|
+
e.preventDefault();
|
|
7880
|
+
this.tab = key === "Home" ? "open" : key === "End" ? "resolved" : this.tab === "open" ? "resolved" : "open";
|
|
7881
|
+
this.focusNext = `[data-focus-key="tab-${this.tab}"]`;
|
|
5668
7882
|
this.render();
|
|
5669
7883
|
}
|
|
5670
|
-
},
|
|
7884
|
+
}, ["open", "resolved"].map((tab) => h("button", {
|
|
5671
7885
|
class: "tab",
|
|
5672
7886
|
role: "tab",
|
|
5673
7887
|
type: "button",
|
|
5674
|
-
|
|
7888
|
+
id: `nuni-tab-${tab}`,
|
|
7889
|
+
"data-focus-key": `tab-${tab}`,
|
|
7890
|
+
"aria-selected": String(this.tab === tab),
|
|
7891
|
+
"aria-controls": "nuni-panel-list",
|
|
7892
|
+
tabindex: this.tab === tab ? 0 : -1,
|
|
5675
7893
|
onclick: () => {
|
|
5676
|
-
this.tab =
|
|
7894
|
+
this.tab = tab;
|
|
5677
7895
|
this.render();
|
|
5678
7896
|
}
|
|
5679
|
-
},
|
|
7897
|
+
}, tab === "open" ? this.t("tabOpen", { count: open.length }) : this.t("tabResolved", { count: resolved.length })))), h("div", {
|
|
7898
|
+
class: "panel-list",
|
|
7899
|
+
id: "nuni-panel-list",
|
|
7900
|
+
role: "tabpanel",
|
|
7901
|
+
"aria-labelledby": `nuni-tab-${this.tab}`
|
|
7902
|
+
}, ...children), this.status && !this.status.claimed && !this.isOwner ? this.renderPanelFooter() : null);
|
|
5680
7903
|
}
|
|
5681
7904
|
renderPanelFooter() {
|
|
5682
|
-
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" },
|
|
7905
|
+
return h("div", { class: "panel-foot" }, h("div", { class: "row" }, h("span", { class: "meta" }, this.t("ownSite")), h("span", { class: "spacer" }), h("button", {
|
|
5683
7906
|
class: "btn",
|
|
5684
7907
|
type: "button",
|
|
5685
7908
|
onclick: () => this.openClaim()
|
|
5686
|
-
}, icon(ICONS.github), "
|
|
7909
|
+
}, icon(ICONS.github), this.t("claim"))));
|
|
5687
7910
|
}
|
|
5688
7911
|
renderCard() {
|
|
5689
7912
|
const card = this.card;
|
|
@@ -5694,19 +7917,19 @@ var NuniWidget = class {
|
|
|
5694
7917
|
const needsName = !this.name;
|
|
5695
7918
|
const nameInput = h("input", {
|
|
5696
7919
|
class: "field",
|
|
5697
|
-
placeholder: "
|
|
7920
|
+
placeholder: this.t("yourName"),
|
|
5698
7921
|
value: this.name,
|
|
5699
7922
|
maxlength: LIMITS.nameMaxLength,
|
|
5700
7923
|
autocomplete: "name",
|
|
5701
7924
|
"data-focus-key": "name",
|
|
5702
|
-
"aria-label": "
|
|
7925
|
+
"aria-label": this.t("yourName")
|
|
5703
7926
|
});
|
|
5704
7927
|
const textarea = h("textarea", {
|
|
5705
7928
|
class: "field",
|
|
5706
|
-
placeholder: "
|
|
7929
|
+
placeholder: this.t("leaveComment"),
|
|
5707
7930
|
maxlength: LIMITS.bodyMaxLength,
|
|
5708
7931
|
"data-focus-key": "body",
|
|
5709
|
-
"aria-label": "
|
|
7932
|
+
"aria-label": this.t("comment")
|
|
5710
7933
|
});
|
|
5711
7934
|
const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
|
|
5712
7935
|
if (prev) textarea.value = prev.value;
|
|
@@ -5736,18 +7959,28 @@ var NuniWidget = class {
|
|
|
5736
7959
|
this.name = "";
|
|
5737
7960
|
this.render();
|
|
5738
7961
|
}
|
|
5739
|
-
}, "
|
|
7962
|
+
}, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
|
|
7963
|
+
src: draft.screenshot.preview,
|
|
7964
|
+
alt: this.t("screenshotAlt")
|
|
7965
|
+
}), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
|
|
7966
|
+
class: "link",
|
|
7967
|
+
type: "button",
|
|
7968
|
+
onclick: () => {
|
|
7969
|
+
draft.screenshot = void 0;
|
|
7970
|
+
this.render();
|
|
7971
|
+
}
|
|
7972
|
+
}, this.t("remove")))) : null, draft.error ? h("div", {
|
|
5740
7973
|
class: "error",
|
|
5741
7974
|
role: "alert"
|
|
5742
|
-
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "
|
|
7975
|
+
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, this.t("send", { key: this.i18n.mod })), h("span", { class: "spacer" }), h("button", {
|
|
5743
7976
|
class: "btn btn-ghost",
|
|
5744
7977
|
type: "button",
|
|
5745
7978
|
onclick: () => this.closeCard()
|
|
5746
|
-
}, "
|
|
7979
|
+
}, this.t("cancel")), h("button", {
|
|
5747
7980
|
class: "btn btn-primary",
|
|
5748
7981
|
type: "submit",
|
|
5749
7982
|
disabled: Boolean(draft.sending)
|
|
5750
|
-
}, icon(ICONS.send), draft.sending ? "
|
|
7983
|
+
}, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
|
|
5751
7984
|
}
|
|
5752
7985
|
const comment = this.comments.find((c) => c._id === card.id);
|
|
5753
7986
|
if (!comment) return null;
|
|
@@ -5770,30 +8003,39 @@ var NuniWidget = class {
|
|
|
5770
8003
|
card.editing = false;
|
|
5771
8004
|
this.render();
|
|
5772
8005
|
}
|
|
5773
|
-
}, "
|
|
8006
|
+
}, this.t("cancel")), h("button", {
|
|
5774
8007
|
class: "btn btn-primary",
|
|
5775
8008
|
type: "button",
|
|
5776
8009
|
disabled: Boolean(card.busy),
|
|
5777
8010
|
onclick: () => this.saveEdit(comment, textarea.value)
|
|
5778
|
-
}, "
|
|
8011
|
+
}, this.t("save"))));
|
|
5779
8012
|
} else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
|
|
5780
8013
|
class: "avatar",
|
|
5781
8014
|
style: `background:${colorFor(comment.authorName)}`
|
|
5782
|
-
}, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, timeAgo(comment.createdAt) + (comment.editedAt ?
|
|
8015
|
+
}, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("button", {
|
|
5783
8016
|
class: "btn btn-ghost btn-icon",
|
|
5784
8017
|
type: "button",
|
|
5785
|
-
"aria-label": "
|
|
8018
|
+
"aria-label": this.t("close"),
|
|
5786
8019
|
onclick: () => this.closeCard()
|
|
5787
|
-
}, icon(ICONS.close))), h("
|
|
8020
|
+
}, icon(ICONS.close))), comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
|
|
8021
|
+
class: "shot",
|
|
8022
|
+
href: this.ownerDetail.screenshotUrl,
|
|
8023
|
+
target: "_blank",
|
|
8024
|
+
rel: "noreferrer",
|
|
8025
|
+
title: this.t("openScreenshot")
|
|
8026
|
+
}, h("img", {
|
|
8027
|
+
src: this.ownerDetail.screenshotUrl,
|
|
8028
|
+
alt: this.t("screenshotTaken")
|
|
8029
|
+
})) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null, comment.page.origin !== location.origin ? h("span", {
|
|
5788
8030
|
class: "badge",
|
|
5789
|
-
title:
|
|
8031
|
+
title: this.t("leftOn", { origin: comment.page.origin })
|
|
5790
8032
|
}, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
|
|
5791
8033
|
class: "badge",
|
|
5792
|
-
title:
|
|
5793
|
-
}, "
|
|
8034
|
+
title: this.t("approximateHint")
|
|
8035
|
+
}, this.t("approximate")) : null, !placement?.element ? h("span", {
|
|
5794
8036
|
class: "badge",
|
|
5795
|
-
title:
|
|
5796
|
-
}, "
|
|
8037
|
+
title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
|
|
8038
|
+
}, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
|
|
5797
8039
|
class: "error",
|
|
5798
8040
|
role: "alert"
|
|
5799
8041
|
}, card.error) : null);
|
|
@@ -5804,18 +8046,27 @@ var NuniWidget = class {
|
|
|
5804
8046
|
type: "button",
|
|
5805
8047
|
disabled: Boolean(card.busy),
|
|
5806
8048
|
onclick: () => this.resolveComment(comment)
|
|
5807
|
-
}, icon(ICONS.check), "
|
|
8049
|
+
}, icon(ICONS.check), this.t("resolve")) : h("button", {
|
|
5808
8050
|
class: "btn",
|
|
5809
8051
|
type: "button",
|
|
5810
8052
|
disabled: Boolean(card.busy),
|
|
5811
8053
|
onclick: () => this.reopenComment(comment)
|
|
5812
|
-
}, icon(ICONS.undo), "
|
|
8054
|
+
}, icon(ICONS.undo), this.t("reopen")));
|
|
5813
8055
|
actions.push(h("span", { class: "spacer" }));
|
|
8056
|
+
const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
|
|
8057
|
+
actions.push(h("button", {
|
|
8058
|
+
class: "btn btn-ghost btn-icon",
|
|
8059
|
+
type: "button",
|
|
8060
|
+
"aria-label": this.t("copyForAgent"),
|
|
8061
|
+
title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
|
|
8062
|
+
disabled: contextPending,
|
|
8063
|
+
onclick: () => this.copyForAgent(comment)
|
|
8064
|
+
}, icon(ICONS.bot)));
|
|
5814
8065
|
if (mine) actions.push(h("button", {
|
|
5815
8066
|
class: "btn btn-ghost btn-icon",
|
|
5816
8067
|
type: "button",
|
|
5817
|
-
"aria-label": "
|
|
5818
|
-
title: "
|
|
8068
|
+
"aria-label": this.t("edit"),
|
|
8069
|
+
title: this.t("edit"),
|
|
5819
8070
|
onclick: () => {
|
|
5820
8071
|
card.editing = true;
|
|
5821
8072
|
this.render();
|
|
@@ -5824,18 +8075,164 @@ var NuniWidget = class {
|
|
|
5824
8075
|
if (mine || owner) actions.push(h("button", {
|
|
5825
8076
|
class: "btn btn-ghost btn-icon btn-danger",
|
|
5826
8077
|
type: "button",
|
|
5827
|
-
"aria-label": "
|
|
5828
|
-
title: "
|
|
8078
|
+
"aria-label": this.t("delete"),
|
|
8079
|
+
title: this.t("delete"),
|
|
8080
|
+
"data-focus-key": "delete",
|
|
5829
8081
|
disabled: Boolean(card.busy),
|
|
5830
|
-
onclick: () => this.deleteComment(comment)
|
|
8082
|
+
onclick: () => void this.deleteComment(comment)
|
|
5831
8083
|
}, icon(ICONS.trash)));
|
|
5832
8084
|
}
|
|
5833
8085
|
return h("div", {
|
|
5834
8086
|
class: cls,
|
|
5835
8087
|
"data-card": "thread",
|
|
5836
8088
|
role: "dialog",
|
|
5837
|
-
"aria-label":
|
|
5838
|
-
}, body, actions.length
|
|
8089
|
+
"aria-label": this.t("commentBy", { name: comment.authorName })
|
|
8090
|
+
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
|
|
8091
|
+
}
|
|
8092
|
+
/** Emoji chips for one message, plus a picker to add one. */
|
|
8093
|
+
renderReactions(comment, targetId) {
|
|
8094
|
+
const card = this.card;
|
|
8095
|
+
if (card?.kind !== "thread") return null;
|
|
8096
|
+
const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
|
|
8097
|
+
const open = card.picker === targetId;
|
|
8098
|
+
return h("div", { class: "reactions" }, ...summaries.map((r) => {
|
|
8099
|
+
const mine = r.authorKeyHashes.includes(this.secretHash);
|
|
8100
|
+
return h("button", {
|
|
8101
|
+
class: "reaction",
|
|
8102
|
+
type: "button",
|
|
8103
|
+
"aria-pressed": String(mine),
|
|
8104
|
+
"aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
|
|
8105
|
+
onclick: () => this.toggleReaction(comment, targetId, r.emoji)
|
|
8106
|
+
}, `${r.emoji} ${r.count}`);
|
|
8107
|
+
}), h("button", {
|
|
8108
|
+
class: "reaction reaction-add",
|
|
8109
|
+
type: "button",
|
|
8110
|
+
"aria-label": this.t("addReaction"),
|
|
8111
|
+
"aria-expanded": String(open),
|
|
8112
|
+
title: this.t("addReaction"),
|
|
8113
|
+
onclick: () => {
|
|
8114
|
+
card.picker = open ? null : targetId;
|
|
8115
|
+
this.render();
|
|
8116
|
+
}
|
|
8117
|
+
}, icon(ICONS.smile)), open ? h("div", {
|
|
8118
|
+
class: "reaction-picker",
|
|
8119
|
+
role: "group",
|
|
8120
|
+
"aria-label": this.t("reactions")
|
|
8121
|
+
}, ...REACTIONS.map((emoji) => h("button", {
|
|
8122
|
+
class: "reaction",
|
|
8123
|
+
type: "button",
|
|
8124
|
+
"aria-label": this.t("reactWith", { emoji }),
|
|
8125
|
+
onclick: () => {
|
|
8126
|
+
card.picker = null;
|
|
8127
|
+
this.toggleReaction(comment, targetId, emoji);
|
|
8128
|
+
this.render();
|
|
8129
|
+
}
|
|
8130
|
+
}, emoji))) : null);
|
|
8131
|
+
}
|
|
8132
|
+
/** The replies under a comment and the box to add one. */
|
|
8133
|
+
renderThread(comment) {
|
|
8134
|
+
const card = this.card;
|
|
8135
|
+
if (card?.kind !== "thread") return null;
|
|
8136
|
+
const replies = this.threadId === comment._id ? this.thread?.replies ?? [] : [];
|
|
8137
|
+
const owner = this.isOwner;
|
|
8138
|
+
const items = replies.map((reply) => {
|
|
8139
|
+
const mine = this.isMyReply(reply);
|
|
8140
|
+
if (card.replyEditing === reply._id) {
|
|
8141
|
+
const box = h("textarea", {
|
|
8142
|
+
class: "field",
|
|
8143
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8144
|
+
"data-focus-key": `reply-edit-${reply._id}`,
|
|
8145
|
+
"aria-label": this.t("editReply")
|
|
8146
|
+
});
|
|
8147
|
+
const prev = this.uiLayer.querySelector(`[data-focus-key="reply-edit-${reply._id}"]`);
|
|
8148
|
+
box.value = prev ? prev.value : reply.body;
|
|
8149
|
+
return h("div", { class: "reply" }, box, h("div", { class: "row" }, h("span", { class: "spacer" }), h("button", {
|
|
8150
|
+
class: "btn btn-ghost",
|
|
8151
|
+
type: "button",
|
|
8152
|
+
onclick: () => {
|
|
8153
|
+
card.replyEditing = void 0;
|
|
8154
|
+
this.render();
|
|
8155
|
+
}
|
|
8156
|
+
}, this.t("cancel")), h("button", {
|
|
8157
|
+
class: "btn btn-primary",
|
|
8158
|
+
type: "button",
|
|
8159
|
+
disabled: Boolean(card.busy),
|
|
8160
|
+
onclick: () => this.saveReplyEdit(comment, reply, box.value)
|
|
8161
|
+
}, this.t("save"))));
|
|
8162
|
+
}
|
|
8163
|
+
return h("div", {
|
|
8164
|
+
class: "reply",
|
|
8165
|
+
"data-reply": reply._id
|
|
8166
|
+
}, h("div", { class: "card-head" }, h("span", {
|
|
8167
|
+
class: "avatar avatar-sm",
|
|
8168
|
+
style: `background:${colorFor(reply.authorName)}`
|
|
8169
|
+
}, initials(reply.authorName)), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), mine ? h("button", {
|
|
8170
|
+
class: "btn btn-ghost btn-icon btn-xs",
|
|
8171
|
+
type: "button",
|
|
8172
|
+
"aria-label": this.t("editReply"),
|
|
8173
|
+
title: this.t("edit"),
|
|
8174
|
+
onclick: () => {
|
|
8175
|
+
card.replyEditing = reply._id;
|
|
8176
|
+
this.render();
|
|
8177
|
+
}
|
|
8178
|
+
}, icon(ICONS.edit)) : null, mine || owner ? h("button", {
|
|
8179
|
+
class: "btn btn-ghost btn-icon btn-xs btn-danger",
|
|
8180
|
+
type: "button",
|
|
8181
|
+
"aria-label": this.t("deleteReply"),
|
|
8182
|
+
title: this.t("delete"),
|
|
8183
|
+
"data-focus-key": `delete-${reply._id}`,
|
|
8184
|
+
disabled: Boolean(card.busy),
|
|
8185
|
+
onclick: () => void this.deleteReply(comment, reply)
|
|
8186
|
+
}, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
|
|
8187
|
+
});
|
|
8188
|
+
const needsName = !owner && !this.name;
|
|
8189
|
+
const nameInput = h("input", {
|
|
8190
|
+
class: "field",
|
|
8191
|
+
placeholder: this.t("yourName"),
|
|
8192
|
+
value: this.name,
|
|
8193
|
+
maxlength: LIMITS.nameMaxLength,
|
|
8194
|
+
autocomplete: "name",
|
|
8195
|
+
"data-focus-key": "reply-name",
|
|
8196
|
+
"aria-label": this.t("yourName")
|
|
8197
|
+
});
|
|
8198
|
+
const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
|
|
8199
|
+
if (prevName) nameInput.value = prevName.value;
|
|
8200
|
+
const box = h("textarea", {
|
|
8201
|
+
class: "field field-reply",
|
|
8202
|
+
placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
|
|
8203
|
+
maxlength: LIMITS.bodyMaxLength,
|
|
8204
|
+
"data-focus-key": "reply",
|
|
8205
|
+
"aria-label": this.t("reply")
|
|
8206
|
+
});
|
|
8207
|
+
const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
|
|
8208
|
+
if (prev) box.value = prev.value;
|
|
8209
|
+
const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
|
|
8210
|
+
box.addEventListener("keydown", (e) => {
|
|
8211
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
8212
|
+
e.preventDefault();
|
|
8213
|
+
send();
|
|
8214
|
+
}
|
|
8215
|
+
});
|
|
8216
|
+
return h("div", { class: "thread" }, items.length ? h("div", {
|
|
8217
|
+
class: "replies",
|
|
8218
|
+
role: "list",
|
|
8219
|
+
"aria-label": this.t("replyCount", { count: items.length })
|
|
8220
|
+
}, ...items.map((item) => {
|
|
8221
|
+
item.setAttribute("role", "listitem");
|
|
8222
|
+
return item;
|
|
8223
|
+
})) : null, h("form", {
|
|
8224
|
+
class: "reply-form",
|
|
8225
|
+
onsubmit: (e) => {
|
|
8226
|
+
e.preventDefault();
|
|
8227
|
+
send();
|
|
8228
|
+
}
|
|
8229
|
+
}, needsName ? nameInput : null, box, h("button", {
|
|
8230
|
+
class: "btn btn-primary btn-icon",
|
|
8231
|
+
type: "submit",
|
|
8232
|
+
"aria-label": this.t("sendReply"),
|
|
8233
|
+
title: this.t("sendReply"),
|
|
8234
|
+
disabled: Boolean(card.sendingReply)
|
|
8235
|
+
}, icon(ICONS.send))));
|
|
5839
8236
|
}
|
|
5840
8237
|
positionCard() {
|
|
5841
8238
|
const el = this.uiLayer.querySelector("[data-card]");
|
|
@@ -5843,11 +8240,7 @@ var NuniWidget = class {
|
|
|
5843
8240
|
let point = null;
|
|
5844
8241
|
if (this.card?.kind === "composer") {
|
|
5845
8242
|
const { draft } = this.card;
|
|
5846
|
-
|
|
5847
|
-
point = {
|
|
5848
|
-
x: r.left + draft.anchor.offset.x * r.width,
|
|
5849
|
-
y: r.top + draft.anchor.offset.y * r.height
|
|
5850
|
-
};
|
|
8243
|
+
point = this.anchorPoint(draft.anchor, draft.element, draft.range);
|
|
5851
8244
|
} else if (this.card?.kind === "thread") {
|
|
5852
8245
|
const comment = this.comments.find((c) => this.card?.kind === "thread" && c._id === this.card.id);
|
|
5853
8246
|
point = comment ? this.pinPositions.get(comment._id) ?? this.pinPoint(comment) : null;
|
|
@@ -5863,8 +8256,9 @@ var NuniWidget = class {
|
|
|
5863
8256
|
top = point.y - 28;
|
|
5864
8257
|
if (left + width > vw - 12) left = point.x - width - 12;
|
|
5865
8258
|
} else {
|
|
5866
|
-
|
|
5867
|
-
|
|
8259
|
+
const { position } = this.config;
|
|
8260
|
+
left = position.endsWith("left") ? 16 : vw - width - 16;
|
|
8261
|
+
top = position.startsWith("top") ? 72 : vh - height - 72;
|
|
5868
8262
|
}
|
|
5869
8263
|
left = Math.max(12, Math.min(left, vw - width - 12));
|
|
5870
8264
|
top = Math.max(12, Math.min(top, vh - height - 12));
|
|
@@ -5878,15 +8272,27 @@ var NuniWidget = class {
|
|
|
5878
8272
|
}
|
|
5879
8273
|
};
|
|
5880
8274
|
//#endregion
|
|
5881
|
-
//#region src/
|
|
8275
|
+
//#region src/mount.ts
|
|
8276
|
+
/** Nuni's own requests are not the page's problem. */
|
|
8277
|
+
function isNuniUrl(config) {
|
|
8278
|
+
const origins = [config.convexUrl, config.convexSiteUrl].flatMap((url) => {
|
|
8279
|
+
try {
|
|
8280
|
+
return [new URL(url).origin];
|
|
8281
|
+
} catch {
|
|
8282
|
+
return [];
|
|
8283
|
+
}
|
|
8284
|
+
});
|
|
8285
|
+
return (url) => {
|
|
8286
|
+
try {
|
|
8287
|
+
return origins.includes(new URL(url, location.href).origin);
|
|
8288
|
+
} catch {
|
|
8289
|
+
return false;
|
|
8290
|
+
}
|
|
8291
|
+
};
|
|
8292
|
+
}
|
|
5882
8293
|
let active = null;
|
|
5883
|
-
/**
|
|
5884
|
-
|
|
5885
|
-
* same project shares the mounted widget; it is removed when every handle is
|
|
5886
|
-
* destroyed. A different project replaces it. Each handle only ever tears
|
|
5887
|
-
* down the widget it was created for.
|
|
5888
|
-
*/
|
|
5889
|
-
function init(options) {
|
|
8294
|
+
/** The shared implementation of `init` for both builds (see index.ts). */
|
|
8295
|
+
function mount(options, runtime) {
|
|
5890
8296
|
const noop = { destroy() {} };
|
|
5891
8297
|
if (typeof window === "undefined" || typeof document === "undefined") return noop;
|
|
5892
8298
|
if (!isProjectId(options.project)) {
|
|
@@ -5899,22 +8305,26 @@ function init(options) {
|
|
|
5899
8305
|
const state = {
|
|
5900
8306
|
project: options.project,
|
|
5901
8307
|
widget: null,
|
|
8308
|
+
collectors: startCollectors(config.capture, isNuniUrl(config)),
|
|
5902
8309
|
timer: 0,
|
|
5903
8310
|
handles: 0
|
|
5904
8311
|
};
|
|
5905
8312
|
active = state;
|
|
5906
|
-
const
|
|
8313
|
+
const start = () => {
|
|
5907
8314
|
if (active !== state) return;
|
|
5908
8315
|
if (!document.body) {
|
|
5909
|
-
state.timer = window.setTimeout(
|
|
8316
|
+
state.timer = window.setTimeout(start, 50);
|
|
5910
8317
|
return;
|
|
5911
8318
|
}
|
|
5912
|
-
state.widget = new NuniWidget(config
|
|
8319
|
+
state.widget = new NuniWidget(config, {
|
|
8320
|
+
collectors: state.collectors,
|
|
8321
|
+
loadScreenshot: config.capture.screenshot ? runtime.loadScreenshot : null
|
|
8322
|
+
});
|
|
5913
8323
|
state.widget.start();
|
|
5914
8324
|
};
|
|
5915
8325
|
const idle = window.requestIdleCallback;
|
|
5916
|
-
if (idle) idle(
|
|
5917
|
-
else state.timer = window.setTimeout(
|
|
8326
|
+
if (idle) idle(start, { timeout: 1500 });
|
|
8327
|
+
else state.timer = window.setTimeout(start, 1);
|
|
5918
8328
|
return handleFor(state);
|
|
5919
8329
|
}
|
|
5920
8330
|
function handleFor(state) {
|
|
@@ -5931,7 +8341,19 @@ function unmount(state) {
|
|
|
5931
8341
|
clearTimeout(state.timer);
|
|
5932
8342
|
state.widget?.destroy();
|
|
5933
8343
|
state.widget = null;
|
|
8344
|
+
state.collectors.stop();
|
|
5934
8345
|
if (active === state) active = null;
|
|
5935
8346
|
}
|
|
5936
8347
|
//#endregion
|
|
5937
|
-
|
|
8348
|
+
//#region src/index.ts
|
|
8349
|
+
/**
|
|
8350
|
+
* Mount Nuni on the page. Call once on the client. Calling it again with the
|
|
8351
|
+
* same project shares the mounted widget; it is removed when every handle is
|
|
8352
|
+
* destroyed. A different project replaces it. Each handle only ever tears
|
|
8353
|
+
* down the widget it was created for.
|
|
8354
|
+
*/
|
|
8355
|
+
function init(options) {
|
|
8356
|
+
return mount(options, { loadScreenshot: () => import("./screenshot-DcJ_SzKF.js").then((m) => m.captureScreenshot) });
|
|
8357
|
+
}
|
|
8358
|
+
//#endregion
|
|
8359
|
+
export { VERSION, init, EN as messages };
|