@nuniapp/widget 0.1.3 → 0.1.4
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 +6 -5
- package/dist/index.d.ts +39 -3
- package/dist/index.js +643 -104
- package/dist/mark-BeysEAHp.js +66 -0
- package/dist/nuni-screenshot.global.js +14 -0
- package/dist/nuni.global.js +29 -3
- package/dist/screenshot-B2x2-nI_.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 generateSecret, i as LIMITS, n as MASK_ATTRIBUTE, o as isProjectId, r as MASK_SELECTOR, t as CAPTURE_MARK } from "./mark-BeysEAHp.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,417 @@ 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/comment-prompt.ts
|
|
95
|
+
/** A code fence longer than any backtick run inside `content`. */
|
|
96
|
+
function fence(content, lang = "") {
|
|
97
|
+
const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), (m) => m[0].length));
|
|
98
|
+
const ticks = "`".repeat(Math.max(3, longest + 1));
|
|
99
|
+
return `${ticks}${lang}\n${content}\n${ticks}`;
|
|
100
|
+
}
|
|
101
|
+
function quote(text) {
|
|
102
|
+
return text.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
|
|
103
|
+
}
|
|
104
|
+
function inline(text) {
|
|
105
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
106
|
+
return clean.includes("`") ? `"${clean}"` : `\`${clean}\``;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* One comment as a ready-to-paste task for a coding agent. Single source of
|
|
110
|
+
* truth for the widget, dashboard, CLI and MCP server.
|
|
111
|
+
*/
|
|
112
|
+
function buildCommentPrompt(comment, options = {}) {
|
|
113
|
+
const includeContext = options.includeContext ?? true;
|
|
114
|
+
const { anchor, page } = comment;
|
|
115
|
+
const out = [];
|
|
116
|
+
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)" : ""));
|
|
117
|
+
out.push("", "## Where", "");
|
|
118
|
+
out.push(`- Page: ${page.title ? `${page.title} ` : ""}(${page.path})`);
|
|
119
|
+
out.push(`- Open it: ${commentUrl(page, comment._id)}`);
|
|
120
|
+
out.push(`- Viewport: ${comment.viewport.w}×${comment.viewport.h} at ${comment.viewport.dpr}x`);
|
|
121
|
+
if (includeContext && comment.userAgent) out.push(`- Browser: ${comment.userAgent}`);
|
|
122
|
+
out.push("", "## Element", "");
|
|
123
|
+
out.push(`- Tag: <${anchor.tag}>`);
|
|
124
|
+
if (anchor.text) out.push(`- Text: ${inline(anchor.text)}`);
|
|
125
|
+
if (anchor.componentName) out.push(`- React component: ${anchor.componentName}`);
|
|
126
|
+
if (anchor.selectors.testId) out.push(`- Test ID: ${inline(anchor.selectors.testId)}`);
|
|
127
|
+
if (anchor.selectors.id) out.push(`- ID: ${inline(anchor.selectors.id)}`);
|
|
128
|
+
if (anchor.selectors.css) out.push(`- CSS selector: ${inline(anchor.selectors.css)}`);
|
|
129
|
+
out.push(`- DOM path: ${inline(anchor.selectors.path)}`);
|
|
130
|
+
const labels = Object.entries(anchor.attrs).filter(([name]) => name !== "class" && name !== "style").map(([name, value]) => `${name}="${value}"`);
|
|
131
|
+
if (labels.length) out.push(`- Attributes: ${inline(labels.join(" "))}`);
|
|
132
|
+
const context = includeContext ? comment.context : void 0;
|
|
133
|
+
if (context?.dom?.html) out.push("", fence(context.dom.html, "html"));
|
|
134
|
+
const styles = Object.entries(context?.dom?.styles ?? {});
|
|
135
|
+
if (styles.length) out.push("", "Computed styles:", "", fence(styles.map(([k, v]) => `${k}: ${v};`).join("\n"), "css"));
|
|
136
|
+
if (context?.console?.length) {
|
|
137
|
+
out.push("", `## Console (${context.console.length})`, "");
|
|
138
|
+
out.push(fence(context.console.map((e) => `[${e.level}] ${e.message}`).join("\n"), "text"));
|
|
139
|
+
}
|
|
140
|
+
if (context?.network?.length) {
|
|
141
|
+
out.push("", `## Failed requests (${context.network.length})`, "");
|
|
142
|
+
for (const r of context.network) out.push(`- ${r.method} ${r.url} → ${r.status === 0 ? "network error" : r.status}`);
|
|
143
|
+
}
|
|
144
|
+
if (includeContext && comment.screenshotUrl) out.push("", "## Screenshot", "", comment.screenshotUrl);
|
|
145
|
+
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.");
|
|
146
|
+
return out.join("\n");
|
|
147
|
+
}
|
|
74
148
|
//#endregion
|
|
75
149
|
//#region src/config.ts
|
|
76
|
-
const VERSION = "0.1.
|
|
150
|
+
const VERSION = "0.1.4";
|
|
77
151
|
function resolveConfig(options) {
|
|
78
152
|
return {
|
|
79
153
|
project: options.project,
|
|
80
154
|
convexUrl: options.convexUrl ?? "https://greedy-giraffe-33.convex.cloud",
|
|
81
155
|
convexSiteUrl: options.convexSiteUrl ?? "https://greedy-giraffe-33.convex.site",
|
|
82
156
|
appUrl: (options.appUrl ?? "https://nuni.praveenjuge.com").replace(/\/$/, ""),
|
|
83
|
-
getPageKey: options.getPageKey
|
|
157
|
+
getPageKey: options.getPageKey,
|
|
158
|
+
capture: {
|
|
159
|
+
console: options.capture?.console ?? true,
|
|
160
|
+
network: options.capture?.network ?? true,
|
|
161
|
+
dom: options.capture?.dom ?? true,
|
|
162
|
+
screenshot: options.capture?.screenshot ?? true
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js
|
|
168
|
+
function _typeof(o) {
|
|
169
|
+
"@babel/helpers - typeof";
|
|
170
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
171
|
+
return typeof o;
|
|
172
|
+
} : function(o) {
|
|
173
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
174
|
+
}, _typeof(o);
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js
|
|
178
|
+
function toPrimitive(t, r) {
|
|
179
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
180
|
+
var e = t[Symbol.toPrimitive];
|
|
181
|
+
if (void 0 !== e) {
|
|
182
|
+
var i = e.call(t, r || "default");
|
|
183
|
+
if ("object" != _typeof(i)) return i;
|
|
184
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
185
|
+
}
|
|
186
|
+
return ("string" === r ? String : Number)(t);
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js
|
|
190
|
+
function toPropertyKey(t) {
|
|
191
|
+
var i = toPrimitive(t, "string");
|
|
192
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js
|
|
196
|
+
function _defineProperty(e, r, t) {
|
|
197
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
198
|
+
value: t,
|
|
199
|
+
enumerable: !0,
|
|
200
|
+
configurable: !0,
|
|
201
|
+
writable: !0
|
|
202
|
+
}) : e[r] = t, e;
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/context.ts
|
|
206
|
+
const MESSAGE_MAX = LIMITS.contextMessageMaxLength;
|
|
207
|
+
function describeValue(value) {
|
|
208
|
+
if (typeof value === "string") return value;
|
|
209
|
+
if (value instanceof Error) {
|
|
210
|
+
const frame = value.stack?.split("\n").find((line) => line.trim().startsWith("at "));
|
|
211
|
+
return `${value.name}: ${value.message}${frame ? ` (${frame.trim()})` : ""}`;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
let budget = 200;
|
|
215
|
+
return JSON.stringify(value, (_key, v) => budget-- > 0 ? v : void 0) ?? String(value);
|
|
216
|
+
} catch {
|
|
217
|
+
return String(value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function clip(text) {
|
|
221
|
+
return text.length > MESSAGE_MAX ? `${text.slice(0, MESSAGE_MAX - 1)}…` : text;
|
|
222
|
+
}
|
|
223
|
+
/** Origin + path only: query strings and hashes often carry tokens. */
|
|
224
|
+
function safeUrl(input) {
|
|
225
|
+
return clip(withoutQuery(input, location.href));
|
|
226
|
+
}
|
|
227
|
+
var Ring = class {
|
|
228
|
+
constructor() {
|
|
229
|
+
_defineProperty(this, "items", []);
|
|
230
|
+
}
|
|
231
|
+
push(item) {
|
|
232
|
+
this.items.push(item);
|
|
233
|
+
if (this.items.length > LIMITS.contextEntryMax) this.items.shift();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Keep the latest console errors and failed requests, so a comment can carry
|
|
238
|
+
* what went wrong on the page. Installed once per mount, removed on destroy.
|
|
239
|
+
* `ignore` skips Nuni's own traffic.
|
|
240
|
+
*/
|
|
241
|
+
function startCollectors(options, ignore) {
|
|
242
|
+
const logs = new Ring();
|
|
243
|
+
const requests = new Ring();
|
|
244
|
+
const undo = [];
|
|
245
|
+
const log = (level, args) => {
|
|
246
|
+
logs.push({
|
|
247
|
+
level,
|
|
248
|
+
message: clip(stripUrlQueries(args.map(describeValue).join(" "))),
|
|
249
|
+
at: Date.now()
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
const failed = (method, url, status) => {
|
|
253
|
+
if (ignore(url)) return;
|
|
254
|
+
requests.push({
|
|
255
|
+
method: method.toUpperCase(),
|
|
256
|
+
url: safeUrl(url),
|
|
257
|
+
status,
|
|
258
|
+
at: Date.now()
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
if (options.console) {
|
|
262
|
+
for (const level of ["error", "warn"]) {
|
|
263
|
+
const original = console[level];
|
|
264
|
+
const wrapped = (...args) => {
|
|
265
|
+
log(level, args);
|
|
266
|
+
original.apply(console, args);
|
|
267
|
+
};
|
|
268
|
+
console[level] = wrapped;
|
|
269
|
+
undo.push(() => {
|
|
270
|
+
if (console[level] === wrapped) console[level] = original;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const onError = (e) => {
|
|
274
|
+
if (e.error) log("error", [e.error]);
|
|
275
|
+
else log("error", [`${e.message}${e.filename ? ` (${safeUrl(e.filename)}:${e.lineno})` : ""}`]);
|
|
276
|
+
};
|
|
277
|
+
const onRejection = (e) => log("error", ["Unhandled rejection:", e.reason]);
|
|
278
|
+
window.addEventListener("error", onError);
|
|
279
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
280
|
+
undo.push(() => {
|
|
281
|
+
window.removeEventListener("error", onError);
|
|
282
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (options.network) {
|
|
286
|
+
const originalFetch = window.fetch;
|
|
287
|
+
if (typeof originalFetch === "function") {
|
|
288
|
+
const wrappedFetch = async (input, init) => {
|
|
289
|
+
if (init && init[CAPTURE_MARK]) return originalFetch.call(window, input, init);
|
|
290
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
291
|
+
const method = init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET");
|
|
292
|
+
try {
|
|
293
|
+
const response = await originalFetch.call(window, input, init);
|
|
294
|
+
if (!response.ok && response.type !== "opaque") failed(method, url, response.status);
|
|
295
|
+
return response;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof DOMException && error.name === "AbortError")) failed(method, url, 0);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
window.fetch = wrappedFetch;
|
|
302
|
+
undo.push(() => {
|
|
303
|
+
if (window.fetch === wrappedFetch) window.fetch = originalFetch;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const proto = window.XMLHttpRequest?.prototype;
|
|
307
|
+
if (proto) {
|
|
308
|
+
const open = proto.open;
|
|
309
|
+
const send = proto.send;
|
|
310
|
+
const info = /* @__PURE__ */ new WeakMap();
|
|
311
|
+
const wrappedOpen = function(method, url, ...rest) {
|
|
312
|
+
info.set(this, {
|
|
313
|
+
method,
|
|
314
|
+
url: String(url)
|
|
315
|
+
});
|
|
316
|
+
return open.call(this, method, url, ...rest);
|
|
317
|
+
};
|
|
318
|
+
proto.open = wrappedOpen;
|
|
319
|
+
const wrappedSend = function(body) {
|
|
320
|
+
const request = info.get(this);
|
|
321
|
+
if (request) {
|
|
322
|
+
let aborted = false;
|
|
323
|
+
const onAbort = () => aborted = true;
|
|
324
|
+
const onEnd = () => {
|
|
325
|
+
this.removeEventListener("abort", onAbort);
|
|
326
|
+
this.removeEventListener("loadend", onEnd);
|
|
327
|
+
if (!aborted && (this.status === 0 || this.status >= 400)) failed(request.method, request.url, this.status);
|
|
328
|
+
};
|
|
329
|
+
this.addEventListener("abort", onAbort);
|
|
330
|
+
this.addEventListener("loadend", onEnd);
|
|
331
|
+
}
|
|
332
|
+
return send.call(this, body);
|
|
333
|
+
};
|
|
334
|
+
proto.send = wrappedSend;
|
|
335
|
+
undo.push(() => {
|
|
336
|
+
if (proto.open === wrappedOpen) proto.open = open;
|
|
337
|
+
if (proto.send === wrappedSend) proto.send = send;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
console: () => [...logs.items],
|
|
343
|
+
network: () => [...requests.items],
|
|
344
|
+
stop() {
|
|
345
|
+
for (const fn of undo.splice(0)) fn();
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const STYLE_PROPS = [
|
|
350
|
+
"display",
|
|
351
|
+
"position",
|
|
352
|
+
"width",
|
|
353
|
+
"height",
|
|
354
|
+
"margin",
|
|
355
|
+
"padding",
|
|
356
|
+
"gap",
|
|
357
|
+
"font-family",
|
|
358
|
+
"font-size",
|
|
359
|
+
"font-weight",
|
|
360
|
+
"line-height",
|
|
361
|
+
"letter-spacing",
|
|
362
|
+
"text-align",
|
|
363
|
+
"color",
|
|
364
|
+
"background-color",
|
|
365
|
+
"border",
|
|
366
|
+
"border-radius",
|
|
367
|
+
"box-shadow",
|
|
368
|
+
"opacity"
|
|
369
|
+
];
|
|
370
|
+
const ATTR_MAX = 200;
|
|
371
|
+
const TEXT_MAX = 200;
|
|
372
|
+
/** Replace an element's contents with a short note. */
|
|
373
|
+
function redact(el, note) {
|
|
374
|
+
el.replaceChildren(el.ownerDocument.createComment(` ${note} `));
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Remove what should never leave the page (form values, option lists and
|
|
378
|
+
* masked areas) and trim the rest.
|
|
379
|
+
*/
|
|
380
|
+
function scrub(root) {
|
|
381
|
+
const walker = root.ownerDocument.createTreeWalker(root, 5);
|
|
382
|
+
const drop = [];
|
|
383
|
+
for (let node = root; node; node = walker.nextNode()) {
|
|
384
|
+
if (node.nodeType === 3) {
|
|
385
|
+
const text = node.textContent ?? "";
|
|
386
|
+
if (text.length > TEXT_MAX) node.textContent = `${text.slice(0, TEXT_MAX)}…`;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const el = node;
|
|
390
|
+
const tag = el.tagName;
|
|
391
|
+
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
|
|
392
|
+
drop.push(el);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (el.hasAttribute("data-nuni-mask")) {
|
|
396
|
+
for (const attr of Array.from(el.attributes)) if (attr.name !== "id" && attr.name !== "class" && attr.name !== "data-nuni-mask") el.removeAttribute(attr.name);
|
|
397
|
+
redact(el, "masked");
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (tag === "INPUT" || tag === "TEXTAREA") {
|
|
401
|
+
el.removeAttribute("value");
|
|
402
|
+
if (tag === "TEXTAREA") el.textContent = "";
|
|
403
|
+
}
|
|
404
|
+
if (tag === "SELECT" || tag === "DATALIST") {
|
|
405
|
+
const count = el.getElementsByTagName("option").length;
|
|
406
|
+
redact(el, `${count} options`);
|
|
407
|
+
}
|
|
408
|
+
for (const attr of Array.from(el.attributes)) if (attr.value.length > ATTR_MAX) el.setAttribute(attr.name, `${attr.value.slice(0, ATTR_MAX)}…`);
|
|
409
|
+
}
|
|
410
|
+
for (const node of drop) node.parentNode?.removeChild(node);
|
|
411
|
+
}
|
|
412
|
+
/** Clone at most `depth` levels of children. */
|
|
413
|
+
function shallowClone(el, depth) {
|
|
414
|
+
const copy = el.cloneNode(false);
|
|
415
|
+
if (depth > 0) for (const child of Array.from(el.childNodes)) copy.appendChild(child.nodeType === 1 ? shallowClone(child, depth - 1) : child.cloneNode(false));
|
|
416
|
+
else if (el.childElementCount) copy.appendChild(el.ownerDocument.createComment(` ${el.childElementCount} more `));
|
|
417
|
+
return copy;
|
|
418
|
+
}
|
|
419
|
+
/** The element's markup and key computed styles, for design feedback. */
|
|
420
|
+
function domContext(el) {
|
|
421
|
+
const max = LIMITS.domSnippetMaxLength;
|
|
422
|
+
let html = "";
|
|
423
|
+
const inMasked = Boolean(el.parentElement?.closest(`[${MASK_ATTRIBUTE}]`));
|
|
424
|
+
for (const depth of inMasked ? [-1] : [
|
|
425
|
+
Infinity,
|
|
426
|
+
3,
|
|
427
|
+
1,
|
|
428
|
+
0
|
|
429
|
+
]) {
|
|
430
|
+
const copy = depth === Infinity ? el.cloneNode(true) : shallowClone(el, Math.max(0, depth));
|
|
431
|
+
if (depth < 0) copy.setAttribute(MASK_ATTRIBUTE, "");
|
|
432
|
+
scrub(copy);
|
|
433
|
+
html = stripHtmlUrlQueries(copy.outerHTML);
|
|
434
|
+
if (html.length <= max) break;
|
|
435
|
+
}
|
|
436
|
+
if (html.length > max) html = `${html.slice(0, max - 1)}…`;
|
|
437
|
+
const styles = {};
|
|
438
|
+
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
439
|
+
for (const prop of STYLE_PROPS) {
|
|
440
|
+
const value = computed?.getPropertyValue(prop).trim();
|
|
441
|
+
if (value) styles[prop] = value;
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
html,
|
|
445
|
+
styles
|
|
84
446
|
};
|
|
85
447
|
}
|
|
86
448
|
//#endregion
|
|
@@ -359,7 +721,7 @@ function describeSegment(el, options) {
|
|
|
359
721
|
}
|
|
360
722
|
}
|
|
361
723
|
const label = el.getAttribute("aria-label");
|
|
362
|
-
if (label && label.length <= 60) segment += attrSelector("aria-label", label);
|
|
724
|
+
if (label && label.length <= 60 && !el.closest(MASK_SELECTOR)) segment += attrSelector("aria-label", label);
|
|
363
725
|
return segment;
|
|
364
726
|
}
|
|
365
727
|
/**
|
|
@@ -411,8 +773,14 @@ const SKIP_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
|
411
773
|
function normalizeText(value) {
|
|
412
774
|
return value.replace(/\s+/g, " ").trim();
|
|
413
775
|
}
|
|
414
|
-
/**
|
|
776
|
+
/**
|
|
777
|
+
* Visible-ish text of an element, whitespace-normalized and truncated.
|
|
778
|
+
* Areas marked data-nuni-mask count as empty, both when capturing and when
|
|
779
|
+
* resolving, so their (private) text is never stored and matching stays
|
|
780
|
+
* consistent.
|
|
781
|
+
*/
|
|
415
782
|
function elementText(el, max = 120) {
|
|
783
|
+
if (el.closest(MASK_SELECTOR)) return "";
|
|
416
784
|
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return normalizeText(el.placeholder || el.getAttribute("aria-label") || "").slice(0, max);
|
|
417
785
|
let out = "";
|
|
418
786
|
const walk = (node) => {
|
|
@@ -424,6 +792,7 @@ function elementText(el, max = 120) {
|
|
|
424
792
|
if (node.nodeType !== 1) return;
|
|
425
793
|
const tag = node.tagName.toUpperCase();
|
|
426
794
|
if (SKIP_TEXT_TAGS.has(tag)) return;
|
|
795
|
+
if (node.hasAttribute("data-nuni-mask")) return;
|
|
427
796
|
if (tag === "IMG") {
|
|
428
797
|
out += " " + (node.getAttribute("alt") ?? "") + " ";
|
|
429
798
|
return;
|
|
@@ -463,6 +832,7 @@ function textSimilarity(a, b) {
|
|
|
463
832
|
//#region ../anchor/src/capture.ts
|
|
464
833
|
function capturedAttributes(el, options) {
|
|
465
834
|
const attrs = {};
|
|
835
|
+
if (el.closest(MASK_SELECTOR)) return attrs;
|
|
466
836
|
for (const name of CAPTURED_ATTRIBUTES) {
|
|
467
837
|
if (!attributeAllowed(name, options)) continue;
|
|
468
838
|
if (name === "value" && !(el instanceof HTMLButtonElement || el.getAttribute("type") === "submit")) continue;
|
|
@@ -4188,44 +4558,6 @@ var ConvexClient = class {
|
|
|
4188
4558
|
}
|
|
4189
4559
|
};
|
|
4190
4560
|
//#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
4561
|
//#region src/api.ts
|
|
4230
4562
|
var NuniApiError = class extends Error {
|
|
4231
4563
|
constructor(message, code) {
|
|
@@ -4275,6 +4607,14 @@ var NuniApi = class {
|
|
|
4275
4607
|
sessionToken
|
|
4276
4608
|
}, cb, () => {});
|
|
4277
4609
|
}
|
|
4610
|
+
/** A comment with the owner-only context (screenshot, console, DOM). */
|
|
4611
|
+
onOwnerComment(id, sessionToken, cb) {
|
|
4612
|
+
return this.client.onUpdate(api.comments.getForOwner, {
|
|
4613
|
+
publicId: this.config.project,
|
|
4614
|
+
id,
|
|
4615
|
+
sessionToken
|
|
4616
|
+
}, (comment) => cb(comment), () => {});
|
|
4617
|
+
}
|
|
4278
4618
|
/** One comment by id, for deep links outside the page listing window. */
|
|
4279
4619
|
getComment(id) {
|
|
4280
4620
|
return this.client.query(api.comments.getById, {
|
|
@@ -4300,6 +4640,23 @@ var NuniApi = class {
|
|
|
4300
4640
|
if (!response.ok || !data.id) throw new NuniApiError(data.message ?? "Couldn't post the comment", data.code ?? "error");
|
|
4301
4641
|
return data.id;
|
|
4302
4642
|
}
|
|
4643
|
+
/** Attach a screenshot to the author's own, just-posted comment. */
|
|
4644
|
+
async uploadScreenshot(id, authorSecret, image) {
|
|
4645
|
+
const response = await fetch(`${this.config.convexSiteUrl}/widget/screenshot`, {
|
|
4646
|
+
method: "POST",
|
|
4647
|
+
headers: {
|
|
4648
|
+
"Content-Type": image.type,
|
|
4649
|
+
"X-Nuni-Project": this.config.project,
|
|
4650
|
+
"X-Nuni-Comment": id,
|
|
4651
|
+
"X-Nuni-Author": authorSecret
|
|
4652
|
+
},
|
|
4653
|
+
body: image
|
|
4654
|
+
});
|
|
4655
|
+
if (!response.ok) {
|
|
4656
|
+
const data = await response.json().catch(() => ({}));
|
|
4657
|
+
throw new NuniApiError(data.message ?? "Couldn't attach the screenshot", data.code ?? "error");
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4303
4660
|
async run(promise) {
|
|
4304
4661
|
try {
|
|
4305
4662
|
return await promise;
|
|
@@ -4387,6 +4744,26 @@ function colorFor(name) {
|
|
|
4387
4744
|
for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
|
|
4388
4745
|
return `oklch(0.62 0.17 ${hash % 360})`;
|
|
4389
4746
|
}
|
|
4747
|
+
/** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
|
|
4748
|
+
async function copyText(text) {
|
|
4749
|
+
try {
|
|
4750
|
+
await navigator.clipboard.writeText(text);
|
|
4751
|
+
return true;
|
|
4752
|
+
} catch {}
|
|
4753
|
+
const area = document.createElement("textarea");
|
|
4754
|
+
area.value = text;
|
|
4755
|
+
area.setAttribute("readonly", "");
|
|
4756
|
+
area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
4757
|
+
document.body.appendChild(area);
|
|
4758
|
+
area.select();
|
|
4759
|
+
try {
|
|
4760
|
+
return document.execCommand("copy");
|
|
4761
|
+
} catch {
|
|
4762
|
+
return false;
|
|
4763
|
+
} finally {
|
|
4764
|
+
area.remove();
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4390
4767
|
//#endregion
|
|
4391
4768
|
//#region src/icons.ts
|
|
4392
4769
|
const s = (d) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
|
@@ -4399,6 +4776,7 @@ const ICONS = {
|
|
|
4399
4776
|
trash: s("<path d=\"M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6\"/>"),
|
|
4400
4777
|
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
4778
|
send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
|
|
4779
|
+
bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
|
|
4402
4780
|
target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
|
|
4403
4781
|
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
4782
|
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\"/>")
|
|
@@ -4810,6 +5188,26 @@ button { cursor: pointer; }
|
|
|
4810
5188
|
}
|
|
4811
5189
|
.badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
|
|
4812
5190
|
.comment-body { white-space: pre-wrap; word-break: break-word; }
|
|
5191
|
+
.shot {
|
|
5192
|
+
display: block;
|
|
5193
|
+
border: 1px solid var(--n-border);
|
|
5194
|
+
border-radius: 8px;
|
|
5195
|
+
overflow: hidden;
|
|
5196
|
+
background: var(--n-bg-subtle);
|
|
5197
|
+
}
|
|
5198
|
+
.shot img { display: block; width: 100%; max-height: 160px; object-fit: cover; object-position: top; }
|
|
5199
|
+
.shot-preview { display: grid; gap: 4px; }
|
|
5200
|
+
.shot-preview img {
|
|
5201
|
+
display: block;
|
|
5202
|
+
width: 100%;
|
|
5203
|
+
max-height: 200px;
|
|
5204
|
+
/* The whole image, so the commenter sees everything that is attached. */
|
|
5205
|
+
object-fit: contain;
|
|
5206
|
+
border: 1px solid var(--n-border);
|
|
5207
|
+
border-radius: 8px;
|
|
5208
|
+
background: var(--n-bg-subtle);
|
|
5209
|
+
}
|
|
5210
|
+
.shot-note { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--n-fg-muted); }
|
|
4813
5211
|
.field {
|
|
4814
5212
|
width: 100%;
|
|
4815
5213
|
padding: 8px 10px;
|
|
@@ -4941,6 +5339,14 @@ function isTypingTarget(target) {
|
|
|
4941
5339
|
if (!(target instanceof HTMLElement)) return false;
|
|
4942
5340
|
return target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
|
4943
5341
|
}
|
|
5342
|
+
function dataUrl(blob) {
|
|
5343
|
+
return new Promise((resolve, reject) => {
|
|
5344
|
+
const reader = new FileReader();
|
|
5345
|
+
reader.onload = () => resolve(String(reader.result));
|
|
5346
|
+
reader.onerror = () => reject(reader.error);
|
|
5347
|
+
reader.readAsDataURL(blob);
|
|
5348
|
+
});
|
|
5349
|
+
}
|
|
4944
5350
|
function describeElement(el) {
|
|
4945
5351
|
const tag = el.tagName.toLowerCase();
|
|
4946
5352
|
const id = el.id ? `#${el.id}` : "";
|
|
@@ -4949,8 +5355,9 @@ function describeElement(el) {
|
|
|
4949
5355
|
return `${tag}${id}${cls}${text ? ` "${text}"` : ""}`;
|
|
4950
5356
|
}
|
|
4951
5357
|
var NuniWidget = class {
|
|
4952
|
-
constructor(config) {
|
|
5358
|
+
constructor(config, runtime) {
|
|
4953
5359
|
this.config = config;
|
|
5360
|
+
this.runtime = runtime;
|
|
4954
5361
|
_defineProperty(this, "host", void 0);
|
|
4955
5362
|
_defineProperty(this, "root", void 0);
|
|
4956
5363
|
_defineProperty(this, "pinLayer", void 0);
|
|
@@ -4982,6 +5389,10 @@ var NuniWidget = class {
|
|
|
4982
5389
|
_defineProperty(this, "ownerName", null);
|
|
4983
5390
|
_defineProperty(this, "deepLinkId", null);
|
|
4984
5391
|
_defineProperty(this, "extraComment", null);
|
|
5392
|
+
_defineProperty(this, "ownerDetail", null);
|
|
5393
|
+
_defineProperty(this, "ownerDetailId", null);
|
|
5394
|
+
_defineProperty(this, "ownerDetailLoaded", false);
|
|
5395
|
+
_defineProperty(this, "ownerDetailUnsub", null);
|
|
4985
5396
|
_defineProperty(this, "layoutFrame", 0);
|
|
4986
5397
|
_defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
|
|
4987
5398
|
_defineProperty(this, "resolveTimer", 0);
|
|
@@ -5017,15 +5428,20 @@ var NuniWidget = class {
|
|
|
5017
5428
|
y: e.clientY
|
|
5018
5429
|
}, { ignoreAttributePrefixes: [] });
|
|
5019
5430
|
this.setPicking(false);
|
|
5431
|
+
const draft = {
|
|
5432
|
+
anchor,
|
|
5433
|
+
element
|
|
5434
|
+
};
|
|
5435
|
+
if (this.config.capture.dom) try {
|
|
5436
|
+
draft.dom = domContext(element);
|
|
5437
|
+
} catch {}
|
|
5020
5438
|
this.card = {
|
|
5021
5439
|
kind: "composer",
|
|
5022
|
-
draft
|
|
5023
|
-
anchor,
|
|
5024
|
-
element
|
|
5025
|
-
}
|
|
5440
|
+
draft
|
|
5026
5441
|
};
|
|
5027
5442
|
this.render();
|
|
5028
5443
|
this.focusComposer();
|
|
5444
|
+
this.takeScreenshot(draft);
|
|
5029
5445
|
});
|
|
5030
5446
|
let secret = read(KEYS.secret);
|
|
5031
5447
|
if (!secret) {
|
|
@@ -5113,6 +5529,7 @@ var NuniWidget = class {
|
|
|
5113
5529
|
this.setPicking(false);
|
|
5114
5530
|
this.pageUnsub?.();
|
|
5115
5531
|
this.sessionUnsub?.();
|
|
5532
|
+
this.ownerDetailUnsub?.();
|
|
5116
5533
|
for (const cleanup of this.cleanups) cleanup();
|
|
5117
5534
|
cancelAnimationFrame(this.layoutFrame);
|
|
5118
5535
|
clearTimeout(this.resolveTimer);
|
|
@@ -5160,6 +5577,24 @@ var NuniWidget = class {
|
|
|
5160
5577
|
this.render();
|
|
5161
5578
|
});
|
|
5162
5579
|
}
|
|
5580
|
+
/** Keep the owner-only details of the open thread subscribed. */
|
|
5581
|
+
syncOwnerDetail() {
|
|
5582
|
+
const token = this.ownerToken;
|
|
5583
|
+
const id = this.card?.kind === "thread" && this.isOwner && token ? this.card.id : null;
|
|
5584
|
+
if (id === this.ownerDetailId) return;
|
|
5585
|
+
this.ownerDetailUnsub?.();
|
|
5586
|
+
this.ownerDetailUnsub = null;
|
|
5587
|
+
this.ownerDetail = null;
|
|
5588
|
+
this.ownerDetailLoaded = false;
|
|
5589
|
+
this.ownerDetailId = id;
|
|
5590
|
+
if (!id || !token) return;
|
|
5591
|
+
this.ownerDetailUnsub = this.api.onOwnerComment(id, token, (comment) => {
|
|
5592
|
+
if (this.ownerDetailId !== id) return;
|
|
5593
|
+
this.ownerDetail = comment;
|
|
5594
|
+
this.ownerDetailLoaded = true;
|
|
5595
|
+
this.render();
|
|
5596
|
+
});
|
|
5597
|
+
}
|
|
5163
5598
|
get isOwner() {
|
|
5164
5599
|
return Boolean(this.ownerToken && this.ownerName);
|
|
5165
5600
|
}
|
|
@@ -5287,6 +5722,50 @@ var NuniWidget = class {
|
|
|
5287
5722
|
const target = e.target;
|
|
5288
5723
|
return target instanceof Element ? target : null;
|
|
5289
5724
|
}
|
|
5725
|
+
/** Capture while the person types, so posting stays instant. */
|
|
5726
|
+
async takeScreenshot(draft) {
|
|
5727
|
+
const load = this.runtime.loadScreenshot;
|
|
5728
|
+
if (!load) return;
|
|
5729
|
+
const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
|
|
5730
|
+
try {
|
|
5731
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
5732
|
+
const capture = await load();
|
|
5733
|
+
const blob = capture ? await capture(draft.element, {
|
|
5734
|
+
exclude: this.host,
|
|
5735
|
+
accent
|
|
5736
|
+
}) : null;
|
|
5737
|
+
if (!blob || !this.isComposing(draft) || draft.sending) return;
|
|
5738
|
+
const preview = await dataUrl(blob);
|
|
5739
|
+
if (!this.isComposing(draft) || draft.sending) return;
|
|
5740
|
+
draft.screenshot = {
|
|
5741
|
+
blob,
|
|
5742
|
+
preview
|
|
5743
|
+
};
|
|
5744
|
+
this.render();
|
|
5745
|
+
} catch {}
|
|
5746
|
+
}
|
|
5747
|
+
isComposing(draft) {
|
|
5748
|
+
return this.card?.kind === "composer" && this.card.draft === draft;
|
|
5749
|
+
}
|
|
5750
|
+
collectContext(draft) {
|
|
5751
|
+
const { collectors } = this.runtime;
|
|
5752
|
+
const context = {};
|
|
5753
|
+
const logs = collectors.console();
|
|
5754
|
+
const failed = collectors.network();
|
|
5755
|
+
if (logs.length) context.console = logs;
|
|
5756
|
+
if (failed.length) context.network = failed;
|
|
5757
|
+
if (draft.dom) context.dom = draft.dom;
|
|
5758
|
+
return Object.keys(context).length ? context : void 0;
|
|
5759
|
+
}
|
|
5760
|
+
async attachScreenshot(id, image) {
|
|
5761
|
+
try {
|
|
5762
|
+
await this.api.uploadScreenshot(id, this.secret, image);
|
|
5763
|
+
} catch {}
|
|
5764
|
+
}
|
|
5765
|
+
copyForAgent(comment) {
|
|
5766
|
+
const detail = this.ownerDetail?._id === comment._id ? this.ownerDetail : null;
|
|
5767
|
+
copyText(buildCommentPrompt(detail ?? comment, { includeContext: Boolean(detail) })).then((ok) => this.showToast(ok ? "Copied for your coding agent" : "Couldn't copy"));
|
|
5768
|
+
}
|
|
5290
5769
|
renderHighlight() {
|
|
5291
5770
|
this.overlayLayer.replaceChildren();
|
|
5292
5771
|
if (!this.picking) return;
|
|
@@ -5398,6 +5877,7 @@ var NuniWidget = class {
|
|
|
5398
5877
|
draft.error = void 0;
|
|
5399
5878
|
this.render();
|
|
5400
5879
|
const loc = describeLocation(location.href);
|
|
5880
|
+
const shot = draft.screenshot;
|
|
5401
5881
|
try {
|
|
5402
5882
|
const id = await this.api.createComment({
|
|
5403
5883
|
body: cleanBody,
|
|
@@ -5413,8 +5893,10 @@ var NuniWidget = class {
|
|
|
5413
5893
|
w: window.innerWidth,
|
|
5414
5894
|
h: window.innerHeight,
|
|
5415
5895
|
dpr: window.devicePixelRatio || 1
|
|
5416
|
-
}
|
|
5896
|
+
},
|
|
5897
|
+
context: this.collectContext(draft)
|
|
5417
5898
|
});
|
|
5899
|
+
if (shot) this.attachScreenshot(id, shot.blob);
|
|
5418
5900
|
this.placements.set(id, {
|
|
5419
5901
|
element: draft.element,
|
|
5420
5902
|
confidence: "exact"
|
|
@@ -5505,6 +5987,7 @@ var NuniWidget = class {
|
|
|
5505
5987
|
this.render();
|
|
5506
5988
|
}
|
|
5507
5989
|
render() {
|
|
5990
|
+
this.syncOwnerDetail();
|
|
5508
5991
|
this.renderPins();
|
|
5509
5992
|
this.renderUi();
|
|
5510
5993
|
this.renderHighlight();
|
|
@@ -5736,7 +6219,17 @@ var NuniWidget = class {
|
|
|
5736
6219
|
this.name = "";
|
|
5737
6220
|
this.render();
|
|
5738
6221
|
}
|
|
5739
|
-
}, "Not you?")), textarea, draft.
|
|
6222
|
+
}, "Not you?")), textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
|
|
6223
|
+
src: draft.screenshot.preview,
|
|
6224
|
+
alt: "Screenshot that will be attached"
|
|
6225
|
+
}), h("div", { class: "shot-note" }, h("span", {}, "Screenshot attached. Only the site owner sees it."), h("span", { class: "spacer" }), h("button", {
|
|
6226
|
+
class: "link",
|
|
6227
|
+
type: "button",
|
|
6228
|
+
onclick: () => {
|
|
6229
|
+
draft.screenshot = void 0;
|
|
6230
|
+
this.render();
|
|
6231
|
+
}
|
|
6232
|
+
}, "Remove"))) : null, draft.error ? h("div", {
|
|
5740
6233
|
class: "error",
|
|
5741
6234
|
role: "alert"
|
|
5742
6235
|
}, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, "⌘ + Enter"), h("span", { class: "spacer" }), h("button", {
|
|
@@ -5784,7 +6277,16 @@ var NuniWidget = class {
|
|
|
5784
6277
|
type: "button",
|
|
5785
6278
|
"aria-label": "Close",
|
|
5786
6279
|
onclick: () => this.closeCard()
|
|
5787
|
-
}, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body),
|
|
6280
|
+
}, icon(ICONS.close))), h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
|
|
6281
|
+
class: "shot",
|
|
6282
|
+
href: this.ownerDetail.screenshotUrl,
|
|
6283
|
+
target: "_blank",
|
|
6284
|
+
rel: "noreferrer",
|
|
6285
|
+
title: "Open the screenshot"
|
|
6286
|
+
}, h("img", {
|
|
6287
|
+
src: this.ownerDetail.screenshotUrl,
|
|
6288
|
+
alt: "Screenshot taken when the comment was left"
|
|
6289
|
+
})) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), "Resolved") : null, comment.page.origin !== location.origin ? h("span", {
|
|
5788
6290
|
class: "badge",
|
|
5789
6291
|
title: `Left on ${comment.page.origin}`
|
|
5790
6292
|
}, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
|
|
@@ -5811,6 +6313,15 @@ var NuniWidget = class {
|
|
|
5811
6313
|
onclick: () => this.reopenComment(comment)
|
|
5812
6314
|
}, icon(ICONS.undo), "Reopen"));
|
|
5813
6315
|
actions.push(h("span", { class: "spacer" }));
|
|
6316
|
+
const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
|
|
6317
|
+
actions.push(h("button", {
|
|
6318
|
+
class: "btn btn-ghost btn-icon",
|
|
6319
|
+
type: "button",
|
|
6320
|
+
"aria-label": "Copy for agent",
|
|
6321
|
+
title: contextPending ? "Loading page context…" : "Copy for your coding agent",
|
|
6322
|
+
disabled: contextPending,
|
|
6323
|
+
onclick: () => this.copyForAgent(comment)
|
|
6324
|
+
}, icon(ICONS.bot)));
|
|
5814
6325
|
if (mine) actions.push(h("button", {
|
|
5815
6326
|
class: "btn btn-ghost btn-icon",
|
|
5816
6327
|
type: "button",
|
|
@@ -5835,7 +6346,7 @@ var NuniWidget = class {
|
|
|
5835
6346
|
"data-card": "thread",
|
|
5836
6347
|
role: "dialog",
|
|
5837
6348
|
"aria-label": `Comment by ${comment.authorName}`
|
|
5838
|
-
}, body, actions.length
|
|
6349
|
+
}, body, actions.length ? h("div", { class: "actions" }, ...actions) : null);
|
|
5839
6350
|
}
|
|
5840
6351
|
positionCard() {
|
|
5841
6352
|
const el = this.uiLayer.querySelector("[data-card]");
|
|
@@ -5878,15 +6389,27 @@ var NuniWidget = class {
|
|
|
5878
6389
|
}
|
|
5879
6390
|
};
|
|
5880
6391
|
//#endregion
|
|
5881
|
-
//#region src/
|
|
6392
|
+
//#region src/mount.ts
|
|
6393
|
+
/** Nuni's own requests are not the page's problem. */
|
|
6394
|
+
function isNuniUrl(config) {
|
|
6395
|
+
const origins = [config.convexUrl, config.convexSiteUrl].flatMap((url) => {
|
|
6396
|
+
try {
|
|
6397
|
+
return [new URL(url).origin];
|
|
6398
|
+
} catch {
|
|
6399
|
+
return [];
|
|
6400
|
+
}
|
|
6401
|
+
});
|
|
6402
|
+
return (url) => {
|
|
6403
|
+
try {
|
|
6404
|
+
return origins.includes(new URL(url, location.href).origin);
|
|
6405
|
+
} catch {
|
|
6406
|
+
return false;
|
|
6407
|
+
}
|
|
6408
|
+
};
|
|
6409
|
+
}
|
|
5882
6410
|
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) {
|
|
6411
|
+
/** The shared implementation of `init` for both builds (see index.ts). */
|
|
6412
|
+
function mount(options, runtime) {
|
|
5890
6413
|
const noop = { destroy() {} };
|
|
5891
6414
|
if (typeof window === "undefined" || typeof document === "undefined") return noop;
|
|
5892
6415
|
if (!isProjectId(options.project)) {
|
|
@@ -5899,22 +6422,26 @@ function init(options) {
|
|
|
5899
6422
|
const state = {
|
|
5900
6423
|
project: options.project,
|
|
5901
6424
|
widget: null,
|
|
6425
|
+
collectors: startCollectors(config.capture, isNuniUrl(config)),
|
|
5902
6426
|
timer: 0,
|
|
5903
6427
|
handles: 0
|
|
5904
6428
|
};
|
|
5905
6429
|
active = state;
|
|
5906
|
-
const
|
|
6430
|
+
const start = () => {
|
|
5907
6431
|
if (active !== state) return;
|
|
5908
6432
|
if (!document.body) {
|
|
5909
|
-
state.timer = window.setTimeout(
|
|
6433
|
+
state.timer = window.setTimeout(start, 50);
|
|
5910
6434
|
return;
|
|
5911
6435
|
}
|
|
5912
|
-
state.widget = new NuniWidget(config
|
|
6436
|
+
state.widget = new NuniWidget(config, {
|
|
6437
|
+
collectors: state.collectors,
|
|
6438
|
+
loadScreenshot: config.capture.screenshot ? runtime.loadScreenshot : null
|
|
6439
|
+
});
|
|
5913
6440
|
state.widget.start();
|
|
5914
6441
|
};
|
|
5915
6442
|
const idle = window.requestIdleCallback;
|
|
5916
|
-
if (idle) idle(
|
|
5917
|
-
else state.timer = window.setTimeout(
|
|
6443
|
+
if (idle) idle(start, { timeout: 1500 });
|
|
6444
|
+
else state.timer = window.setTimeout(start, 1);
|
|
5918
6445
|
return handleFor(state);
|
|
5919
6446
|
}
|
|
5920
6447
|
function handleFor(state) {
|
|
@@ -5931,7 +6458,19 @@ function unmount(state) {
|
|
|
5931
6458
|
clearTimeout(state.timer);
|
|
5932
6459
|
state.widget?.destroy();
|
|
5933
6460
|
state.widget = null;
|
|
6461
|
+
state.collectors.stop();
|
|
5934
6462
|
if (active === state) active = null;
|
|
5935
6463
|
}
|
|
5936
6464
|
//#endregion
|
|
6465
|
+
//#region src/index.ts
|
|
6466
|
+
/**
|
|
6467
|
+
* Mount Nuni on the page. Call once on the client. Calling it again with the
|
|
6468
|
+
* same project shares the mounted widget; it is removed when every handle is
|
|
6469
|
+
* destroyed. A different project replaces it. Each handle only ever tears
|
|
6470
|
+
* down the widget it was created for.
|
|
6471
|
+
*/
|
|
6472
|
+
function init(options) {
|
|
6473
|
+
return mount(options, { loadScreenshot: () => import("./screenshot-B2x2-nI_.js").then((m) => m.captureScreenshot) });
|
|
6474
|
+
}
|
|
6475
|
+
//#endregion
|
|
5937
6476
|
export { VERSION, init };
|