@nuniapp/widget 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dom-BWcT-q7S.js +255 -0
- package/dist/index.d.ts +87 -6
- package/dist/index.js +606 -150
- package/dist/nuni-screenshot.global.js +41 -7
- package/dist/nuni.global.js +91 -8
- package/dist/{screenshot-DcJ_SzKF.js → tools-gIAPu66h.js} +225 -14
- package/package.json +1 -1
- package/dist/mark-B8wojiFu.js +0 -90
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
//#region ../shared/src/id.ts
|
|
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
|
+
/** CLI and MCP sessions, created by `nuni login`. */
|
|
36
|
+
cliSessionTtlMs: 7776e6,
|
|
37
|
+
/** How long a project transfer link works. */
|
|
38
|
+
transferTtlMs: 6048e5,
|
|
39
|
+
/** Comments changed at once from the dashboard. */
|
|
40
|
+
bulkMax: 100,
|
|
41
|
+
/** Pins found or lost in one report from a visitor's widget. */
|
|
42
|
+
pinReportMax: 200,
|
|
43
|
+
/** The dashboard's "Not found" count stops here ("99+"). */
|
|
44
|
+
lostCountMax: 99,
|
|
45
|
+
/** How long a `nuni login` code waits for approval in the dashboard. */
|
|
46
|
+
cliLoginTtlMs: 6e5,
|
|
47
|
+
anchorTextMaxLength: 120,
|
|
48
|
+
/** Selected text kept with a text comment, and the context around it. */
|
|
49
|
+
quoteMaxLength: 500,
|
|
50
|
+
quoteContextLength: 32,
|
|
51
|
+
/** Per page, the widget loads this many newest open and resolved comments. */
|
|
52
|
+
pageOpenLimit: 1e3,
|
|
53
|
+
pageResolvedLimit: 200,
|
|
54
|
+
/** The newest of a commenter's own comments, across pages. */
|
|
55
|
+
yoursLimit: 100,
|
|
56
|
+
/** Most console or network entries kept with one comment. */
|
|
57
|
+
contextEntryMax: 20,
|
|
58
|
+
contextMessageMaxLength: 500,
|
|
59
|
+
/** Trimmed outerHTML of the commented element. */
|
|
60
|
+
domSnippetMaxLength: 4e3,
|
|
61
|
+
screenshotMaxBytes: 6e5,
|
|
62
|
+
/** Images a commenter attaches to one comment (their own or marked up). */
|
|
63
|
+
imagesPerComment: 3,
|
|
64
|
+
imageMaxBytes: 6e5,
|
|
65
|
+
/** Longest side of an attached image, in pixels. */
|
|
66
|
+
imageMaxSide: 1600,
|
|
67
|
+
/** Replies kept per comment thread. */
|
|
68
|
+
repliesPerComment: 200,
|
|
69
|
+
/** Reactions kept per thread (the comment and its replies). */
|
|
70
|
+
reactionsPerComment: 2e3,
|
|
71
|
+
/** How long after posting the author can still attach the screenshot. */
|
|
72
|
+
screenshotUploadWindowMs: 3e5
|
|
73
|
+
};
|
|
74
|
+
/** The reactions anyone can add to a comment or reply. */
|
|
75
|
+
const REACTIONS = [
|
|
76
|
+
"👍",
|
|
77
|
+
"❤️",
|
|
78
|
+
"🎉",
|
|
79
|
+
"👀",
|
|
80
|
+
"✅",
|
|
81
|
+
"😄"
|
|
82
|
+
];
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region ../shared/src/privacy.ts
|
|
85
|
+
/**
|
|
86
|
+
* Site owners mark private areas with this attribute. Their text never
|
|
87
|
+
* reaches Nuni: not in the pin anchor (which is public), the markup snippet
|
|
88
|
+
* or the screenshot.
|
|
89
|
+
*/
|
|
90
|
+
const MASK_ATTRIBUTE = "data-nuni-mask";
|
|
91
|
+
const MASK_SELECTOR = `[${MASK_ATTRIBUTE}]`;
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/mark.ts
|
|
94
|
+
/**
|
|
95
|
+
* Marks Nuni's own screenshot requests (fonts and images it inlines), so the
|
|
96
|
+
* network collector can skip them without muting the page. `Symbol.for` is
|
|
97
|
+
* shared by the widget and the separately loaded screenshot script.
|
|
98
|
+
*/
|
|
99
|
+
const CAPTURE_MARK = Symbol.for("nuni.capture");
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/image.ts
|
|
102
|
+
/** The image types the backend accepts. */
|
|
103
|
+
const IMAGE_TYPES = [
|
|
104
|
+
"image/png",
|
|
105
|
+
"image/jpeg",
|
|
106
|
+
"image/webp"
|
|
107
|
+
];
|
|
108
|
+
function toBlob(canvas, type, quality) {
|
|
109
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* WebP, or JPEG where the browser can't encode WebP, at the best quality
|
|
113
|
+
* that fits `maxBytes`. Null when even the smallest doesn't fit.
|
|
114
|
+
*/
|
|
115
|
+
async function encodeCanvas(canvas, maxBytes) {
|
|
116
|
+
for (const [type, quality] of [
|
|
117
|
+
["image/webp", .82],
|
|
118
|
+
["image/jpeg", .8],
|
|
119
|
+
["image/jpeg", .55]
|
|
120
|
+
]) {
|
|
121
|
+
const blob = await toBlob(canvas, type, quality);
|
|
122
|
+
if (blob && blob.type === type && blob.size <= maxBytes) return blob;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* An image someone picked or pasted, scaled down to the size limit and
|
|
128
|
+
* re-encoded, which also drops its metadata (location, camera). Null when
|
|
129
|
+
* it isn't a readable image.
|
|
130
|
+
*/
|
|
131
|
+
async function imageFromFile(file) {
|
|
132
|
+
if (!IMAGE_TYPES.includes(file.type)) return null;
|
|
133
|
+
let bitmap;
|
|
134
|
+
try {
|
|
135
|
+
bitmap = await createImageBitmap(file);
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const scale = Math.min(1, LIMITS.imageMaxSide / Math.max(bitmap.width, bitmap.height));
|
|
140
|
+
const canvas = document.createElement("canvas");
|
|
141
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
142
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
143
|
+
try {
|
|
144
|
+
const ctx = canvas.getContext("2d");
|
|
145
|
+
if (!ctx) return null;
|
|
146
|
+
ctx.fillStyle = "#fff";
|
|
147
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
148
|
+
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
149
|
+
} finally {
|
|
150
|
+
bitmap.close();
|
|
151
|
+
}
|
|
152
|
+
return encodeCanvas(canvas, LIMITS.imageMaxBytes);
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/dom.ts
|
|
156
|
+
/** Tiny element factory. Strings become text nodes (never HTML). */
|
|
157
|
+
function h(tag, attrs = {}, ...children) {
|
|
158
|
+
const el = document.createElement(tag);
|
|
159
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
160
|
+
if (value == null || value === false) continue;
|
|
161
|
+
if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2).toLowerCase(), value);
|
|
162
|
+
else if (key === "class") el.className = String(value);
|
|
163
|
+
else if (value === true) el.setAttribute(key, "");
|
|
164
|
+
else el.setAttribute(key, String(value));
|
|
165
|
+
}
|
|
166
|
+
for (const child of children.flat()) {
|
|
167
|
+
if (child == null || child === false) continue;
|
|
168
|
+
el.append(typeof child === "string" ? document.createTextNode(child) : child);
|
|
169
|
+
}
|
|
170
|
+
return el;
|
|
171
|
+
}
|
|
172
|
+
/** Renders one of the constant SVG strings in icons.ts (never user input). */
|
|
173
|
+
function icon(svg) {
|
|
174
|
+
const span = document.createElement("span");
|
|
175
|
+
span.className = "icon";
|
|
176
|
+
span.setAttribute("aria-hidden", "true");
|
|
177
|
+
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
178
|
+
span.append(document.importNode(parsed.documentElement, true));
|
|
179
|
+
return span;
|
|
180
|
+
}
|
|
181
|
+
function initials(name) {
|
|
182
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
183
|
+
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2)).toUpperCase();
|
|
184
|
+
}
|
|
185
|
+
/** Stable pleasant color per author name. */
|
|
186
|
+
function colorFor(name) {
|
|
187
|
+
let hash = 0;
|
|
188
|
+
for (const ch of name) hash = hash * 31 + ch.charCodeAt(0) >>> 0;
|
|
189
|
+
return `oklch(0.5 0.17 ${hash % 360})`;
|
|
190
|
+
}
|
|
191
|
+
/** Copy text, falling back to execCommand where the async clipboard is off (plain http previews). */
|
|
192
|
+
async function copyText(text) {
|
|
193
|
+
try {
|
|
194
|
+
await navigator.clipboard.writeText(text);
|
|
195
|
+
return true;
|
|
196
|
+
} catch {}
|
|
197
|
+
const area = document.createElement("textarea");
|
|
198
|
+
area.value = text;
|
|
199
|
+
area.setAttribute("readonly", "");
|
|
200
|
+
area.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
201
|
+
document.body.appendChild(area);
|
|
202
|
+
area.select();
|
|
203
|
+
try {
|
|
204
|
+
return document.execCommand("copy");
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
} finally {
|
|
208
|
+
area.remove();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Any CSS color as RGB, using the browser's own parser. */
|
|
212
|
+
function parseColor(color) {
|
|
213
|
+
const probe = document.createElement("span");
|
|
214
|
+
probe.style.color = color;
|
|
215
|
+
if (!probe.style.color) return null;
|
|
216
|
+
probe.style.display = "none";
|
|
217
|
+
document.documentElement.appendChild(probe);
|
|
218
|
+
const computed = getComputedStyle(probe).color;
|
|
219
|
+
probe.remove();
|
|
220
|
+
if (/^rgba?\(/.test(computed)) {
|
|
221
|
+
const parts = computed.match(/[\d.]+/g)?.map(Number);
|
|
222
|
+
if (parts && parts.length >= 3) return [
|
|
223
|
+
parts[0],
|
|
224
|
+
parts[1],
|
|
225
|
+
parts[2]
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const canvas = document.createElement("canvas");
|
|
230
|
+
canvas.width = canvas.height = 1;
|
|
231
|
+
const ctx = canvas.getContext("2d");
|
|
232
|
+
if (!ctx) return null;
|
|
233
|
+
ctx.fillStyle = color;
|
|
234
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
235
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
236
|
+
return [
|
|
237
|
+
r,
|
|
238
|
+
g,
|
|
239
|
+
b
|
|
240
|
+
];
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Black or white text, whichever reads better on the color (WCAG). */
|
|
246
|
+
function readableOn([r, g, b]) {
|
|
247
|
+
const channel = (c) => {
|
|
248
|
+
const v = c / 255;
|
|
249
|
+
return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
250
|
+
};
|
|
251
|
+
const l = .2126 * channel(r) + .7152 * channel(g) + .0722 * channel(b);
|
|
252
|
+
return 1.05 / (l + .05) >= (l + .05) / .05 ? "#ffffff" : "#111111";
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { isProjectId as _, initials as a, IMAGE_TYPES as c, CAPTURE_MARK as d, MASK_ATTRIBUTE as f, generateSecret as g, REACTIONS as h, icon as i, encodeCanvas as l, LIMITS as m, copyText as n, parseColor as o, MASK_SELECTOR as p, h as r, readableOn as s, colorFor as t, imageFromFile as u };
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ declare const EN: {
|
|
|
25
25
|
close: string;
|
|
26
26
|
tabOpen: string;
|
|
27
27
|
tabResolved: string;
|
|
28
|
+
tabYours: string;
|
|
29
|
+
yours: string;
|
|
30
|
+
noYours: string;
|
|
31
|
+
thisPage: string;
|
|
32
|
+
statusOpen: string;
|
|
28
33
|
loading: string;
|
|
29
34
|
noOpen: string;
|
|
30
35
|
noOpenKey: string;
|
|
@@ -53,6 +58,25 @@ declare const EN: {
|
|
|
53
58
|
one: string;
|
|
54
59
|
other: string;
|
|
55
60
|
};
|
|
61
|
+
suggestEdit: string;
|
|
62
|
+
suggestedEdit: string;
|
|
63
|
+
removeSuggestion: string;
|
|
64
|
+
newText: string;
|
|
65
|
+
addNote: string;
|
|
66
|
+
addImage: string;
|
|
67
|
+
markUpScreenshot: string;
|
|
68
|
+
preparingScreenshot: string;
|
|
69
|
+
markUpTitle: string;
|
|
70
|
+
markUpBox: string;
|
|
71
|
+
markUpArrow: string;
|
|
72
|
+
markUpPen: string;
|
|
73
|
+
markUpUndo: string;
|
|
74
|
+
markUpDone: string;
|
|
75
|
+
markUpFailed: string;
|
|
76
|
+
imagesPublic: string;
|
|
77
|
+
imageN: string;
|
|
78
|
+
removeImage: string;
|
|
79
|
+
openImage: string;
|
|
56
80
|
pinLabel: string;
|
|
57
81
|
commentBy: string;
|
|
58
82
|
openScreenshot: string;
|
|
@@ -66,6 +90,15 @@ declare const EN: {
|
|
|
66
90
|
resolve: string;
|
|
67
91
|
reopen: string;
|
|
68
92
|
copyForAgent: string;
|
|
93
|
+
copyNewText: string;
|
|
94
|
+
movePin: string;
|
|
95
|
+
movePinHint: string;
|
|
96
|
+
pinMoved: string;
|
|
97
|
+
closeOutdated: string;
|
|
98
|
+
closedOutdated: string;
|
|
99
|
+
outdated: string;
|
|
100
|
+
lostHint: string;
|
|
101
|
+
newTextCopied: string;
|
|
69
102
|
copyForAgentHint: string;
|
|
70
103
|
loadingContext: string;
|
|
71
104
|
edit: string;
|
|
@@ -92,6 +125,12 @@ declare const EN: {
|
|
|
92
125
|
linkGone: string;
|
|
93
126
|
enterName: string;
|
|
94
127
|
writeFirst: string;
|
|
128
|
+
changeTextFirst: string;
|
|
129
|
+
tooManyImages: string;
|
|
130
|
+
imageUnreadable: string;
|
|
131
|
+
imageFailed: string;
|
|
132
|
+
noScreenshot: string;
|
|
133
|
+
suggestionTooLong: string;
|
|
95
134
|
commentTooLong: string;
|
|
96
135
|
replyTooLong: string;
|
|
97
136
|
postFailed: string;
|
|
@@ -146,6 +185,11 @@ interface NuniOptions {
|
|
|
146
185
|
locale?: string;
|
|
147
186
|
/** Replace any of the widget's words. See the `EN` messages. */
|
|
148
187
|
messages?: Partial<Messages>;
|
|
188
|
+
/**
|
|
189
|
+
* Let commenters attach images and mark up the screenshot. They are shown
|
|
190
|
+
* to everyone who can see the comment. Default true; claimed sites only.
|
|
191
|
+
*/
|
|
192
|
+
images?: boolean;
|
|
149
193
|
/**
|
|
150
194
|
* Extra context attached to each comment for the project owner and their
|
|
151
195
|
* coding agent. Everything is on by default; set a key to `false` to stop
|
|
@@ -182,12 +226,6 @@ interface ScreenshotOptions {
|
|
|
182
226
|
focus?: () => Box;
|
|
183
227
|
}
|
|
184
228
|
type CaptureScreenshot = (el: Element, options: ScreenshotOptions) => Promise<Blob | null>;
|
|
185
|
-
declare global {
|
|
186
|
-
interface Window {
|
|
187
|
-
/** Set by nuni-screenshot.global.js (the CDN build). */
|
|
188
|
-
__nuniScreenshot?: CaptureScreenshot;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
229
|
interface Box {
|
|
192
230
|
left: number;
|
|
193
231
|
top: number;
|
|
@@ -195,6 +233,49 @@ interface Box {
|
|
|
195
233
|
bottom: number;
|
|
196
234
|
}
|
|
197
235
|
//#endregion
|
|
236
|
+
//#region src/markup.d.ts
|
|
237
|
+
/**
|
|
238
|
+
* A small editor to draw boxes, arrows and freehand lines on a screenshot
|
|
239
|
+
* before it is attached. Loaded with the screenshot code, on demand.
|
|
240
|
+
*/
|
|
241
|
+
interface MarkUpLabels {
|
|
242
|
+
title: string;
|
|
243
|
+
box: string;
|
|
244
|
+
arrow: string;
|
|
245
|
+
pen: string;
|
|
246
|
+
undo: string;
|
|
247
|
+
cancel: string;
|
|
248
|
+
done: string;
|
|
249
|
+
/** Shown when the drawing can't be saved as an image. */
|
|
250
|
+
failed: string;
|
|
251
|
+
}
|
|
252
|
+
interface MarkUpOptions {
|
|
253
|
+
/** Where the editor opens: inside the widget's shadow root. */
|
|
254
|
+
container: HTMLElement;
|
|
255
|
+
labels: MarkUpLabels;
|
|
256
|
+
color: string;
|
|
257
|
+
}
|
|
258
|
+
/** Resolves with the marked-up image, or null when cancelled. */
|
|
259
|
+
type MarkUp = (image: Blob, options: MarkUpOptions) => Promise<Blob | null>;
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/tools.d.ts
|
|
262
|
+
/**
|
|
263
|
+
* The code loaded on demand once someone comments (a separate chunk for npm
|
|
264
|
+
* users, a separate script for the CDN build): taking the screenshot and
|
|
265
|
+
* marking it up.
|
|
266
|
+
*/
|
|
267
|
+
declare const screenshotTools: {
|
|
268
|
+
capture: CaptureScreenshot;
|
|
269
|
+
markUp: MarkUp;
|
|
270
|
+
};
|
|
271
|
+
type ScreenshotTools = typeof screenshotTools;
|
|
272
|
+
declare global {
|
|
273
|
+
interface Window {
|
|
274
|
+
/** Set by nuni-screenshot.global.js (the CDN build). */
|
|
275
|
+
__nuniScreenshot?: ScreenshotTools;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
198
279
|
//#region src/mount.d.ts
|
|
199
280
|
interface NuniInstance {
|
|
200
281
|
destroy(): void;
|