visual-feedback 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +100 -0
- package/CHANGELOG.md +104 -0
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/dist/capture-CufRosj3.mjs +83 -0
- package/dist/dialog-B57CJ4I_.mjs +1374 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/keep-BG5WxZPU.mjs +74 -0
- package/dist/pdf-Gj-ZO9W5.mjs +85 -0
- package/dist/react.d.mts +6 -0
- package/dist/react.mjs +29 -0
- package/dist/report-BwLr1sYt.mjs +121 -0
- package/dist/report-CaJegYyg.d.mts +57 -0
- package/dist/report-CnspEsHf.mjs +95 -0
- package/dist/seen-BpIWldOP.mjs +21 -0
- package/dist/server.d.mts +14 -0
- package/dist/server.mjs +104 -0
- package/dist/widget-B1JDOVI1.mjs +355 -0
- package/dist/widget-Z1KOUKjx.d.mts +66 -0
- package/docs/API.md +147 -0
- package/docs/SPEC.md +214 -0
- package/docs/THEMING.md +72 -0
- package/docs/recipes/github-issues.md +209 -0
- package/docs/recipes/local.md +75 -0
- package/docs/recipes/postgres.md +114 -0
- package/docs/recipes/s3.md +129 -0
- package/docs/recipes/supabase.md +124 -0
- package/docs/recipes/vercel-blob.md +129 -0
- package/package.json +101 -4
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/widget.ts
|
|
3
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
4
|
+
function supported() {
|
|
5
|
+
return typeof document !== "undefined" && "adoptedStyleSheets" in Document.prototype && "adoptedStyleSheets" in ShadowRoot.prototype && CSS.supports("color", "color-mix(in srgb, red, red)");
|
|
6
|
+
}
|
|
7
|
+
function sheet(text) {
|
|
8
|
+
const result = new CSSStyleSheet();
|
|
9
|
+
result.replaceSync(text);
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
function variables() {
|
|
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
|
+
}
|
|
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
|
+
function icon() {
|
|
18
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
19
|
+
const path = document.createElementNS(SVG, "path");
|
|
20
|
+
for (const [name, value] of Object.entries({
|
|
21
|
+
viewBox: "0 0 24 24",
|
|
22
|
+
"aria-hidden": "true",
|
|
23
|
+
fill: "none",
|
|
24
|
+
stroke: "currentColor",
|
|
25
|
+
"stroke-width": "2",
|
|
26
|
+
"stroke-linejoin": "round",
|
|
27
|
+
part: "trigger-icon"
|
|
28
|
+
})) svg.setAttribute(name, value);
|
|
29
|
+
path.setAttribute("d", "M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.4A8 8 0 1 1 21 12z");
|
|
30
|
+
svg.append(path);
|
|
31
|
+
return svg;
|
|
32
|
+
}
|
|
33
|
+
function field(element) {
|
|
34
|
+
return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName);
|
|
35
|
+
}
|
|
36
|
+
function deepActive() {
|
|
37
|
+
let active = document.activeElement;
|
|
38
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
39
|
+
return active;
|
|
40
|
+
}
|
|
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
|
+
}
|
|
66
|
+
const noop = {
|
|
67
|
+
open: async () => {},
|
|
68
|
+
close() {},
|
|
69
|
+
destroy() {},
|
|
70
|
+
update() {},
|
|
71
|
+
on: () => () => {}
|
|
72
|
+
};
|
|
73
|
+
function createFeedback(options = {}) {
|
|
74
|
+
if (!supported()) return noop;
|
|
75
|
+
const tag = options.tagName ?? "visual-feedback";
|
|
76
|
+
if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
|
|
77
|
+
disconnectedCallback() {
|
|
78
|
+
this[DISCONNECTED]?.();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const host = document.createElement(tag);
|
|
82
|
+
const root = host.attachShadow({ mode: "open" });
|
|
83
|
+
const wrapper = document.createElement("div");
|
|
84
|
+
wrapper.className = "vf";
|
|
85
|
+
root.append(wrapper);
|
|
86
|
+
let view;
|
|
87
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
88
|
+
const call = (fns, args) => {
|
|
89
|
+
for (const fn of [...fns]) try {
|
|
90
|
+
fn(...args);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reportError(error);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const emit = (event, ...args) => call(handlers.get(event) ?? [], args);
|
|
96
|
+
const start = () => void widget.open().catch(() => {});
|
|
97
|
+
let state = "idle";
|
|
98
|
+
let dialog;
|
|
99
|
+
let session = 0;
|
|
100
|
+
let returnFocus = null;
|
|
101
|
+
let pending;
|
|
102
|
+
let trigger;
|
|
103
|
+
let label;
|
|
104
|
+
let unlisten;
|
|
105
|
+
function listen(shortcut) {
|
|
106
|
+
const parts = shortcut.toLowerCase().split("+");
|
|
107
|
+
const key = parts.pop();
|
|
108
|
+
const names = [
|
|
109
|
+
"alt",
|
|
110
|
+
"shift",
|
|
111
|
+
"control",
|
|
112
|
+
"meta"
|
|
113
|
+
];
|
|
114
|
+
const mods = names.map((name) => parts.includes(name)).join();
|
|
115
|
+
const typing = !/control|meta/.test(shortcut.toLowerCase());
|
|
116
|
+
const on = (event) => {
|
|
117
|
+
if (event instanceof KeyboardEvent && state === "idle" && !event.repeat && !event.isComposing && !event.getModifierState("AltGraph") && parts.every((part) => names.includes(part)) && [
|
|
118
|
+
event.altKey,
|
|
119
|
+
event.shiftKey,
|
|
120
|
+
event.ctrlKey,
|
|
121
|
+
event.metaKey
|
|
122
|
+
].join() === mods && [event.code.replace(/^(Key|Digit)/, ""), event.key].some((name) => name.toLowerCase() === key) && !(typing && field(event.composedPath()[0]))) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
start();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
addEventListener("keydown", on);
|
|
128
|
+
return () => removeEventListener("keydown", on);
|
|
129
|
+
}
|
|
130
|
+
let base = [];
|
|
131
|
+
let extra = [];
|
|
132
|
+
function sheets() {
|
|
133
|
+
const dialogCss = view?.css(Boolean(options.unstyled)).map(sheet) ?? [];
|
|
134
|
+
root.adoptedStyleSheets = [
|
|
135
|
+
...base,
|
|
136
|
+
...dialogCss,
|
|
137
|
+
...extra
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
function apply() {
|
|
141
|
+
wrapper.style.colorScheme = options.theme?.replace("auto", "") ?? "";
|
|
142
|
+
base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
|
|
143
|
+
extra = options.css ? [sheet(options.css)] : [];
|
|
144
|
+
sheets();
|
|
145
|
+
if (options.trigger === "none") {
|
|
146
|
+
trigger?.remove();
|
|
147
|
+
trigger = void 0;
|
|
148
|
+
} else {
|
|
149
|
+
if (!trigger) {
|
|
150
|
+
trigger = document.createElement("button");
|
|
151
|
+
trigger.type = "button";
|
|
152
|
+
trigger.className = "trigger";
|
|
153
|
+
trigger.setAttribute("part", "trigger");
|
|
154
|
+
label = document.createElement("span");
|
|
155
|
+
label.setAttribute("part", "trigger-text");
|
|
156
|
+
trigger.append(icon(), label);
|
|
157
|
+
trigger.addEventListener("click", start);
|
|
158
|
+
wrapper.prepend(trigger);
|
|
159
|
+
}
|
|
160
|
+
const [y, x] = (options.position ?? "bottom-right").split("-");
|
|
161
|
+
trigger.dataset.y = y;
|
|
162
|
+
trigger.dataset.x = x;
|
|
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
|
+
}
|
|
170
|
+
}
|
|
171
|
+
unlisten?.();
|
|
172
|
+
unlisten = options.shortcut ? listen(options.shortcut) : void 0;
|
|
173
|
+
}
|
|
174
|
+
apply();
|
|
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
|
+
}
|
|
193
|
+
function hide(on) {
|
|
194
|
+
wrapper.classList.toggle("capturing", on);
|
|
195
|
+
}
|
|
196
|
+
function requestDisplay() {
|
|
197
|
+
const media = navigator.mediaDevices;
|
|
198
|
+
if (options.capture?.mode !== "display" || typeof media?.getDisplayMedia !== "function") return;
|
|
199
|
+
hide(true);
|
|
200
|
+
const stream = media.getDisplayMedia({ video: true });
|
|
201
|
+
stream.catch(() => {});
|
|
202
|
+
return stream;
|
|
203
|
+
}
|
|
204
|
+
function stop(stream) {
|
|
205
|
+
stream?.then((media) => {
|
|
206
|
+
for (const track of media.getTracks()) track.stop();
|
|
207
|
+
}, () => {});
|
|
208
|
+
}
|
|
209
|
+
async function grab(module, mine, stream) {
|
|
210
|
+
hide(true);
|
|
211
|
+
const quiet = view?.announce(root, options.strings);
|
|
212
|
+
try {
|
|
213
|
+
return await module.capture(options.capture, stream);
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
} finally {
|
|
217
|
+
quiet?.();
|
|
218
|
+
if (mine === session) hide(false);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function cancel() {
|
|
222
|
+
session++;
|
|
223
|
+
hide(false);
|
|
224
|
+
}
|
|
225
|
+
/** Returns whether the draft stays kept; when not, it has ended. */
|
|
226
|
+
function teardown(keep) {
|
|
227
|
+
const stays = dialog?.unmount(keep) ?? false;
|
|
228
|
+
dialog = void 0;
|
|
229
|
+
const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
|
|
230
|
+
returnFocus = null;
|
|
231
|
+
target?.focus?.();
|
|
232
|
+
return stays;
|
|
233
|
+
}
|
|
234
|
+
async function show(mine, kept) {
|
|
235
|
+
returnFocus = deepActive();
|
|
236
|
+
const stream = kept ? void 0 : requestDisplay();
|
|
237
|
+
try {
|
|
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")]);
|
|
243
|
+
if (mine !== session) return stop(stream);
|
|
244
|
+
view = loaded;
|
|
245
|
+
sheets();
|
|
246
|
+
const image = kept ? null : await grab(capture, mine, stream);
|
|
247
|
+
if (mine !== session) return;
|
|
248
|
+
if (!host.isConnected) {
|
|
249
|
+
state = "idle";
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
dialog = view.mount(wrapper, {
|
|
253
|
+
close: () => widget.close(),
|
|
254
|
+
retry: () => void retry(capture).catch((error) => {
|
|
255
|
+
widget.close();
|
|
256
|
+
emit("error", error);
|
|
257
|
+
}),
|
|
258
|
+
emit,
|
|
259
|
+
options: () => options,
|
|
260
|
+
keeps
|
|
261
|
+
}, options);
|
|
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
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
stop(stream);
|
|
278
|
+
if (mine === session) {
|
|
279
|
+
state = "idle";
|
|
280
|
+
hide(false);
|
|
281
|
+
teardown();
|
|
282
|
+
}
|
|
283
|
+
emit("error", error);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
state = "open";
|
|
287
|
+
emit("open");
|
|
288
|
+
}
|
|
289
|
+
async function retry(capture) {
|
|
290
|
+
if (wrapper.classList.contains("capturing")) return;
|
|
291
|
+
const mine = session;
|
|
292
|
+
dialog?.lock(false);
|
|
293
|
+
const image = await grab(capture, mine, requestDisplay());
|
|
294
|
+
if (mine !== session) return;
|
|
295
|
+
dialog?.capture(image);
|
|
296
|
+
}
|
|
297
|
+
const widget = {
|
|
298
|
+
open() {
|
|
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();
|
|
305
|
+
state = "opening";
|
|
306
|
+
pending = show(++session);
|
|
307
|
+
return pending;
|
|
308
|
+
},
|
|
309
|
+
close() {
|
|
310
|
+
end();
|
|
311
|
+
},
|
|
312
|
+
destroy() {
|
|
313
|
+
if (state === "destroyed") return;
|
|
314
|
+
const wasOpen = state === "open";
|
|
315
|
+
state = "destroyed";
|
|
316
|
+
cancel();
|
|
317
|
+
unlisten?.();
|
|
318
|
+
const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
|
|
319
|
+
handlers.clear();
|
|
320
|
+
host.remove();
|
|
321
|
+
call(closers, []);
|
|
322
|
+
},
|
|
323
|
+
update(next) {
|
|
324
|
+
if (state === "destroyed") return;
|
|
325
|
+
options = {
|
|
326
|
+
...options,
|
|
327
|
+
...next
|
|
328
|
+
};
|
|
329
|
+
apply();
|
|
330
|
+
},
|
|
331
|
+
on(event, fn) {
|
|
332
|
+
if (state === "destroyed") return () => {};
|
|
333
|
+
const set = handlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
334
|
+
handlers.set(event, set);
|
|
335
|
+
const handler = fn;
|
|
336
|
+
set.add(handler);
|
|
337
|
+
return () => set.delete(handler);
|
|
338
|
+
}
|
|
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());
|
|
349
|
+
host[DISCONNECTED] = () => queueMicrotask(() => {
|
|
350
|
+
if (!host.isConnected) end(true);
|
|
351
|
+
});
|
|
352
|
+
return widget;
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
export { discardDraft as n, createFeedback as t };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
import { n as Mark, t as FeedbackReport } from "./report-CaJegYyg.mjs";
|
|
3
|
+
//#region src/capture.d.ts
|
|
4
|
+
type CaptureOptions = {
|
|
5
|
+
mode?: "dom" | "display";
|
|
6
|
+
maxWidth?: number;
|
|
7
|
+
type?: "image/webp" | "image/png";
|
|
8
|
+
quality?: number;
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/annotate.d.ts
|
|
12
|
+
type Tool = Mark["tool"] | "select";
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/widget.d.ts
|
|
15
|
+
/** What each event passes to its handlers. */
|
|
16
|
+
type FeedbackEvents = {
|
|
17
|
+
open: [];
|
|
18
|
+
close: [];
|
|
19
|
+
submit: [report: FeedbackReport];
|
|
20
|
+
sent: [report: FeedbackReport];
|
|
21
|
+
error: [error: unknown];
|
|
22
|
+
saved: [];
|
|
23
|
+
};
|
|
24
|
+
type FeedbackEvent = keyof FeedbackEvents;
|
|
25
|
+
/** The texts the widget shows, by SPEC §12 key. */
|
|
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>;
|
|
27
|
+
type FeedbackOptions = {
|
|
28
|
+
onSubmit?: (report: FeedbackReport) => Promise<void>;
|
|
29
|
+
endpoint?: string;
|
|
30
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
31
|
+
capture?: CaptureOptions;
|
|
32
|
+
tools?: Tool[];
|
|
33
|
+
categories?: {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
}[];
|
|
37
|
+
requireNote?: boolean;
|
|
38
|
+
redactUrl?: boolean;
|
|
39
|
+
context?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
40
|
+
trigger?: "button" | "none";
|
|
41
|
+
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
42
|
+
shortcut?: string | false;
|
|
43
|
+
strings?: Partial<FeedbackStrings>;
|
|
44
|
+
theme?: "auto" | "light" | "dark";
|
|
45
|
+
css?: string;
|
|
46
|
+
unstyled?: boolean;
|
|
47
|
+
container?: HTMLElement;
|
|
48
|
+
tagName?: string;
|
|
49
|
+
draft?: "session" | "memory";
|
|
50
|
+
};
|
|
51
|
+
type FeedbackWidget = {
|
|
52
|
+
open(): Promise<void>;
|
|
53
|
+
close(): void;
|
|
54
|
+
destroy(): void;
|
|
55
|
+
/** A key given as undefined goes back to its default. */
|
|
56
|
+
update(options: { [K in keyof FeedbackOptions]?: FeedbackOptions[K] | undefined; }): void;
|
|
57
|
+
on<E extends FeedbackEvent>(event: E, fn: (...args: FeedbackEvents[E]) => void): () => void;
|
|
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>;
|
|
64
|
+
declare function createFeedback(options?: FeedbackOptions): FeedbackWidget;
|
|
65
|
+
//#endregion
|
|
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
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# visual-feedback: API
|
|
2
|
+
|
|
3
|
+
ESM only, with TypeScript types. On first open it imports `@zumer/snapdom` by name, so a page
|
|
4
|
+
needs a bundler, or an import map that points that name to the file. There are three entry points:
|
|
5
|
+
|
|
6
|
+
| import | for | runs in |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `visual-feedback` | `createFeedback()`, `discardDraft()` and the types | browser |
|
|
9
|
+
| `visual-feedback/server` | `parseFeedbackRequest()`, `FeedbackRequestError` and the report types | any runtime with Web `Request` (Node 20+, Bun, Deno, edge) |
|
|
10
|
+
| `visual-feedback/react` | `<Feedback />` and the type `FeedbackProps` | browser, React 18+ |
|
|
11
|
+
|
|
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.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { createFeedback } from "visual-feedback";
|
|
18
|
+
|
|
19
|
+
const feedback = createFeedback({
|
|
20
|
+
onSubmit: async (report) => {
|
|
21
|
+
const body = new FormData();
|
|
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}`);
|
|
27
|
+
}
|
|
28
|
+
const res = await fetch("/api/feedback", { method: "POST", body });
|
|
29
|
+
if (!res.ok) throw new Error("It didn't send. Your note is kept.");
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
| option | type | default | meaning |
|
|
36
|
+
|---|---|---|---|
|
|
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. |
|
|
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. |
|
|
41
|
+
| `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner of the trigger. |
|
|
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. |
|
|
44
|
+
| `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
|
|
45
|
+
| `requireNote` | `boolean` | `true` | |
|
|
46
|
+
| `capture.mode` | `"dom" \| "display"` | `"dom"` | SPEC §5. The type of `capture` is exported as `CaptureOptions`. |
|
|
47
|
+
| `capture.maxWidth` | `number` | `1920` | |
|
|
48
|
+
| `capture.type` | `"image/webp" \| "image/png"` | `"image/webp"` | |
|
|
49
|
+
| `capture.quality` | `number` | `0.85` | For WebP. |
|
|
50
|
+
| `redactUrl` | `boolean` | `false` | Drops the query string and the hash from `page.url`, the report's and each screenshot's. |
|
|
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. |
|
|
52
|
+
| `strings` | `Partial<FeedbackStrings>` | English | SPEC §12 keys. Keys left out, or given as `undefined`, keep their English text. |
|
|
53
|
+
| `theme` | `"auto" \| "light" \| "dark"` | `"auto"` | THEMING.md. |
|
|
54
|
+
| `css` | `string` | – | Extra CSS added inside the shadow root, after the defaults. THEMING.md. |
|
|
55
|
+
| `unstyled` | `boolean` | `false` | Keeps only the layout-critical CSS and the focus ring. THEMING.md. |
|
|
56
|
+
| `container` | `HTMLElement` | `document.body` | Where the host element is appended. |
|
|
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. |
|
|
59
|
+
|
|
60
|
+
### FeedbackWidget
|
|
61
|
+
| member | meaning |
|
|
62
|
+
|---|---|
|
|
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.
|
|
78
|
+
|
|
79
|
+
## FeedbackReport
|
|
80
|
+
```ts
|
|
81
|
+
type FeedbackReport = {
|
|
82
|
+
id: string; // UUID v4 from crypto.getRandomValues, so plain http works
|
|
83
|
+
createdAt: string; // ISO 8601, as Date.prototype.toISOString() writes it
|
|
84
|
+
note: string;
|
|
85
|
+
category?: string; // the chosen category's id; absent without `categories`
|
|
86
|
+
screenshots: Screenshot[]; // at most 10; empty after "Send without screenshot"
|
|
87
|
+
page: { url: string; title: string };
|
|
88
|
+
viewport: { width: number; height: number; devicePixelRatio: number };
|
|
89
|
+
scroll: { x: number; y: number };
|
|
90
|
+
userAgent: string;
|
|
91
|
+
language: string;
|
|
92
|
+
timeZone: string;
|
|
93
|
+
context: Record<string, unknown>;
|
|
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
|
+
};
|
|
102
|
+
type Mark =
|
|
103
|
+
| { tool: "pen"; points: [number, number][] }
|
|
104
|
+
| { tool: "box"; x: number; y: number; width: number; height: 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 };
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## `<Feedback {...options} />` (`visual-feedback/react`)
|
|
111
|
+
Its props are the options of `createFeedback`. It renders nothing where it stands: after it mounts
|
|
112
|
+
in the browser it creates the widget, a changed prop goes to `update()` (a prop that is gone goes
|
|
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
|
|
115
|
+
client component, so a server component can render it with props that reach the browser as data,
|
|
116
|
+
such as `endpoint`; `onSubmit`, `headers` as a function, `context` and `container` need a client
|
|
117
|
+
component.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { Feedback } from "visual-feedback/react";
|
|
121
|
+
|
|
122
|
+
<Feedback endpoint="/api/feedback" />;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## parseFeedbackRequest(request, limits?) (`visual-feedback/server`)
|
|
126
|
+
```ts
|
|
127
|
+
const { report } = await parseFeedbackRequest(request, { maxImageBytes: 5_000_000 });
|
|
128
|
+
```
|
|
129
|
+
- It reads the multipart body that `endpoint` sends and checks the report's shape. `id` must be a
|
|
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`
|
|
132
|
+
does not define, and `__proto__` keys at any depth, are dropped.
|
|
133
|
+
- In the report's text, `context` included, NUL characters are removed and half surrogate pairs (a
|
|
134
|
+
cut emoji) become U+FFFD, so any store, Postgres `jsonb` included, can keep it.
|
|
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
|
|
139
|
+
`maxImageBytes`, which is `10_000_000` by default. The report part may be at most 1 MiB, and
|
|
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`.
|
|
146
|
+
- Storing the report, and checking who sent it, is the host's job. `report.context` comes from the
|
|
147
|
+
browser, so it is never proof of who sent it: take the user from your own session.
|