visual-feedback 0.1.0 → 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 +11 -7
- package/CHANGELOG.md +91 -0
- package/README.md +5 -3
- package/dist/dialog-B57CJ4I_.mjs +1374 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +1 -1
- package/dist/keep-BG5WxZPU.mjs +74 -0
- package/dist/pdf-Gj-ZO9W5.mjs +85 -0
- package/dist/react.d.mts +1 -1
- package/dist/react.mjs +1 -1
- package/dist/{report-BxonlIdW.mjs → report-BwLr1sYt.mjs} +45 -21
- package/dist/{report-CIgy7uzP.d.mts → report-CaJegYyg.d.mts} +30 -16
- package/dist/{report-BRZX6sWL.mjs → report-CnspEsHf.mjs} +27 -15
- package/dist/server.d.mts +2 -3
- package/dist/server.mjs +22 -13
- package/dist/{widget-CVnusNlY.mjs → widget-B1JDOVI1.mjs} +102 -25
- package/dist/{widget-Ca1heF8n.d.mts → widget-Z1KOUKjx.d.mts} +14 -4
- package/docs/API.md +59 -31
- package/docs/SPEC.md +113 -27
- package/docs/THEMING.md +16 -9
- package/docs/recipes/github-issues.md +44 -20
- package/docs/recipes/local.md +17 -13
- package/docs/recipes/postgres.md +42 -20
- package/docs/recipes/s3.md +15 -10
- package/docs/recipes/supabase.md +18 -18
- package/docs/recipes/vercel-blob.md +18 -12
- package/package.json +6 -3
- package/dist/dialog-BTY6FKLo.mjs +0 -485
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
|
|
2
|
-
import { a as FeedbackWidget, i as FeedbackStrings, n as FeedbackEvents, o as createFeedback, r as FeedbackOptions, s as
|
|
3
|
-
import { n as Mark, t as FeedbackReport } from "./report-
|
|
4
|
-
export { type CaptureOptions, type FeedbackEvent, type FeedbackEvents, type FeedbackOptions, type FeedbackReport, type FeedbackStrings, type FeedbackWidget, type Mark, createFeedback };
|
|
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
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
|
|
2
|
-
export { t as createFeedback } from "./widget-
|
|
2
|
+
export { t as createFeedback, n as discardDraft } from "./widget-B1JDOVI1.mjs";
|
|
@@ -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
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { r as FeedbackOptions } from "./widget-
|
|
2
|
+
import { r as FeedbackOptions } from "./widget-Z1KOUKjx.mjs";
|
|
3
3
|
//#region src/react/index.d.ts
|
|
4
4
|
export type FeedbackProps = FeedbackOptions;
|
|
5
5
|
export declare function Feedback(props: FeedbackProps): null;
|
package/dist/react.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
|
|
2
|
-
//#region src/shared/report.ts
|
|
3
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}$/;
|
|
4
3
|
function fail(path) {
|
|
5
4
|
throw new TypeError(`report.${path} has the wrong shape`);
|
|
@@ -51,10 +50,50 @@ function mark(value, path) {
|
|
|
51
50
|
from: point(fields.from, `${path}.from`),
|
|
52
51
|
to: point(fields.to, `${path}.to`)
|
|
53
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
|
+
}
|
|
54
63
|
return fail(`${path}.tool`);
|
|
55
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
|
+
}
|
|
56
95
|
/**
|
|
57
|
-
* The one shape check for a report without its
|
|
96
|
+
* The one shape check for a report without its images, as JSON carries it. Returns a copy that
|
|
58
97
|
* holds only the known fields, or throws a TypeError that names the first wrong field.
|
|
59
98
|
*/
|
|
60
99
|
function checkReport(value) {
|
|
@@ -63,30 +102,15 @@ function checkReport(value) {
|
|
|
63
102
|
const createdAt = string(fields.createdAt, "createdAt");
|
|
64
103
|
const time = Date.parse(createdAt);
|
|
65
104
|
if (!(time >= 0 && time < Date.UTC(1e4, 0, 1)) || new Date(time).toISOString() !== createdAt) fail("createdAt");
|
|
66
|
-
|
|
67
|
-
if (!Array.isArray(
|
|
68
|
-
const page = object(fields.page, "page");
|
|
69
|
-
const viewport = object(fields.viewport, "viewport");
|
|
70
|
-
const scroll = object(fields.scroll, "scroll");
|
|
105
|
+
const shots = fields.screenshots;
|
|
106
|
+
if (!Array.isArray(shots) || shots.length > 10) fail("screenshots");
|
|
71
107
|
return {
|
|
72
108
|
id: fields.id,
|
|
73
109
|
createdAt,
|
|
74
110
|
note: string(fields.note, "note"),
|
|
75
111
|
...fields.category === void 0 ? {} : { category: string(fields.category, "category") },
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
url: string(page.url, "page.url"),
|
|
79
|
-
title: string(page.title, "page.title")
|
|
80
|
-
},
|
|
81
|
-
viewport: {
|
|
82
|
-
width: number(viewport.width, "viewport.width"),
|
|
83
|
-
height: number(viewport.height, "viewport.height"),
|
|
84
|
-
devicePixelRatio: number(viewport.devicePixelRatio, "viewport.devicePixelRatio")
|
|
85
|
-
},
|
|
86
|
-
scroll: {
|
|
87
|
-
x: number(scroll.x, "scroll.x"),
|
|
88
|
-
y: number(scroll.y, "scroll.y")
|
|
89
|
-
},
|
|
112
|
+
screenshots: shots.map((item, i) => screenshot(item, `screenshots[${i}]`)),
|
|
113
|
+
...facts(fields, ""),
|
|
90
114
|
userAgent: string(fields.userAgent, "userAgent"),
|
|
91
115
|
language: string(fields.language, "language"),
|
|
92
116
|
timeZone: string(fields.timeZone, "timeZone"),
|
|
@@ -13,31 +13,45 @@ type Mark = {
|
|
|
13
13
|
tool: "arrow";
|
|
14
14
|
from: [number, number];
|
|
15
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;
|
|
16
41
|
};
|
|
17
42
|
type FeedbackReport = {
|
|
18
43
|
id: string;
|
|
19
44
|
createdAt: string;
|
|
20
45
|
note: string;
|
|
21
46
|
category?: string;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
title: string;
|
|
27
|
-
};
|
|
28
|
-
viewport: {
|
|
29
|
-
width: number;
|
|
30
|
-
height: number;
|
|
31
|
-
devicePixelRatio: number;
|
|
32
|
-
};
|
|
33
|
-
scroll: {
|
|
34
|
-
x: number;
|
|
35
|
-
y: number;
|
|
36
|
-
};
|
|
47
|
+
screenshots: Screenshot[];
|
|
48
|
+
page: Page;
|
|
49
|
+
viewport: Viewport;
|
|
50
|
+
scroll: Scroll;
|
|
37
51
|
userAgent: string;
|
|
38
52
|
language: string;
|
|
39
53
|
timeZone: string;
|
|
40
54
|
context: Record<string, unknown>;
|
|
41
55
|
};
|
|
42
56
|
//#endregion
|
|
43
|
-
export { Mark as n, FeedbackReport as t };
|
|
57
|
+
export { Mark as n, Screenshot as r, FeedbackReport as t };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { t as checkReport } from "./report-
|
|
2
|
+
import { t as checkReport } from "./report-BwLr1sYt.mjs";
|
|
3
3
|
//#region src/report.ts
|
|
4
4
|
function uuid() {
|
|
5
5
|
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
@@ -31,28 +31,39 @@ function shown(error) {
|
|
|
31
31
|
return typeof message === "string" && message ? message : void 0;
|
|
32
32
|
}
|
|
33
33
|
/** Builds the report and checks it with the same shape check the server uses. */
|
|
34
|
-
async function build(draft,
|
|
34
|
+
async function build(draft, shots, seen, settings) {
|
|
35
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
|
+
});
|
|
36
44
|
const report = {
|
|
37
45
|
id: uuid(),
|
|
38
46
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
39
47
|
note: draft.note,
|
|
40
48
|
...draft.category === void 0 ? {} : { category: draft.category },
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
viewport: seen.viewport,
|
|
47
|
-
scroll: seen.scroll,
|
|
49
|
+
screenshots: shots.map((shot) => ({
|
|
50
|
+
marks: shot.marks,
|
|
51
|
+
...facts(shot.seen)
|
|
52
|
+
})),
|
|
53
|
+
...facts(seen),
|
|
48
54
|
userAgent: navigator.userAgent,
|
|
49
55
|
language: navigator.language,
|
|
50
56
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
51
57
|
context
|
|
52
58
|
};
|
|
59
|
+
const checked = checkReport(report);
|
|
60
|
+
const screenshots = checked.screenshots.map((shot, i) => ({
|
|
61
|
+
...shot,
|
|
62
|
+
image: shots[i].image
|
|
63
|
+
}));
|
|
53
64
|
return {
|
|
54
|
-
...
|
|
55
|
-
|
|
65
|
+
...checked,
|
|
66
|
+
screenshots
|
|
56
67
|
};
|
|
57
68
|
}
|
|
58
69
|
/** Sends with onSubmit, or else POSTs what parseFeedbackRequest reads to the endpoint. */
|
|
@@ -61,13 +72,14 @@ async function deliver(report, settings) {
|
|
|
61
72
|
if (onSubmit) return host(() => onSubmit(report));
|
|
62
73
|
if (!endpoint) throw new Error("Neither onSubmit nor endpoint is set.");
|
|
63
74
|
const body = new FormData();
|
|
75
|
+
const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
|
|
64
76
|
body.append("report", JSON.stringify({
|
|
65
77
|
...report,
|
|
66
|
-
|
|
78
|
+
screenshots
|
|
67
79
|
}));
|
|
68
|
-
|
|
69
|
-
const extension =
|
|
70
|
-
body.append(
|
|
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}`);
|
|
71
83
|
}
|
|
72
84
|
const { headers: given } = settings;
|
|
73
85
|
const headers = typeof given === "function" ? await host(given) : given ?? {};
|
package/dist/server.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { n as Mark, t as FeedbackReport } from "./report-
|
|
2
|
+
import { n as Mark, r as Screenshot, t as FeedbackReport } from "./report-CaJegYyg.mjs";
|
|
3
3
|
//#region src/server/index.d.ts
|
|
4
4
|
export declare class FeedbackRequestError extends Error {
|
|
5
5
|
readonly status: 400 | 413;
|
|
@@ -9,7 +9,6 @@ export declare function parseFeedbackRequest(request: Request, limits?: {
|
|
|
9
9
|
maxImageBytes?: number;
|
|
10
10
|
}): Promise<{
|
|
11
11
|
report: FeedbackReport;
|
|
12
|
-
image: Blob | null;
|
|
13
12
|
}>;
|
|
14
13
|
//#endregion
|
|
15
|
-
export type { FeedbackReport, Mark };
|
|
14
|
+
export type { FeedbackReport, Mark, Screenshot };
|
package/dist/server.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
|
|
2
|
-
import { t as checkReport } from "./report-
|
|
2
|
+
import { t as checkReport } from "./report-BwLr1sYt.mjs";
|
|
3
3
|
//#region src/server/index.ts
|
|
4
4
|
const DEFAULT_MAX_IMAGE_BYTES = 1e7;
|
|
5
5
|
const REPORT_BYTES = 1048576;
|
|
6
6
|
const FRAMING_BYTES = 65536;
|
|
7
|
+
const IMAGE_PART = /^image\d+$/;
|
|
7
8
|
const PNG = [
|
|
8
9
|
137,
|
|
9
10
|
80,
|
|
@@ -62,7 +63,7 @@ async function parseFeedbackRequest(request, limits = {}) {
|
|
|
62
63
|
if (!(maxImageBytes >= 0)) throw new RangeError("maxImageBytes must be a number of bytes.");
|
|
63
64
|
const contentType = request.headers.get("content-type") ?? "";
|
|
64
65
|
if (!/^multipart\/form-data\s*;/i.test(contentType)) throw new FeedbackRequestError(400, "The request is not multipart/form-data.");
|
|
65
|
-
const body = await read(request, maxImageBytes + REPORT_BYTES + FRAMING_BYTES);
|
|
66
|
+
const body = await read(request, 10 * maxImageBytes + REPORT_BYTES + FRAMING_BYTES);
|
|
66
67
|
let form;
|
|
67
68
|
try {
|
|
68
69
|
form = await new Response(body, { headers: { "content-type": contentType } }).formData();
|
|
@@ -70,11 +71,9 @@ async function parseFeedbackRequest(request, limits = {}) {
|
|
|
70
71
|
throw new FeedbackRequestError(400, "The multipart body cannot be read.");
|
|
71
72
|
}
|
|
72
73
|
const reports = form.getAll("report");
|
|
73
|
-
const images = form.getAll("image");
|
|
74
74
|
const [json] = reports;
|
|
75
|
-
const [part] = images;
|
|
76
75
|
if (reports.length !== 1 || typeof json !== "string") throw new FeedbackRequestError(400, "The request needs one report part, as text.");
|
|
77
|
-
if (
|
|
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.");
|
|
78
77
|
if (new Blob([json]).size > REPORT_BYTES) throw new FeedbackRequestError(413, "The report is too large.");
|
|
79
78
|
let fields;
|
|
80
79
|
try {
|
|
@@ -82,14 +81,24 @@ async function parseFeedbackRequest(request, limits = {}) {
|
|
|
82
81
|
} catch (error) {
|
|
83
82
|
throw new FeedbackRequestError(400, error instanceof Error ? error.message : "Bad report.");
|
|
84
83
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
} };
|
|
93
102
|
}
|
|
94
103
|
//#endregion
|
|
95
104
|
export { FeedbackRequestError, parseFeedbackRequest };
|