visual-feedback 0.1.0 → 0.3.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/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 CaptureOptions, t as FeedbackEvent } from "./widget-Ca1heF8n.mjs";
3
- import { n as Mark, t as FeedbackReport } from "./report-CIgy7uzP.mjs";
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-CVnusNlY.mjs";
2
+ export { t as createFeedback, n as discardDraft } from "./widget-DvGTo0B4.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-Ca1heF8n.mjs";
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,5 @@
1
1
  "use client";
2
- import { t as createFeedback } from "./widget-CVnusNlY.mjs";
2
+ import { t as createFeedback } from "./widget-DvGTo0B4.mjs";
3
3
  import { useEffect, useRef } from "react";
4
4
  //#region src/react/index.ts
5
5
  function Feedback(props) {
@@ -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 image, as JSON carries it. Returns a copy that
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
- if (fields.image !== void 0 && fields.image !== null) fail("image");
67
- if (!Array.isArray(fields.marks)) fail("marks");
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
- marks: fields.marks.map((item, i) => mark(item, `marks[${i}]`)),
77
- page: {
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
- image: Blob | null;
23
- marks: Mark[];
24
- page: {
25
- url: string;
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-BxonlIdW.mjs";
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, image, seen, settings) {
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
- marks: image ? draft.marks : [],
42
- page: {
43
- url: pageUrl(seen.url, Boolean(settings.redactUrl)),
44
- title: seen.title
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
- ...checkReport(report),
55
- image
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
- image: void 0
78
+ screenshots
67
79
  }));
68
- if (report.image) {
69
- const extension = report.image.type === "image/png" ? "png" : "webp";
70
- body.append("image", report.image, `${report.id}.${extension}`);
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-CIgy7uzP.mjs";
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-BxonlIdW.mjs";
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 (images.length > 1 || typeof part === "string") throw new FeedbackRequestError(400, "The request has more than one image, or an image 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.");
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 image = part ? await checkImage(part, maxImageBytes) : null;
86
- return {
87
- report: {
88
- ...fields,
89
- image
90
- },
91
- image
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 };