@nuniapp/widget 0.1.8 → 0.1.10

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.
@@ -1,4 +1,215 @@
1
- import { i as LIMITS, n as MASK_ATTRIBUTE, t as CAPTURE_MARK } from "./mark-B8wojiFu.js";
1
+ import { d as CAPTURE_MARK, f as MASK_ATTRIBUTE, l as encodeCanvas, m as LIMITS, r as h } from "./dom-BWcT-q7S.js";
2
+ //#region src/markup.ts
3
+ const STYLES = `
4
+ .mk {
5
+ position: fixed;
6
+ inset: 0;
7
+ display: grid;
8
+ place-items: center;
9
+ padding: 16px;
10
+ background: rgb(0 0 0 / 0.55);
11
+ pointer-events: auto;
12
+ z-index: 10;
13
+ }
14
+ .mk-box {
15
+ display: grid;
16
+ gap: 10px;
17
+ max-width: 100%;
18
+ padding: 10px;
19
+ border-radius: 14px;
20
+ background: var(--n-bg);
21
+ color: var(--n-fg);
22
+ box-shadow: var(--n-shadow);
23
+ }
24
+ .mk-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
25
+ .mk-bar .spacer { flex: 1; }
26
+ .mk-error { margin: 0; color: var(--n-danger); font-size: 13px; }
27
+ .mk-error:empty { display: none; }
28
+ .mk-bar [aria-pressed="true"] { background: var(--n-accent-soft); border-color: var(--n-accent); }
29
+ .mk-canvas {
30
+ display: block;
31
+ max-width: min(960px, calc(100vw - 52px));
32
+ max-height: calc(100vh - 120px);
33
+ border-radius: 8px;
34
+ cursor: crosshair;
35
+ touch-action: none;
36
+ }
37
+ `;
38
+ function lineWidth(canvas) {
39
+ return Math.max(3, Math.round(Math.max(canvas.width, canvas.height) / 250));
40
+ }
41
+ function drawShape(ctx, shape, width) {
42
+ const [a, b] = shape.points;
43
+ if (!a) return;
44
+ ctx.beginPath();
45
+ if (shape.tool === "pen") {
46
+ ctx.moveTo(a.x, a.y);
47
+ for (const p of shape.points.slice(1)) ctx.lineTo(p.x, p.y);
48
+ } else if (b && shape.tool === "box") ctx.rect(a.x, a.y, b.x - a.x, b.y - a.y);
49
+ else if (b) {
50
+ const angle = Math.atan2(b.y - a.y, b.x - a.x);
51
+ const head = width * 4;
52
+ ctx.moveTo(a.x, a.y);
53
+ ctx.lineTo(b.x, b.y);
54
+ for (const side of [-1, 1]) {
55
+ ctx.moveTo(b.x, b.y);
56
+ ctx.lineTo(b.x - head * Math.cos(angle + side * Math.PI / 7), b.y - head * Math.sin(angle + side * Math.PI / 7));
57
+ }
58
+ }
59
+ ctx.stroke();
60
+ }
61
+ /** Big enough to be meant, not a stray click. */
62
+ function isDrawn(shape) {
63
+ const [a, b] = shape.points;
64
+ if (!a || !b) return false;
65
+ if (shape.tool === "pen") return shape.points.length > 2;
66
+ return Math.hypot(b.x - a.x, b.y - a.y) > 6;
67
+ }
68
+ const markUp = async (image, { container, labels, color }) => {
69
+ let bitmap;
70
+ try {
71
+ bitmap = await createImageBitmap(image);
72
+ } catch {
73
+ return null;
74
+ }
75
+ const root = container.getRootNode();
76
+ if (!root.querySelector("style[data-nuni-markup]")) {
77
+ const style = h("style", { "data-nuni-markup": "" }, STYLES);
78
+ (root instanceof Document ? root.head : root).append(style);
79
+ }
80
+ const canvas = h("canvas", {
81
+ class: "mk-canvas",
82
+ width: bitmap.width,
83
+ height: bitmap.height,
84
+ role: "img",
85
+ "aria-label": labels.title
86
+ });
87
+ const ctx = canvas.getContext("2d");
88
+ if (!ctx) {
89
+ bitmap.close();
90
+ return null;
91
+ }
92
+ const width = lineWidth(canvas);
93
+ const shapes = [];
94
+ let current = null;
95
+ let tool = "box";
96
+ const draw = () => {
97
+ ctx.drawImage(bitmap, 0, 0);
98
+ ctx.strokeStyle = color;
99
+ ctx.lineWidth = width;
100
+ ctx.lineCap = "round";
101
+ ctx.lineJoin = "round";
102
+ for (const shape of current ? [...shapes, current] : shapes) drawShape(ctx, shape, width);
103
+ undo.disabled = shapes.length === 0;
104
+ };
105
+ const point = (e) => {
106
+ const rect = canvas.getBoundingClientRect();
107
+ return {
108
+ x: (e.clientX - rect.left) / rect.width * canvas.width,
109
+ y: (e.clientY - rect.top) / rect.height * canvas.height
110
+ };
111
+ };
112
+ canvas.addEventListener("pointerdown", (e) => {
113
+ if (!e.isPrimary) return;
114
+ e.preventDefault();
115
+ canvas.setPointerCapture(e.pointerId);
116
+ const p = point(e);
117
+ current = {
118
+ tool,
119
+ points: tool === "pen" ? [p] : [p, p]
120
+ };
121
+ draw();
122
+ });
123
+ canvas.addEventListener("pointermove", (e) => {
124
+ if (!current) return;
125
+ const p = point(e);
126
+ if (current.tool === "pen") current.points.push(p);
127
+ else current.points[1] = p;
128
+ draw();
129
+ });
130
+ const finish = () => {
131
+ if (current && isDrawn(current)) shapes.push(current);
132
+ current = null;
133
+ draw();
134
+ };
135
+ canvas.addEventListener("pointerup", finish);
136
+ canvas.addEventListener("pointercancel", finish);
137
+ const toolButtons = [
138
+ "box",
139
+ "arrow",
140
+ "pen"
141
+ ].map((name) => h("button", {
142
+ class: "btn",
143
+ type: "button",
144
+ "aria-pressed": String(name === tool),
145
+ onclick: () => {
146
+ tool = name;
147
+ for (const b of toolButtons) b.setAttribute("aria-pressed", String(b.dataset.tool === tool));
148
+ },
149
+ "data-tool": name
150
+ }, labels[name]));
151
+ const undo = h("button", {
152
+ class: "btn btn-ghost",
153
+ type: "button",
154
+ onclick: () => {
155
+ shapes.pop();
156
+ draw();
157
+ }
158
+ }, labels.undo);
159
+ const previous = root.activeElement;
160
+ return new Promise((resolve) => {
161
+ let closing = false;
162
+ const close = async (save) => {
163
+ if (closing) return;
164
+ closing = true;
165
+ const result = save ? await encodeCanvas(canvas, LIMITS.imageMaxBytes) : null;
166
+ if (save && !result) {
167
+ closing = false;
168
+ error.textContent = labels.failed;
169
+ return;
170
+ }
171
+ overlay.remove();
172
+ bitmap.close();
173
+ previous?.focus();
174
+ resolve(result);
175
+ };
176
+ const error = h("p", {
177
+ class: "mk-error",
178
+ role: "alert"
179
+ });
180
+ const done = h("button", {
181
+ class: "btn btn-primary",
182
+ type: "button",
183
+ onclick: () => void close(true)
184
+ }, labels.done);
185
+ const overlay = h("div", {
186
+ class: "mk",
187
+ role: "dialog",
188
+ "aria-modal": "true",
189
+ "aria-label": labels.title,
190
+ onkeydown: (e) => {
191
+ const key = e;
192
+ if (key.key === "Escape") close(false);
193
+ else if (key.key === "z" && (key.metaKey || key.ctrlKey)) {
194
+ shapes.pop();
195
+ draw();
196
+ } else return;
197
+ key.preventDefault();
198
+ }
199
+ }, h("div", { class: "mk-box" }, h("div", {
200
+ class: "mk-bar",
201
+ role: "toolbar",
202
+ "aria-label": labels.title
203
+ }, ...toolButtons, undo, h("span", { class: "spacer" }), h("button", {
204
+ class: "btn btn-ghost",
205
+ type: "button",
206
+ onclick: () => void close(false)
207
+ }, labels.cancel), done), error, canvas));
208
+ container.append(overlay);
209
+ draw();
210
+ done.focus();
211
+ });
212
+ };
2
213
  "p".charCodeAt(0);
