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
|
@@ -10,10 +10,10 @@ function sheet(text) {
|
|
|
10
10
|
return result;
|
|
11
11
|
}
|
|
12
12
|
function variables() {
|
|
13
|
-
return `.vf{--_r:var(--vf-radius,
|
|
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;
|
|
16
|
-
const THEME = `.trigger{border:1px solid var(--_b);border-radius:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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-
|
|
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?:
|
|
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,
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
36
|
-
| `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. |
|
|
37
|
-
| `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws
|
|
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
|
|
40
|
-
| `tools` | `("pen" \| "box" \| "arrow")[]` | all
|
|
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
|
|
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).
|
|
62
|
-
| `on(event, fn): () => void` | Events: `open
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
114
|
-
larger body is refused while it streams in.
|
|
115
|
-
- `image` is the checked image
|
|
116
|
-
|
|
117
|
-
|
|
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.
|