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.
@@ -10,10 +10,10 @@ function sheet(text) {
10
10
  return result;
11
11
  }
12
12
  function variables() {
13
- return `.vf{--_r:var(--vf-radius,12px);--_s:var(--vf-surface,Canvas);--_t:var(--vf-text,CanvasText);--_m:var(--vf-muted,color-mix(in srgb,var(--_t) 60%,transparent));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"});--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 4px 16px rgb(0 0 0/12%));--_size:var(--vf-trigger-size,36px);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
13
+ return `.vf{--_r:var(--vf-radius,16px);--_s:var(--vf-surface,Canvas);--_t:var(--vf-text,CanvasText);--_m:var(--vf-muted,color-mix(in srgb,var(--_t) 60%,transparent));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"});--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 12px 32px -12px rgb(0 0 0/35%));--_size:var(--vf-trigger-size,2.5em);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
14
14
  }
15
- const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{position:fixed;z-index:var(--_z);box-sizing:border-box;width:var(--_size);height:var(--_size);margin:0;padding:0;display:grid;place-items:center}.trigger[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}.trigger[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}.trigger[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}.trigger[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:55%;height:55%}.capturing,.capturing .backdrop{visibility:hidden}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
16
- const THEME = `.trigger{border:1px solid var(--_b);border-radius:50%;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}`;
15
+ const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{font:inherit;position:fixed;z-index:var(--_z);box-sizing:border-box;height:var(--_size);margin:0;padding:0 14px 0 12px;display:flex;align-items:center;gap:8px;white-space:nowrap}.trigger[data-icon]{width:var(--_size);padding:0;justify-content:center}[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:1.15em;height:1.15em}.capturing,.capturing .backdrop{visibility:hidden}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
16
+ const THEME = `.trigger{font-weight:600;border:1px solid var(--_b);border-radius:999px;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}`;
17
17
  function icon() {
18
18
  const svg = document.createElementNS(SVG, "svg");
19
19
  const path = document.createElementNS(SVG, "path");
@@ -39,6 +39,30 @@ function deepActive() {
39
39
  return active;
40
40
  }
41
41
  const DISCONNECTED = Symbol.for("visual-feedback.disconnected");
42
+ const owners = /* @__PURE__ */ new Map();
43
+ const enders = /* @__PURE__ */ new WeakMap();
44
+ let dialogs;
45
+ let discarding = Promise.resolve();
46
+ /**
47
+ * Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
48
+ * it back; a widget holding it, open or folded, ends it.
49
+ */
50
+ async function discardDraft(tagName = "visual-feedback") {
51
+ const key = `visual-feedback:${tagName}`;
52
+ let id = null;
53
+ try {
54
+ id = sessionStorage.getItem(key);
55
+ sessionStorage.removeItem(key);
56
+ } catch {}
57
+ const owner = owners.get(tagName);
58
+ if (owner) enders.get(owner)?.();
59
+ const mine = discarding.then(async () => {
60
+ await (await dialogs?.catch(() => void 0))?.settled();
61
+ if (id) await (await import("./keep-BG5WxZPU.mjs")).forget(id);
62
+ });
63
+ discarding = mine.catch(() => {});
64
+ await mine;
65
+ }
42
66
  const noop = {
43
67
  open: async () => {},
44
68
  close() {},
@@ -76,6 +100,7 @@ function createFeedback(options = {}) {
76
100
  let returnFocus = null;
77
101
  let pending;
78
102
  let trigger;
103
+ let label;
79
104
  let unlisten;
80
105
  function listen(shortcut) {
81
106
  const parts = shortcut.toLowerCase().split("+");
@@ -126,20 +151,45 @@ function createFeedback(options = {}) {
126
151
  trigger.type = "button";
127
152
  trigger.className = "trigger";
128
153
  trigger.setAttribute("part", "trigger");
129
- trigger.append(icon());
154
+ label = document.createElement("span");
155
+ label.setAttribute("part", "trigger-text");
156
+ trigger.append(icon(), label);
130
157
  trigger.addEventListener("click", start);
131
158
  wrapper.prepend(trigger);
132
159
  }
133
160
  const [y, x] = (options.position ?? "bottom-right").split("-");
134
161
  trigger.dataset.y = y;
135
162
  trigger.dataset.x = x;
136
- trigger.setAttribute("aria-label", options.strings?.trigger ?? "Send feedback");
163
+ const { trigger: name, triggerText } = options.strings ?? {};
164
+ trigger.setAttribute("aria-label", name || triggerText || "Send feedback");
165
+ trigger.toggleAttribute("data-icon", !triggerText);
166
+ if (label) {
167
+ label.textContent = triggerText ?? "";
168
+ label.hidden = !triggerText;
169
+ }
137
170
  }
138
171
  unlisten?.();
139
172
  unlisten = options.shortcut ? listen(options.shortcut) : void 0;
140
173
  }
141
174
  apply();
142
175
  (options.container ?? document.body ?? document.documentElement).append(host);
176
+ const keeps = () => {
177
+ if (options.draft === "memory") return false;
178
+ if (!owners.get(tag)?.isConnected && host.isConnected) owners.set(tag, host);
179
+ return owners.get(tag) === host;
180
+ };
181
+ restore();
182
+ function restore() {
183
+ try {
184
+ if (!keeps() || !sessionStorage.getItem(`visual-feedback:${tag}`)) return false;
185
+ } catch {
186
+ return false;
187
+ }
188
+ state = "opening";
189
+ pending = show(++session, true);
190
+ pending.catch(() => {});
191
+ return true;
192
+ }
143
193
  function hide(on) {
144
194
  wrapper.classList.toggle("capturing", on);
145
195
  }
@@ -172,22 +222,28 @@ function createFeedback(options = {}) {
172
222
  session++;
173
223
  hide(false);
174
224
  }
175
- function teardown() {
176
- dialog?.unmount();
225
+ /** Returns whether the draft stays kept; when not, it has ended. */
226
+ function teardown(keep) {
227
+ const stays = dialog?.unmount(keep) ?? false;
177
228
  dialog = void 0;
178
229
  const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
179
230
  returnFocus = null;
180
231
  target?.focus?.();
232
+ return stays;
181
233
  }
182
- async function show(mine) {
234
+ async function show(mine, kept) {
183
235
  returnFocus = deepActive();
184
- const stream = requestDisplay();
236
+ const stream = kept ? void 0 : requestDisplay();
185
237
  try {
186
- const [loaded, capture] = await Promise.all([import("./dialog-BTY6FKLo.mjs"), import("./capture-CufRosj3.mjs")]);
238
+ dialogs ??= import("./dialog-B57CJ4I_.mjs").catch((error) => {
239
+ dialogs = void 0;
240
+ throw error;
241
+ });
242
+ const [loaded, capture] = await Promise.all([dialogs, import("./capture-CufRosj3.mjs")]);
187
243
  if (mine !== session) return stop(stream);
188
244
  view = loaded;
189
245
  sheets();
190
- const image = await grab(capture, mine, stream);
246
+ const image = kept ? null : await grab(capture, mine, stream);
191
247
  if (mine !== session) return;
192
248
  if (!host.isConnected) {
193
249
  state = "idle";
@@ -200,9 +256,23 @@ function createFeedback(options = {}) {
200
256
  emit("error", error);
201
257
  }),
202
258
  emit,
203
- options: () => options
259
+ options: () => options,
260
+ keeps
204
261
  }, options);
205
- dialog.capture(image);
262
+ const mounted = dialog;
263
+ if (!kept) mounted.capture(image);
264
+ else {
265
+ const back = await mounted.revive();
266
+ if (mine !== session) {
267
+ mounted.unmount(true);
268
+ return;
269
+ }
270
+ if (!back) {
271
+ state = "idle";
272
+ mounted.unmount();
273
+ return;
274
+ }
275
+ }
206
276
  } catch (error) {
207
277
  stop(stream);
208
278
  if (mine === session) {
@@ -226,19 +296,18 @@ function createFeedback(options = {}) {
226
296
  }
227
297
  const widget = {
228
298
  open() {
229
- if (state === "opening" && pending) return pending;
230
- if (state !== "idle") return Promise.resolve();
299
+ if (state === "opening" && pending) {
300
+ const mine = session;
301
+ return pending.then(() => mine === session ? widget.open() : void 0);
302
+ }
303
+ if (state !== "idle") return dialog?.reopen(), Promise.resolve();
304
+ if (restore()) return widget.open();
231
305
  state = "opening";
232
306
  pending = show(++session);
233
307
  return pending;
234
308
  },
235
309
  close() {
236
- if (state !== "open" && state !== "opening") return;
237
- const wasOpen = state === "open";
238
- state = "idle";
239
- cancel();
240
- teardown();
241
- if (wasOpen) emit("close");
310
+ end();
242
311
  },
243
312
  destroy() {
244
313
  if (state === "destroyed") return;
@@ -246,8 +315,7 @@ function createFeedback(options = {}) {
246
315
  state = "destroyed";
247
316
  cancel();
248
317
  unlisten?.();
249
- if (wasOpen) teardown();
250
- const closers = wasOpen ? [...handlers.get("close") ?? []] : [];
318
+ const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
251
319
  handlers.clear();
252
320
  host.remove();
253
321
  call(closers, []);
@@ -269,10 +337,19 @@ function createFeedback(options = {}) {
269
337
  return () => set.delete(handler);
270
338
  }
271
339
  };
340
+ function end(keep) {
341
+ if (state !== "open" && state !== "opening") return;
342
+ const wasOpen = state === "open";
343
+ state = "idle";
344
+ cancel();
345
+ const stays = teardown(keep);
346
+ if (wasOpen && !stays) emit("close");
347
+ }
348
+ enders.set(host, () => end());
272
349
  host[DISCONNECTED] = () => queueMicrotask(() => {
273
- if (!host.isConnected) widget.close();
350
+ if (!host.isConnected) end(true);
274
351
  });
275
352
  return widget;
276
353
  }
277
354
  //#endregion
278
- export { createFeedback as t };
355
+ export { discardDraft as n, createFeedback as t };
@@ -1,5 +1,5 @@
1
1
 
2
- import { n as Mark, t as FeedbackReport } from "./report-CIgy7uzP.mjs";
2
+ import { n as Mark, t as FeedbackReport } from "./report-CaJegYyg.mjs";
3
3
  //#region src/capture.d.ts
4
4
  type CaptureOptions = {
5
5
  mode?: "dom" | "display";
@@ -8,6 +8,9 @@ type CaptureOptions = {
8
8
  quality?: number;
9
9
  };
10
10
  //#endregion
11
+ //#region src/annotate.d.ts
12
+ type Tool = Mark["tool"] | "select";
13
+ //#endregion
11
14
  //#region src/widget.d.ts
12
15
  /** What each event passes to its handlers. */
13
16
  type FeedbackEvents = {
@@ -16,16 +19,17 @@ type FeedbackEvents = {
16
19
  submit: [report: FeedbackReport];
17
20
  sent: [report: FeedbackReport];
18
21
  error: [error: unknown];
22
+ saved: [];
19
23
  };
20
24
  type FeedbackEvent = keyof FeedbackEvents;
21
25
  /** The texts the widget shows, by SPEC §12 key. */
22
- type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "undo" | "clear" | "cancel" | "send" | "sending" | "sent" | "error" | "retry", string>;
26
+ type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "text" | "select" | "remove" | "undo" | "clear" | "cancel" | "send" | "sending" | "sent" | "error" | "retry" | "triggerText" | "escHint" | "minimise" | "screenshot" | "addScreenshot" | "removeScreenshot" | "draft" | "captureView" | "reopen" | "discard" | "discardConfirm" | "keepEditing" | "moreSend" | "savePdf" | "pdfError", string>;
23
27
  type FeedbackOptions = {
24
28
  onSubmit?: (report: FeedbackReport) => Promise<void>;
25
29
  endpoint?: string;
26
30
  headers?: Record<string, string> | (() => Record<string, string>);
27
31
  capture?: CaptureOptions;
28
- tools?: Mark["tool"][];
32
+ tools?: Tool[];
29
33
  categories?: {
30
34
  id: string;
31
35
  label: string;
@@ -42,6 +46,7 @@ type FeedbackOptions = {
42
46
  unstyled?: boolean;
43
47
  container?: HTMLElement;
44
48
  tagName?: string;
49
+ draft?: "session" | "memory";
45
50
  };
46
51
  type FeedbackWidget = {
47
52
  open(): Promise<void>;
@@ -51,6 +56,11 @@ type FeedbackWidget = {
51
56
  update(options: { [K in keyof FeedbackOptions]?: FeedbackOptions[K] | undefined; }): void;
52
57
  on<E extends FeedbackEvent>(event: E, fn: (...args: FeedbackEvents[E]) => void): () => void;
53
58
  };
59
+ /**
60
+ * Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
61
+ * it back; a widget holding it, open or folded, ends it.
62
+ */
63
+ declare function discardDraft(tagName?: string): Promise<void>;
54
64
  declare function createFeedback(options?: FeedbackOptions): FeedbackWidget;
55
65
  //#endregion
56
- export { FeedbackWidget as a, FeedbackStrings as i, FeedbackEvents as n, createFeedback as o, FeedbackOptions as r, CaptureOptions as s, FeedbackEvent as t };
66
+ export { FeedbackWidget as a, CaptureOptions as c, FeedbackStrings as i, FeedbackEvents as n, createFeedback as o, FeedbackOptions as r, discardDraft as s, FeedbackEvent as t };
package/docs/API.md CHANGED
@@ -5,11 +5,13 @@ needs a bundler, or an import map that points that name to the file. There are t
5
5
 
6
6
  | import | for | runs in |
7
7
  |---|---|---|
8
- | `visual-feedback` | `createFeedback()` and the types | browser |
8
+ | `visual-feedback` | `createFeedback()`, `discardDraft()` and the types | browser |
9
9
  | `visual-feedback/server` | `parseFeedbackRequest()`, `FeedbackRequestError` and the report types | any runtime with Web `Request` (Node 20+, Bun, Deno, edge) |
10
10
  | `visual-feedback/react` | `<Feedback />` and the type `FeedbackProps` | browser, React 18+ |
11
11
 
12
12
  ## createFeedback(options: FeedbackOptions): FeedbackWidget
13
+ Without a document (a server render) or in a browser below SPEC §10, it returns a widget whose
14
+ methods do nothing: it draws nothing and fires no event.
13
15
 
14
16
  ```ts
15
17
  import { createFeedback } from "visual-feedback";
@@ -17,10 +19,11 @@ import { createFeedback } from "visual-feedback";
17
19
  const feedback = createFeedback({
18
20
  onSubmit: async (report) => {
19
21
  const body = new FormData();
20
- body.append("report", JSON.stringify({ ...report, image: undefined }));
21
- if (report.image) {
22
- const extension = report.image.type === "image/png" ? "png" : "webp";
23
- body.append("image", report.image, `${report.id}.${extension}`);
22
+ const screenshots = report.screenshots.map(({ image: _, ...shot }) => shot);
23
+ body.append("report", JSON.stringify({ ...report, screenshots }));
24
+ for (const [i, { image }] of report.screenshots.entries()) {
25
+ const extension = image.type === "image/png" ? "png" : "webp";
26
+ body.append(`image${i}`, image, `${report.id}-${i}.${extension}`);
24
27
  }
25
28
  const res = await fetch("/api/feedback", { method: "POST", body });
26
29
  if (!res.ok) throw new Error("It didn't send. Your note is kept.");
@@ -32,45 +35,55 @@ const feedback = createFeedback({
32
35
  | option | type | default | meaning |
33
36
  |---|---|---|---|
34
37
  | `onSubmit` | `(report) => Promise<void>` | – | Sends the report. A throw shows the send error state, using the error's message. Give this or `endpoint`; with both, `onSubmit` is used. |
35
- | `endpoint` | `string` | – | POSTs `multipart/form-data` with a `report` part (JSON, no image) and an `image` part named `<id>.webp` or `<id>.png`. An answer that is not 2xx, a redirect included, shows the send error state with the default `error` text. It is a same-origin `fetch`, so it sends cookies only to the page's own origin. |
36
- | `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. |
37
- | `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws nothing; the host calls `open()`. |
38
+ | `endpoint` | `string` | – | POSTs `multipart/form-data` with a `report` part (JSON, each screenshot without its image) and one part per screenshot, `image0`, `image1` and on, named `<id>-<n>.webp` or `<id>-<n>.png`. An answer that is not 2xx, a redirect included, shows the send error state with the default `error` text. It is a same-origin `fetch`, so it sends cookies only to the page's own origin; another origin needs CORS on its server and a `connect-src` that allows it. |
39
+ | `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. A function is called at Send; a throw shows the send error state, using the error's message. |
40
+ | `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws no trigger; the host calls `open()`. A kept draft's bar still shows. |
38
41
  | `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner of the trigger. |
39
- | `shortcut` | `string \| false` | `false` | For example `"Alt+Shift+F"`: any of `Alt`, `Shift`, `Control` and `Meta`, then one key, a letter, a digit or a key name such as `F2`, joined by `+`. The modifiers must match exactly, AltGr never matches, and any other modifier name makes it match nothing. Without `Control` or `Meta` it does not fire while focus is in a text field. It does nothing while the widget is open. Opt-in; it is the widget's only global listener, one `keydown` on `window` (SPEC §9 names what the capture library keeps). |
40
- | `tools` | `("pen" \| "box" \| "arrow")[]` | all three | Order is the toolbar order. Repeats and unknown names are ignored; an empty list shows no toolbar. |
42
+ | `shortcut` | `string \| false` | `false` | For example `"Alt+Shift+F"`: any of `Alt`, `Shift`, `Control` and `Meta`, then one key, a letter, a digit or a key name such as `F2`, joined by `+`. The modifiers must match exactly, AltGr never matches, and any other modifier name makes it match nothing. Without `Control` or `Meta` it does not fire while focus is in a text field. It does nothing while the dialog is open or a draft is kept in the draft bar. Opt-in; it is the widget's only global listener, one `keydown` on `window` (SPEC §9 names what the capture library keeps). |
43
+ | `tools` | `("pen" \| "box" \| "arrow" \| "text" \| "select")[]` | all five | Order is the toolbar order, and the first drawing tool is the one selected at open. Repeats and unknown names are ignored; an empty list, or Select alone, shows no toolbar. The Delete button is there only with Select. |
41
44
  | `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
42
45
  | `requireNote` | `boolean` | `true` | |
43
46
  | `capture.mode` | `"dom" \| "display"` | `"dom"` | SPEC §5. The type of `capture` is exported as `CaptureOptions`. |
44
47
  | `capture.maxWidth` | `number` | `1920` | |
45
48
  | `capture.type` | `"image/webp" \| "image/png"` | `"image/webp"` | |
46
49
  | `capture.quality` | `number` | `0.85` | For WebP. |
47
- | `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url`. |
50
+ | `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url`, the report's and each screenshot's. |
48
51
  | `context` | `() => Record<string, unknown> \| Promise<…>` | – | Host data added to the report, such as a user id or the app version. Called at Send; a throw shows the send error state, using the error's message. |
49
52
  | `strings` | `Partial<FeedbackStrings>` | English | SPEC §12 keys. Keys left out, or given as `undefined`, keep their English text. |
50
53
  | `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | THEMING.md. |
51
54
  | `css` | `string` | – | Extra CSS added inside the shadow root, after the defaults. THEMING.md. |
52
- | `unstyled` | `boolean` | `false` | Keeps only the layout-critical CSS. THEMING.md. |
55
+ | `unstyled` | `boolean` | `false` | Keeps only the layout-critical CSS and the focus ring. THEMING.md. |
53
56
  | `container` | `HTMLElement` | `document.body` | Where the host element is appended. |
54
57
  | `tagName` | `string` | `"visual-feedback"` | The custom element name, if it clashes. |
58
+ | `draft` | `"session" \| "memory"` | `"session"` | Where a draft folded in the draft bar is kept. `"session"` keeps it for this tab across page loads and route changes, as it was last folded, until it ends or a day after it was last folded or brought back (SPEC §7); `"memory"` keeps nothing on the device, so a page load ends it. One widget of a tag keeps a draft, the first whose element is in the page; when that element leaves, the next widget of the tag to need it takes over. Another one with the same `tagName` keeps its own in memory only. |
55
59
 
56
60
  ### FeedbackWidget
57
61
  | member | meaning |
58
62
  |---|---|
59
- | `open(): Promise<void>` | Starts a capture. When it is already open, it does nothing. When the dialog cannot load or be shown, it rejects and `error` fires. |
60
- | `close(): void` | Cancels and discards the draft. Before `submit` fires, nothing is sent; after it, the delivery is not stopped, and `sent` or `error` still fires when it ends. |
61
- | `destroy(): void` | Removes the element, its listeners and its stylesheet, but not what the capture library keeps (SPEC §9). If the dialog is open, `close` fires, and it is the last event: a delivery still under way fires neither `sent` nor `error`. |
62
- | `on(event, fn): () => void` | Events: `open`, `close`, `submit` (with the report, before each attempt), `sent` (with the report), `error` (with the error). The type `FeedbackEvents` maps each event to what it passes. Returns an unsubscribe function. |
63
- | `update(options): void` | Merges these options into the current ones, key by key: a key given as `undefined` goes back to its default, and `strings` or `capture` replaces the whole object. `tagName` and `container` are ignored. The trigger, `position`, `theme`, `css`, `unstyled`, `strings.trigger` and `shortcut` change at once. The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
63
+ | `open(): Promise<void>` | Starts a capture. When a draft is kept in the draft bar, or this tab still keeps one, it opens the dialog on that draft instead, once it is back; when the dialog is open already, it does nothing. When the dialog cannot load or be shown, it rejects and `error` fires. |
64
+ | `close(): void` | Cancels and discards the draft, also when it is kept in the draft bar, and never asks first as the Cancel button can (SPEC §2); one a page load is still bringing back is only hidden, kept, with no event (SPEC §7). Before `submit` fires, nothing is sent; after it, the delivery is not stopped, and `sent` or `error` still fires when it ends. |
65
+ | `destroy(): void` | Removes the element, its listeners and its stylesheet, but not what the capture library keeps (SPEC §9). A delivery still under way fires neither `sent` nor `error` after it. The draft, open or folded, stays kept, so `close` does not fire: the next widget of the same tag made in this tab brings it back folded, as after a page load, so a route change keeps it. A host that removes the element gets the same. A draft it cannot keep ends, and `close` fires as the last event: with `draft: "memory"`, a Send already handed to the host (`submit` fired), a widget that does not own its tag's draft, no screenshot (the capture error, or after "Send without screenshot"), a screenshot taken while it did not keep drafts, or sessionStorage that refuses it. To end the draft too, as at a logout, call `discardDraft()`. A draft whose IndexedDB write then fails (storage full) is lost, with no `close`: the widget is gone by then. |
66
+ | `on(event, fn): () => void` | Events: `open` (when a draft starts, and when a kept draft comes back), `close` (only when the draft ends: sent and closed, Cancel, Discard, `close()`, or a `destroy()` or removed element that does not keep it; folding into the draft bar, opening it again, and a `destroy()` or removal that keeps it fire none), `submit` (with the report, before each attempt), `sent` (with the report), `error` (with the error), `saved` (after Save as PDF downloads the PDF; nothing is sent). The type `FeedbackEvents` maps each event to what it passes. Returns an unsubscribe function. |
67
+ | `update(options): void` | Merges these options into the current ones, key by key: a key given as `undefined` goes back to its default, and `strings` or `capture` replaces the whole object. `tagName` and `container` are ignored. The trigger, `position` (a draft bar already shown moves at its next fold), `theme`, `css`, `unstyled`, `strings.trigger`, `strings.triggerText` and `shortcut` change at once. The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
68
+
69
+ ## discardDraft(tagName?: string): Promise<void>
70
+ Deletes the draft this tab keeps for a tag (default `"visual-feedback"`), as at a logout, so no
71
+ later widget of that tag brings it back: the id in sessionStorage and the record in IndexedDB. A
72
+ widget that holds it, open or folded, ends it, and `close` fires; one still bringing it back at a
73
+ page load stops, with no event, since it never showed it. It waits for a write still under
74
+ way, so none lands after it. It needs no widget on the page; with `<Feedback />`, import it from
75
+ `visual-feedback`. A widget made with a `tagName` needs that same name here. It rejects when the
76
+ device's storage cannot be reached; the id is already gone by then, so no widget brings the draft
77
+ back.
64
78
 
65
79
  ## FeedbackReport
66
80
  ```ts
67
81
  type FeedbackReport = {
68
82
  id: string; // UUID v4 from crypto.getRandomValues, so plain http works
69
- createdAt: string; // ISO 8601
83
+ createdAt: string; // ISO 8601, as Date.prototype.toISOString() writes it
70
84
  note: string;
71
- category?: string;
72
- image: Blob | null; // marks painted in; null after "Send without screenshot"
73
- marks: Mark[]; // vectors, in image pixels
85
+ category?: string; // the chosen category's id; absent without `categories`
86
+ screenshots: Screenshot[]; // at most 10; empty after "Send without screenshot"
74
87
  page: { url: string; title: string };
75
88
  viewport: { width: number; height: number; devicePixelRatio: number };
76
89
  scroll: { x: number; y: number };
@@ -79,16 +92,26 @@ type FeedbackReport = {
79
92
  timeZone: string;
80
93
  context: Record<string, unknown>;
81
94
  };
95
+ type Screenshot = {
96
+ image: Blob; // marks painted in
97
+ marks: Mark[]; // shapes and text labels, in image pixels
98
+ page: { url: string; title: string }; // the page as it was when this screenshot was taken
99
+ viewport: { width: number; height: number; devicePixelRatio: number };
100
+ scroll: { x: number; y: number };
101
+ };
82
102
  type Mark =
83
103
  | { tool: "pen"; points: [number, number][] }
84
104
  | { tool: "box"; x: number; y: number; width: number; height: number }
85
- | { tool: "arrow"; from: [number, number]; to: [number, number] };
105
+ | { tool: "arrow"; from: [number, number]; to: [number, number] }
106
+ // A label, its text at most 280 characters, centred on y and starting at x.
107
+ | { tool: "text"; x: number; y: number; text: string };
86
108
  ```
87
109
 
88
110
  ## `<Feedback {...options} />` (`visual-feedback/react`)
89
111
  Its props are the options of `createFeedback`. It renders nothing where it stands: after it mounts
90
112
  in the browser it creates the widget, a changed prop goes to `update()` (a prop that is gone goes
91
- back to its default; `tagName` and `container` are read once), and unmounting destroys it. It is a
113
+ back to its default; `tagName` and `container` are read once), and unmounting destroys it, which
114
+ keeps a draft: call `discardDraft()` at a logout. It is a
92
115
  client component, so a server component can render it with props that reach the browser as data,
93
116
  such as `endpoint`; `onSubmit`, `headers` as a function, `context` and `container` need a client
94
117
  component.
@@ -101,19 +124,24 @@ import { Feedback } from "visual-feedback/react";
101
124
 
102
125
  ## parseFeedbackRequest(request, limits?) (`visual-feedback/server`)
103
126
  ```ts
104
- const { report, image } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
127
+ const { report } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
105
128
  ```
106
129
  - It reads the multipart body that `endpoint` sends and checks the report's shape. `id` must be a
107
- lower-case UUID v4, and `createdAt` an ISO time from 1970 to 9999. Fields that `FeedbackReport`
130
+ lower-case UUID v4, and `createdAt` an ISO time from 1970 to 9999, in exactly the form
131
+ `Date.prototype.toISOString()` writes (UTC, with milliseconds). Fields that `FeedbackReport`
108
132
  does not define, and `__proto__` keys at any depth, are dropped.
109
133
  - In the report's text, `context` included, NUL characters are removed and half surrogate pairs (a
110
134
  cut emoji) become U+FFFD, so any store, Postgres `jsonb` included, can keep it.
111
- - It checks that the image is a real WebP or PNG (magic bytes, not the declared type) and within
135
+ - A report holds at most 10 screenshots, and screenshot `n` needs exactly one image part,
136
+ `image<n>`. An image part with no screenshot is refused, and so is a part named `image`, which
137
+ visual-feedback 0.1 sent.
138
+ - It checks that each image is a real WebP or PNG (magic bytes, not the declared type) and within
112
139
  `maxImageBytes`, which is `10_000_000` by default. The report part may be at most 1 MiB, and
113
- the whole body at most `maxImageBytes` plus 1 MiB plus 64 KiB for the multipart framing; a
114
- larger body is refused while it streams in.
115
- - `image` is the checked image, typed from its bytes, or `null` when there is no image part.
116
- `report.image` is the same value.
117
- - If any check fails, it throws a `FeedbackRequestError` that carries a `status` (400 or 413).
140
+ the whole body at most 10 times `maxImageBytes` plus 1 MiB plus 64 KiB for the multipart
141
+ framing; a larger body is refused while it streams in.
142
+ - `report.screenshots[n].image` is the checked image of part `image<n>`, typed from its bytes.
143
+ - If any check fails, it throws a `FeedbackRequestError` that carries a `status` (400 or 413). A
144
+ `maxImageBytes` that is not a number of bytes is a mistake in the host's code: it throws a
145
+ `RangeError`.
118
146
  - Storing the report, and checking who sent it, is the host's job. `report.context` comes from the
119
147
  browser, so it is never proof of who sent it: take the user from your own session.