visual-feedback 0.0.0-stage → 0.2.0
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/AGENTS.md +100 -0
- package/CHANGELOG.md +104 -0
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/dist/capture-CufRosj3.mjs +83 -0
- package/dist/dialog-B57CJ4I_.mjs +1374 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/keep-BG5WxZPU.mjs +74 -0
- package/dist/pdf-Gj-ZO9W5.mjs +85 -0
- package/dist/react.d.mts +6 -0
- package/dist/react.mjs +29 -0
- package/dist/report-BwLr1sYt.mjs +121 -0
- package/dist/report-CaJegYyg.d.mts +57 -0
- package/dist/report-CnspEsHf.mjs +95 -0
- package/dist/seen-BpIWldOP.mjs +21 -0
- package/dist/server.d.mts +14 -0
- package/dist/server.mjs +104 -0
- package/dist/widget-B1JDOVI1.mjs +355 -0
- package/dist/widget-Z1KOUKjx.d.mts +66 -0
- package/docs/API.md +147 -0
- package/docs/SPEC.md +214 -0
- package/docs/THEMING.md +72 -0
- package/docs/recipes/github-issues.md +209 -0
- package/docs/recipes/local.md +75 -0
- package/docs/recipes/postgres.md +114 -0
- package/docs/recipes/s3.md +129 -0
- package/docs/recipes/supabase.md +124 -0
- package/docs/recipes/vercel-blob.md +129 -0
- package/package.json +101 -4
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
|
|
2
|
+
import { a as FeedbackWidget, c as CaptureOptions, i as FeedbackStrings, n as FeedbackEvents, o as createFeedback, r as FeedbackOptions, s as discardDraft, t as FeedbackEvent } from "./widget-Z1KOUKjx.mjs";
|
|
3
|
+
import { n as Mark, r as Screenshot, t as FeedbackReport } from "./report-CaJegYyg.mjs";
|
|
4
|
+
export { type CaptureOptions, type FeedbackEvent, type FeedbackEvents, type FeedbackOptions, type FeedbackReport, type FeedbackStrings, type FeedbackWidget, type Mark, type Screenshot, createFeedback, discardDraft };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/keep.ts
|
|
3
|
+
/** A draft older than this is gone: the tab that kept it was most likely closed. */
|
|
4
|
+
const KEPT_FOR = 864e5;
|
|
5
|
+
function database() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const request = indexedDB.open("visual-feedback", 1);
|
|
8
|
+
request.onupgradeneeded = () => request.result.createObjectStore("drafts");
|
|
9
|
+
request.onsuccess = () => resolve(request.result);
|
|
10
|
+
request.onerror = () => reject(request.error);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
async function run(mode, work) {
|
|
14
|
+
const db = await database();
|
|
15
|
+
try {
|
|
16
|
+
return await new Promise((resolve, reject) => {
|
|
17
|
+
const transaction = db.transaction("drafts", mode);
|
|
18
|
+
const request = work(transaction.objectStore("drafts"));
|
|
19
|
+
transaction.oncomplete = () => resolve(request?.result);
|
|
20
|
+
transaction.onerror = () => reject(transaction.error);
|
|
21
|
+
transaction.onabort = () => reject(transaction.error);
|
|
22
|
+
});
|
|
23
|
+
} finally {
|
|
24
|
+
db.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function sweepIn(store) {
|
|
28
|
+
const cursors = store.openCursor();
|
|
29
|
+
cursors.onsuccess = () => {
|
|
30
|
+
const cursor = cursors.result;
|
|
31
|
+
if (!cursor) return;
|
|
32
|
+
if (cursor.value.saved < Date.now() - 864e5) cursor.delete();
|
|
33
|
+
cursor.continue();
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function sweep() {
|
|
37
|
+
return run("readwrite", (store) => {
|
|
38
|
+
sweepIn(store);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Stores the draft under `id`, and lets go of old ones. */
|
|
42
|
+
async function keep(id, draft) {
|
|
43
|
+
const shots = [];
|
|
44
|
+
for (const { image, ...shot } of draft.shots) shots.push({
|
|
45
|
+
...shot,
|
|
46
|
+
image: await image.arrayBuffer(),
|
|
47
|
+
type: image.type
|
|
48
|
+
});
|
|
49
|
+
const stored = {
|
|
50
|
+
...draft,
|
|
51
|
+
shots
|
|
52
|
+
};
|
|
53
|
+
return run("readwrite", (store) => {
|
|
54
|
+
store.put(stored, id);
|
|
55
|
+
sweepIn(store);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function read(id) {
|
|
59
|
+
const stored = await run("readonly", (store) => store.get(id));
|
|
60
|
+
if (!stored) return void 0;
|
|
61
|
+
const shots = stored.shots.map(({ image, type, ...shot }) => ({
|
|
62
|
+
...shot,
|
|
63
|
+
image: new Blob([image], { type })
|
|
64
|
+
}));
|
|
65
|
+
return {
|
|
66
|
+
...stored,
|
|
67
|
+
shots
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function forget(id) {
|
|
71
|
+
return run("readwrite", (store) => store.delete(id));
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { KEPT_FOR, forget, keep, read, sweep };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/pdf.ts
|
|
3
|
+
const WIDTH = 595.28;
|
|
4
|
+
/** A PDF 1.4 of JPEG pages, one per canvas, made in the browser with no library. */
|
|
5
|
+
async function pdf(pages, encode) {
|
|
6
|
+
const text = new TextEncoder();
|
|
7
|
+
const parts = [];
|
|
8
|
+
const offsets = [];
|
|
9
|
+
let size = 0;
|
|
10
|
+
const add = (part) => {
|
|
11
|
+
const bytes = typeof part === "string" ? text.encode(part) : part;
|
|
12
|
+
parts.push(bytes);
|
|
13
|
+
size += bytes.length;
|
|
14
|
+
};
|
|
15
|
+
const begin = (n, head) => {
|
|
16
|
+
offsets[n] = size;
|
|
17
|
+
add(`${n} 0 obj\n${head}\n`);
|
|
18
|
+
};
|
|
19
|
+
add("%PDF-1.4\n%âãÏÓ\n");
|
|
20
|
+
begin(1, "<< /Type /Catalog /Pages 2 0 R >>\nendobj");
|
|
21
|
+
begin(2, `<< /Type /Pages /Kids [${pages.map((_, i) => `${3 + 3 * i} 0 R`).join(" ")}] /Count ${pages.length} >>\nendobj`);
|
|
22
|
+
for (const [i, page] of pages.entries()) {
|
|
23
|
+
const { width, height: tall } = page;
|
|
24
|
+
const jpeg = new Uint8Array(await (await encode(page, {
|
|
25
|
+
type: "image/jpeg",
|
|
26
|
+
quality: .9
|
|
27
|
+
})).arrayBuffer());
|
|
28
|
+
const height = (tall * WIDTH / width).toFixed(2);
|
|
29
|
+
const n = 3 + 3 * i;
|
|
30
|
+
begin(n, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${WIDTH} ${height}] /Resources << /XObject << /I ${n + 2} 0 R >> >> /Contents ${n + 1} 0 R >>\nendobj`);
|
|
31
|
+
const draw = `q ${WIDTH} 0 0 ${height} 0 0 cm /I Do Q`;
|
|
32
|
+
begin(n + 1, `<< /Length ${draw.length} >>\nstream\n${draw}\nendstream\nendobj`);
|
|
33
|
+
begin(n + 2, `<< /Type /XObject /Subtype /Image /Width ${width} /Height ${tall} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream`);
|
|
34
|
+
add(jpeg);
|
|
35
|
+
add("\nendstream\nendobj\n");
|
|
36
|
+
}
|
|
37
|
+
const xref = size;
|
|
38
|
+
const count = offsets.length;
|
|
39
|
+
add(`xref\n0 ${count}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`);
|
|
40
|
+
add(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`);
|
|
41
|
+
return new Blob(parts, { type: "application/pdf" });
|
|
42
|
+
}
|
|
43
|
+
/** The first page: the category and the note, then the first screenshot under them, if any. */
|
|
44
|
+
function cover(note, category, image, font) {
|
|
45
|
+
const width = image?.width ?? 1200;
|
|
46
|
+
const scale = width / 1200;
|
|
47
|
+
const size = Math.round(22 * scale);
|
|
48
|
+
const line = Math.round(size * 1.4);
|
|
49
|
+
const margin = Math.round(40 * scale);
|
|
50
|
+
const page = document.createElement("canvas");
|
|
51
|
+
const context = page.getContext("2d");
|
|
52
|
+
if (!context) throw new Error("no 2d context");
|
|
53
|
+
const lines = [];
|
|
54
|
+
const wrap = (paragraph, weight) => {
|
|
55
|
+
context.font = `${weight} ${size}px ${font}`;
|
|
56
|
+
let row = "";
|
|
57
|
+
for (const char of paragraph) {
|
|
58
|
+
if (row && context.measureText(row + char).width > width - 2 * margin) {
|
|
59
|
+
const space = row.lastIndexOf(" ");
|
|
60
|
+
const cut = char !== " " && space > 0 ? space : row.length;
|
|
61
|
+
lines.push([weight, row.slice(0, cut)]);
|
|
62
|
+
row = row.slice(cut).trimStart();
|
|
63
|
+
}
|
|
64
|
+
row += char;
|
|
65
|
+
}
|
|
66
|
+
lines.push([weight, row]);
|
|
67
|
+
};
|
|
68
|
+
if (category) wrap(category, "600");
|
|
69
|
+
for (const paragraph of (note || " ").split("\n")) wrap(paragraph, "400");
|
|
70
|
+
const top = margin * 2 + lines.length * line;
|
|
71
|
+
page.width = width;
|
|
72
|
+
page.height = top + (image?.height ?? 0);
|
|
73
|
+
context.fillStyle = "#fff";
|
|
74
|
+
context.fillRect(0, 0, page.width, page.height);
|
|
75
|
+
context.fillStyle = "#111";
|
|
76
|
+
context.textBaseline = "top";
|
|
77
|
+
for (const [i, [weight, row]] of lines.entries()) {
|
|
78
|
+
context.font = `${weight} ${size}px ${font}`;
|
|
79
|
+
context.fillText(row, margin, margin + i * line);
|
|
80
|
+
}
|
|
81
|
+
if (image) context.drawImage(image, 0, top);
|
|
82
|
+
return page;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { cover, pdf };
|
package/dist/react.d.mts
ADDED
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { t as createFeedback } from "./widget-B1JDOVI1.mjs";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
//#region src/react/index.ts
|
|
5
|
+
function Feedback(props) {
|
|
6
|
+
const widget = useRef(null);
|
|
7
|
+
const shown = useRef(props);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const created = createFeedback(shown.current);
|
|
10
|
+
widget.current = created;
|
|
11
|
+
return () => {
|
|
12
|
+
created.destroy();
|
|
13
|
+
widget.current = null;
|
|
14
|
+
};
|
|
15
|
+
}, []);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const before = shown.current;
|
|
18
|
+
if (!widget.current || before === props) return;
|
|
19
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(props)]);
|
|
20
|
+
if ([...keys].every((key) => Object.is(before[key], props[key]))) return;
|
|
21
|
+
const next = { ...props };
|
|
22
|
+
for (const key of keys) if (!(key in props)) next[key] = void 0;
|
|
23
|
+
widget.current.update(next);
|
|
24
|
+
shown.current = props;
|
|
25
|
+
});
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { Feedback };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
|
|
2
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
3
|
+
function fail(path) {
|
|
4
|
+
throw new TypeError(`report.${path} has the wrong shape`);
|
|
5
|
+
}
|
|
6
|
+
function object(value, path) {
|
|
7
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
const UNSTORABLE = /\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
11
|
+
const storable = (text) => text.replace(UNSTORABLE, (found) => found === "\0" ? "" : "�");
|
|
12
|
+
function string(value, path) {
|
|
13
|
+
if (typeof value !== "string") fail(path);
|
|
14
|
+
return storable(value);
|
|
15
|
+
}
|
|
16
|
+
function storableDeep(value) {
|
|
17
|
+
if (typeof value === "string") return storable(value);
|
|
18
|
+
if (Array.isArray(value)) return value.map(storableDeep);
|
|
19
|
+
if (value === null || typeof value !== "object") return value;
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
22
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [storable(key), storableDeep(item)]).filter(([key]) => key !== "__proto__"));
|
|
23
|
+
}
|
|
24
|
+
function number(value, path) {
|
|
25
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail(path);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function point(value, path) {
|
|
29
|
+
if (!Array.isArray(value) || value.length !== 2) fail(path);
|
|
30
|
+
return [number(value[0], path), number(value[1], path)];
|
|
31
|
+
}
|
|
32
|
+
function mark(value, path) {
|
|
33
|
+
const fields = object(value, path);
|
|
34
|
+
if (fields.tool === "pen") {
|
|
35
|
+
if (!Array.isArray(fields.points)) fail(`${path}.points`);
|
|
36
|
+
return {
|
|
37
|
+
tool: "pen",
|
|
38
|
+
points: fields.points.map((item, i) => point(item, `${path}.points[${i}]`))
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (fields.tool === "box") return {
|
|
42
|
+
tool: "box",
|
|
43
|
+
x: number(fields.x, `${path}.x`),
|
|
44
|
+
y: number(fields.y, `${path}.y`),
|
|
45
|
+
width: number(fields.width, `${path}.width`),
|
|
46
|
+
height: number(fields.height, `${path}.height`)
|
|
47
|
+
};
|
|
48
|
+
if (fields.tool === "arrow") return {
|
|
49
|
+
tool: "arrow",
|
|
50
|
+
from: point(fields.from, `${path}.from`),
|
|
51
|
+
to: point(fields.to, `${path}.to`)
|
|
52
|
+
};
|
|
53
|
+
if (fields.tool === "text") {
|
|
54
|
+
const text = string(fields.text, `${path}.text`);
|
|
55
|
+
if ([...text].length > 280) fail(`${path}.text`);
|
|
56
|
+
return {
|
|
57
|
+
tool: "text",
|
|
58
|
+
x: number(fields.x, `${path}.x`),
|
|
59
|
+
y: number(fields.y, `${path}.y`),
|
|
60
|
+
text
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return fail(`${path}.tool`);
|
|
64
|
+
}
|
|
65
|
+
function facts(fields, path) {
|
|
66
|
+
const at = path ? `${path}.` : "";
|
|
67
|
+
const page = object(fields.page, `${at}page`);
|
|
68
|
+
const viewport = object(fields.viewport, `${at}viewport`);
|
|
69
|
+
const scroll = object(fields.scroll, `${at}scroll`);
|
|
70
|
+
return {
|
|
71
|
+
page: {
|
|
72
|
+
url: string(page.url, `${at}page.url`),
|
|
73
|
+
title: string(page.title, `${at}page.title`)
|
|
74
|
+
},
|
|
75
|
+
viewport: {
|
|
76
|
+
width: number(viewport.width, `${at}viewport.width`),
|
|
77
|
+
height: number(viewport.height, `${at}viewport.height`),
|
|
78
|
+
devicePixelRatio: number(viewport.devicePixelRatio, `${at}viewport.devicePixelRatio`)
|
|
79
|
+
},
|
|
80
|
+
scroll: {
|
|
81
|
+
x: number(scroll.x, `${at}scroll.x`),
|
|
82
|
+
y: number(scroll.y, `${at}scroll.y`)
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function screenshot(value, path) {
|
|
87
|
+
const fields = object(value, path);
|
|
88
|
+
if (fields.image !== void 0 && fields.image !== null) fail(`${path}.image`);
|
|
89
|
+
if (!Array.isArray(fields.marks)) fail(`${path}.marks`);
|
|
90
|
+
return {
|
|
91
|
+
marks: fields.marks.map((item, i) => mark(item, `${path}.marks[${i}]`)),
|
|
92
|
+
...facts(fields, path)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The one shape check for a report without its images, as JSON carries it. Returns a copy that
|
|
97
|
+
* holds only the known fields, or throws a TypeError that names the first wrong field.
|
|
98
|
+
*/
|
|
99
|
+
function checkReport(value) {
|
|
100
|
+
const fields = object(value, "");
|
|
101
|
+
if (typeof fields.id !== "string" || !UUID_V4.test(fields.id)) fail("id");
|
|
102
|
+
const createdAt = string(fields.createdAt, "createdAt");
|
|
103
|
+
const time = Date.parse(createdAt);
|
|
104
|
+
if (!(time >= 0 && time < Date.UTC(1e4, 0, 1)) || new Date(time).toISOString() !== createdAt) fail("createdAt");
|
|
105
|
+
const shots = fields.screenshots;
|
|
106
|
+
if (!Array.isArray(shots) || shots.length > 10) fail("screenshots");
|
|
107
|
+
return {
|
|
108
|
+
id: fields.id,
|
|
109
|
+
createdAt,
|
|
110
|
+
note: string(fields.note, "note"),
|
|
111
|
+
...fields.category === void 0 ? {} : { category: string(fields.category, "category") },
|
|
112
|
+
screenshots: shots.map((item, i) => screenshot(item, `screenshots[${i}]`)),
|
|
113
|
+
...facts(fields, ""),
|
|
114
|
+
userAgent: string(fields.userAgent, "userAgent"),
|
|
115
|
+
language: string(fields.language, "language"),
|
|
116
|
+
timeZone: string(fields.timeZone, "timeZone"),
|
|
117
|
+
context: storableDeep(object(fields.context, "context"))
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
export { checkReport as t };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/shared/report.d.ts
|
|
3
|
+
type Mark = {
|
|
4
|
+
tool: "pen";
|
|
5
|
+
points: [number, number][];
|
|
6
|
+
} | {
|
|
7
|
+
tool: "box";
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
} | {
|
|
13
|
+
tool: "arrow";
|
|
14
|
+
from: [number, number];
|
|
15
|
+
to: [number, number];
|
|
16
|
+
} | {
|
|
17
|
+
tool: "text";
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
text: string;
|
|
21
|
+
};
|
|
22
|
+
type Page = {
|
|
23
|
+
url: string;
|
|
24
|
+
title: string;
|
|
25
|
+
};
|
|
26
|
+
type Viewport = {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
devicePixelRatio: number;
|
|
30
|
+
};
|
|
31
|
+
type Scroll = {
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
};
|
|
35
|
+
type Screenshot = {
|
|
36
|
+
image: Blob;
|
|
37
|
+
marks: Mark[];
|
|
38
|
+
page: Page;
|
|
39
|
+
viewport: Viewport;
|
|
40
|
+
scroll: Scroll;
|
|
41
|
+
};
|
|
42
|
+
type FeedbackReport = {
|
|
43
|
+
id: string;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
note: string;
|
|
46
|
+
category?: string;
|
|
47
|
+
screenshots: Screenshot[];
|
|
48
|
+
page: Page;
|
|
49
|
+
viewport: Viewport;
|
|
50
|
+
scroll: Scroll;
|
|
51
|
+
userAgent: string;
|
|
52
|
+
language: string;
|
|
53
|
+
timeZone: string;
|
|
54
|
+
context: Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { Mark as n, Screenshot as r, FeedbackReport as t };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
import { t as checkReport } from "./report-BwLr1sYt.mjs";
|
|
3
|
+
//#region src/report.ts
|
|
4
|
+
function uuid() {
|
|
5
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
6
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 64;
|
|
7
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
8
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
9
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
10
|
+
}
|
|
11
|
+
function pageUrl(href, redact) {
|
|
12
|
+
if (!redact) return href;
|
|
13
|
+
const url = new URL(href);
|
|
14
|
+
url.search = "";
|
|
15
|
+
url.hash = "";
|
|
16
|
+
return url.href;
|
|
17
|
+
}
|
|
18
|
+
let fromHost;
|
|
19
|
+
async function host(call) {
|
|
20
|
+
try {
|
|
21
|
+
return await call();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
fromHost = error;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The host's own error message, the only one the user is shown. */
|
|
28
|
+
function shown(error) {
|
|
29
|
+
if (error !== fromHost) return void 0;
|
|
30
|
+
const message = typeof error === "string" ? error : error?.message;
|
|
31
|
+
return typeof message === "string" && message ? message : void 0;
|
|
32
|
+
}
|
|
33
|
+
/** Builds the report and checks it with the same shape check the server uses. */
|
|
34
|
+
async function build(draft, shots, seen, settings) {
|
|
35
|
+
const context = await host(() => settings.context?.()) ?? {};
|
|
36
|
+
const facts = ({ url, title, viewport, scroll }) => ({
|
|
37
|
+
page: {
|
|
38
|
+
url: pageUrl(url, Boolean(settings.redactUrl)),
|
|
39
|
+
title
|
|
40
|
+
},
|
|
41
|
+
viewport,
|
|
42
|
+
scroll
|
|
43
|
+
});
|
|
44
|
+
const report = {
|
|
45
|
+
id: uuid(),
|
|
46
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47
|
+
note: draft.note,
|
|
48
|
+
...draft.category === void 0 ? {} : { category: draft.category },
|
|
49
|
+
screenshots: shots.map((shot) => ({
|
|
50
|
+
marks: shot.marks,
|
|
51
|
+
...facts(shot.seen)
|
|
52
|
+
})),
|
|
53
|
+
...facts(seen),
|
|
54
|
+
userAgent: navigator.userAgent,
|
|
55
|
+
language: navigator.language,
|
|
56
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
57
|
+
context
|
|
58
|
+
};
|
|
59
|
+
const checked = checkReport(report);
|
|
60
|
+
const screenshots = checked.screenshots.map((shot, i) => ({
|
|
61
|
+
...shot,
|
|
62
|
+
image: shots[i].image
|
|
63
|
+
}));
|
|
64
|
+
return {
|
|
65
|
+
...checked,
|
|
66
|
+
screenshots
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Sends with onSubmit, or else POSTs what parseFeedbackRequest reads to the endpoint. */
|
|
70
|
+
async function deliver(report, settings) {
|
|
71
|
+
const { onSubmit, endpoint } = settings;
|
|
72
|
+
if (onSubmit) return host(() => onSubmit(report));
|
|
73
|
+
if (!endpoint) throw new Error("Neither onSubmit nor endpoint is set.");
|
|
74
|
+
const body = new FormData();
|
|
75
|
+
const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
|
|
76
|
+
body.append("report", JSON.stringify({
|
|
77
|
+
...report,
|
|
78
|
+
screenshots
|
|
79
|
+
}));
|
|
80
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
81
|
+
const extension = image.type === "image/png" ? "png" : "webp";
|
|
82
|
+
body.append(`image${i}`, image, `${report.id}-${i}.${extension}`);
|
|
83
|
+
}
|
|
84
|
+
const { headers: given } = settings;
|
|
85
|
+
const headers = typeof given === "function" ? await host(given) : given ?? {};
|
|
86
|
+
const response = await fetch(endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
body,
|
|
89
|
+
headers,
|
|
90
|
+
redirect: "manual"
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) throw new Error(`The endpoint answered ${response.status}.`);
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { build, deliver, shown };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/seen.ts
|
|
3
|
+
/** What the page looked like when each screenshot was taken. */
|
|
4
|
+
const shots = /* @__PURE__ */ new WeakMap();
|
|
5
|
+
function look() {
|
|
6
|
+
return {
|
|
7
|
+
url: location.href,
|
|
8
|
+
title: document.title,
|
|
9
|
+
viewport: {
|
|
10
|
+
width: innerWidth,
|
|
11
|
+
height: innerHeight,
|
|
12
|
+
devicePixelRatio
|
|
13
|
+
},
|
|
14
|
+
scroll: {
|
|
15
|
+
x: scrollX,
|
|
16
|
+
y: scrollY
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { shots as n, look as t };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
import { n as Mark, r as Screenshot, t as FeedbackReport } from "./report-CaJegYyg.mjs";
|
|
3
|
+
//#region src/server/index.d.ts
|
|
4
|
+
export declare class FeedbackRequestError extends Error {
|
|
5
|
+
readonly status: 400 | 413;
|
|
6
|
+
constructor(status: 400 | 413, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function parseFeedbackRequest(request: Request, limits?: {
|
|
9
|
+
maxImageBytes?: number;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
report: FeedbackReport;
|
|
12
|
+
}>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export type { FeedbackReport, Mark, Screenshot };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
|
|
2
|
+
import { t as checkReport } from "./report-BwLr1sYt.mjs";
|
|
3
|
+
//#region src/server/index.ts
|
|
4
|
+
const DEFAULT_MAX_IMAGE_BYTES = 1e7;
|
|
5
|
+
const REPORT_BYTES = 1048576;
|
|
6
|
+
const FRAMING_BYTES = 65536;
|
|
7
|
+
const IMAGE_PART = /^image\d+$/;
|
|
8
|
+
const PNG = [
|
|
9
|
+
137,
|
|
10
|
+
80,
|
|
11
|
+
78,
|
|
12
|
+
71,
|
|
13
|
+
13,
|
|
14
|
+
10,
|
|
15
|
+
26,
|
|
16
|
+
10
|
|
17
|
+
];
|
|
18
|
+
var FeedbackRequestError = class extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(status, message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "FeedbackRequestError";
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
async function read(request, limit) {
|
|
27
|
+
if (Number(request.headers.get("content-length")) > limit) throw new FeedbackRequestError(413, "The request is too large.");
|
|
28
|
+
if (!request.body) throw new FeedbackRequestError(400, "The request has no body.");
|
|
29
|
+
const reader = request.body.getReader();
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let size = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
let next;
|
|
34
|
+
try {
|
|
35
|
+
next = await reader.read();
|
|
36
|
+
} catch {
|
|
37
|
+
throw new FeedbackRequestError(400, "The request body broke off.");
|
|
38
|
+
}
|
|
39
|
+
const { done, value } = next;
|
|
40
|
+
if (done) return new Blob(chunks);
|
|
41
|
+
size += value.byteLength;
|
|
42
|
+
if (size > limit) {
|
|
43
|
+
await reader.cancel().catch(() => {});
|
|
44
|
+
throw new FeedbackRequestError(413, "The request is too large.");
|
|
45
|
+
}
|
|
46
|
+
chunks.push(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function imageType(bytes) {
|
|
50
|
+
if (PNG.every((byte, i) => bytes[i] === byte)) return "image/png";
|
|
51
|
+
const header = String.fromCharCode(...bytes.subarray(0, 12));
|
|
52
|
+
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP") return "image/webp";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
async function checkImage(part, maxImageBytes) {
|
|
56
|
+
if (part.size > maxImageBytes) throw new FeedbackRequestError(413, "The image is too large.");
|
|
57
|
+
const type = imageType(new Uint8Array(await part.slice(0, 12).arrayBuffer()));
|
|
58
|
+
if (!type) throw new FeedbackRequestError(400, "The image is not a PNG or WebP.");
|
|
59
|
+
return part.slice(0, part.size, type);
|
|
60
|
+
}
|
|
61
|
+
async function parseFeedbackRequest(request, limits = {}) {
|
|
62
|
+
const maxImageBytes = limits.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
|
|
63
|
+
if (!(maxImageBytes >= 0)) throw new RangeError("maxImageBytes must be a number of bytes.");
|
|
64
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
65
|
+
if (!/^multipart\/form-data\s*;/i.test(contentType)) throw new FeedbackRequestError(400, "The request is not multipart/form-data.");
|
|
66
|
+
const body = await read(request, 10 * maxImageBytes + REPORT_BYTES + FRAMING_BYTES);
|
|
67
|
+
let form;
|
|
68
|
+
try {
|
|
69
|
+
form = await new Response(body, { headers: { "content-type": contentType } }).formData();
|
|
70
|
+
} catch {
|
|
71
|
+
throw new FeedbackRequestError(400, "The multipart body cannot be read.");
|
|
72
|
+
}
|
|
73
|
+
const reports = form.getAll("report");
|
|
74
|
+
const [json] = reports;
|
|
75
|
+
if (reports.length !== 1 || typeof json !== "string") throw new FeedbackRequestError(400, "The request needs one report part, as text.");
|
|
76
|
+
if (form.has("image")) throw new FeedbackRequestError(400, "The request has an image part named image; visual-feedback 0.2 sends image0, image1 and on.");
|
|
77
|
+
if (new Blob([json]).size > REPORT_BYTES) throw new FeedbackRequestError(413, "The report is too large.");
|
|
78
|
+
let fields;
|
|
79
|
+
try {
|
|
80
|
+
fields = checkReport(JSON.parse(json, (key, value) => key === "__proto__" ? void 0 : value));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new FeedbackRequestError(400, error instanceof Error ? error.message : "Bad report.");
|
|
83
|
+
}
|
|
84
|
+
const count = fields.screenshots.length;
|
|
85
|
+
const expected = new Set(Array.from({ length: count }, (_, i) => `image${i}`));
|
|
86
|
+
for (const name of form.keys()) if (IMAGE_PART.test(name) && !expected.has(name)) throw new FeedbackRequestError(400, `The request has an image part, ${name}, with no screenshot.`);
|
|
87
|
+
const images = [];
|
|
88
|
+
for (let i = 0; i < count; i++) {
|
|
89
|
+
const parts = form.getAll(`image${i}`);
|
|
90
|
+
const [part] = parts;
|
|
91
|
+
if (parts.length !== 1 || typeof part === "string" || !part) throw new FeedbackRequestError(400, `The request needs one image${i} part, as a file.`);
|
|
92
|
+
images.push(await checkImage(part, maxImageBytes));
|
|
93
|
+
}
|
|
94
|
+
const screenshots = fields.screenshots.map((shot, i) => ({
|
|
95
|
+
...shot,
|
|
96
|
+
image: images[i]
|
|
97
|
+
}));
|
|
98
|
+
return { report: {
|
|
99
|
+
...fields,
|
|
100
|
+
screenshots
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { FeedbackRequestError, parseFeedbackRequest };
|