@nuniapp/widget 0.1.7 → 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 +94 -8
- package/dist/index.js +791 -162
- package/dist/nuni-screenshot.global.js +41 -7
- package/dist/nuni.global.js +116 -12
- 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,11 +25,18 @@ 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;
|
|
31
36
|
noResolved: string;
|
|
32
37
|
notFoundHere: string;
|
|
38
|
+
searchComments: string;
|
|
39
|
+
noMatches: string;
|
|
33
40
|
otherPages: string;
|
|
34
41
|
openOnPage: string;
|
|
35
42
|
replyCount: {
|
|
@@ -51,6 +58,25 @@ declare const EN: {
|
|
|
51
58
|
one: string;
|
|
52
59
|
other: string;
|
|
53
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;
|
|
54
80
|
pinLabel: string;
|
|
55
81
|
commentBy: string;
|
|
56
82
|
openScreenshot: string;
|
|
@@ -64,6 +90,15 @@ declare const EN: {
|
|
|
64
90
|
resolve: string;
|
|
65
91
|
reopen: string;
|
|
66
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;
|
|
67
102
|
copyForAgentHint: string;
|
|
68
103
|
loadingContext: string;
|
|
69
104
|
edit: string;
|
|
@@ -90,6 +125,12 @@ declare const EN: {
|
|
|
90
125
|
linkGone: string;
|
|
91
126
|
enterName: string;
|
|
92
127
|
writeFirst: string;
|
|
128
|
+
changeTextFirst: string;
|
|
129
|
+
tooManyImages: string;
|
|
130
|
+
imageUnreadable: string;
|
|
131
|
+
imageFailed: string;
|
|
132
|
+
noScreenshot: string;
|
|
133
|
+
suggestionTooLong: string;
|
|
93
134
|
commentTooLong: string;
|
|
94
135
|
replyTooLong: string;
|
|
95
136
|
postFailed: string;
|
|
@@ -105,7 +146,7 @@ type MessageKey = keyof typeof EN;
|
|
|
105
146
|
type Messages = { [K in MessageKey]: string | Plural; };
|
|
106
147
|
//#endregion
|
|
107
148
|
//#region src/config.d.ts
|
|
108
|
-
type Position = "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
149
|
+
type Position = "bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-center" | "top-left";
|
|
109
150
|
type Theme = "auto" | "light" | "dark";
|
|
110
151
|
/** Built-in page keys, for when `getPageKey` is not set. */
|
|
111
152
|
type PageKey = "path" | "path+search" | "path+hash";
|
|
@@ -125,7 +166,10 @@ interface NuniOptions {
|
|
|
125
166
|
* the path (default), the path and query string, or the path and hash.
|
|
126
167
|
*/
|
|
127
168
|
pageKey?: PageKey;
|
|
128
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Where the toolbar starts. Default "bottom-right". Visitors can drag it to
|
|
171
|
+
* another corner or edge middle, and their choice is remembered.
|
|
172
|
+
*/
|
|
129
173
|
position?: Position;
|
|
130
174
|
/** Brand color for buttons, pins and highlights (any CSS color). */
|
|
131
175
|
accentColor?: string;
|
|
@@ -141,6 +185,11 @@ interface NuniOptions {
|
|
|
141
185
|
locale?: string;
|
|
142
186
|
/** Replace any of the widget's words. See the `EN` messages. */
|
|
143
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;
|
|
144
193
|
/**
|
|
145
194
|
* Extra context attached to each comment for the project owner and their
|
|
146
195
|
* coding agent. Everything is on by default; set a key to `false` to stop
|
|
@@ -177,12 +226,6 @@ interface ScreenshotOptions {
|
|
|
177
226
|
focus?: () => Box;
|
|
178
227
|
}
|
|
179
228
|
type CaptureScreenshot = (el: Element, options: ScreenshotOptions) => Promise<Blob | null>;
|
|
180
|
-
declare global {
|
|
181
|
-
interface Window {
|
|
182
|
-
/** Set by nuni-screenshot.global.js (the CDN build). */
|
|
183
|
-
__nuniScreenshot?: CaptureScreenshot;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
229
|
interface Box {
|
|
187
230
|
left: number;
|
|
188
231
|
top: number;
|
|
@@ -190,6 +233,49 @@ interface Box {
|
|
|
190
233
|
bottom: number;
|
|
191
234
|
}
|
|
192
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
|
|
193
279
|
//#region src/mount.d.ts
|
|
194
280
|
interface NuniInstance {
|
|
195
281
|
destroy(): void;
|