3
214
  "H".charCodeAt(0);
4
215
  "Y".charCodeAt(0);
@@ -1223,9 +1434,6 @@ function maskedBoxes(frame) {
1223
1434
  }
1224
1435
  return out;
1225
1436
  }
1226
- function toBlob(canvas, type, quality) {
1227
- return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
1228
- }
1229
1437
  const captureScreenshot = async (el, options) => {
1230
1438
  const frame = frameFor(el);
1231
1439
  const { box, target, masks, crop } = isolatedLayout(frame, el, options.focus);
@@ -1274,15 +1482,18 @@ const captureScreenshot = async (el, options) => {
1274
1482
  ctx.strokeStyle = options.accent;
1275
1483
  ctx.lineWidth = line;
1276
1484
  if (x1 > x0 && y1 > y0) ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
1277
- for (const [type, quality] of [
1278
- ["image/webp", .82],
1279
- ["image/jpeg", .8],
1280
- ["image/jpeg", .55]
1281
- ]) {
1282
- const blob = await toBlob(canvas, type, quality);
1283
- if (blob && blob.type === type && blob.size <= LIMITS.screenshotMaxBytes) return blob;
1284
- }
1285
- return null;
1485
+ return encodeCanvas(canvas, LIMITS.screenshotMaxBytes);
1486
+ };
1487
+ //#endregion
1488
+ //#region src/screenshot-tools.ts
1489
+ /**
1490
+ * The code loaded on demand once someone comments (a separate chunk for npm
1491
+ * users, a separate script for the CDN build): taking the screenshot and
1492
+ * marking it up.
1493
+ */
1494
+ const screenshotTools = {
1495
+ capture: captureScreenshot,
1496
+ markUp
1286
1497
  };
1287
1498
  //#endregion
1288
- export { captureScreenshot };
1499
+ export { screenshotTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuniapp/widget",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Pin comments to anything on your site. Talk it through with your team.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,90 +0,0 @@
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
- /** How long a `nuni login` code waits for approval in the dashboard. */
42
- cliLoginTtlMs: 6e5,
43
- anchorTextMaxLength: 120,
44
- /** Selected text kept with a text comment, and the context around it. */
45
- quoteMaxLength: 500,
46
- quoteContextLength: 32,
47
- /** Per page, the widget loads this many newest open and resolved comments. */
48
- pageOpenLimit: 1e3,
49
- pageResolvedLimit: 200,
50
- /** Most console or network entries kept with one comment. */
51
- contextEntryMax: 20,
52
- contextMessageMaxLength: 500,
53
- /** Trimmed outerHTML of the commented element. */
54
- domSnippetMaxLength: 4e3,
55
- screenshotMaxBytes: 6e5,
56
- /** Replies kept per comment thread. */
57
- repliesPerComment: 200,
58
- /** Reactions kept per thread (the comment and its replies). */
59
- reactionsPerComment: 2e3,
60
- /** How long after posting the author can still attach the screenshot. */
61
- screenshotUploadWindowMs: 3e5
62
- };
63
- /** The reactions anyone can add to a comment or reply. */
64
- const REACTIONS = [
65
- "👍",
66
- "❤️",
67
- "🎉",
68
- "👀",
69
- "✅",
70
- "😄"
71
- ];
72
- //#endregion
73
- //#region ../shared/src/privacy.ts
74
- /**
75
- * Site owners mark private areas with this attribute. Their text never
76
- * reaches Nuni: not in the pin anchor (which is public), the markup snippet
77
- * or the screenshot.
78
- */
79
- const MASK_ATTRIBUTE = "data-nuni-mask";
80
- const MASK_SELECTOR = `[${MASK_ATTRIBUTE}]`;
81
- //#endregion
82
- //#region src/mark.ts
83
- /**
84
- * Marks Nuni's own screenshot requests (fonts and images it inlines), so the
85
- * network collector can skip them without muting the page. `Symbol.for` is
86
- * shared by the widget and the separately loaded screenshot script.
87
- */
88
- const CAPTURE_MARK = Symbol.for("nuni.capture");
89
- //#endregion
90
- export { REACTIONS as a, LIMITS as i, MASK_ATTRIBUTE as n, generateSecret as o, MASK_SELECTOR as r, isProjectId as s, CAPTURE_MARK as t };