visual-feedback 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,485 +0,0 @@
1
-
2
- import { n as shots, t as look } from "./seen-BpIWldOP.mjs";
3
- //#region src/annotate.ts
4
- function context(canvas) {
5
- const result = canvas.getContext("2d");
6
- if (!result) throw new Error("no 2d context");
7
- return result;
8
- }
9
- function arrowHead(target, [x1, y1], [x2, y2]) {
10
- const angle = Math.atan2(y2 - y1, x2 - x1);
11
- const size = target.lineWidth * 4;
12
- for (const side of [-1, 1]) {
13
- target.moveTo(x2, y2);
14
- target.lineTo(x2 - size * Math.cos(angle + side * Math.PI / 6), y2 - size * Math.sin(angle + side * Math.PI / 6));
15
- }
16
- }
17
- function stroke(target, mark) {
18
- target.beginPath();
19
- if (mark.tool === "pen") for (const [i, [x, y]] of mark.points.entries()) if (i === 0) target.moveTo(x, y);
20
- else target.lineTo(x, y);
21
- else if (mark.tool === "box") target.rect(mark.x, mark.y, mark.width, mark.height);
22
- else {
23
- target.moveTo(...mark.from);
24
- target.lineTo(...mark.to);
25
- arrowHead(target, mark.from, mark.to);
26
- }
27
- target.stroke();
28
- }
29
- function drawn(mark) {
30
- if (mark.tool === "pen") return mark.points.length > 1;
31
- if (mark.tool === "box") return mark.width > 0 || mark.height > 0;
32
- return mark.from[0] !== mark.to[0] || mark.from[1] !== mark.to[1];
33
- }
34
- /** Draws on `canvas` in image pixels, and repaints it from a clean copy on every change. */
35
- function annotate(canvas, onChange) {
36
- const clean = document.createElement("canvas");
37
- clean.width = canvas.width;
38
- clean.height = canvas.height;
39
- context(clean).drawImage(canvas, 0, 0);
40
- const target = context(canvas);
41
- const marks = [];
42
- let current = null;
43
- function paint() {
44
- target.setTransform(1, 0, 0, 1, 0, 0);
45
- target.drawImage(clean, 0, 0);
46
- target.strokeStyle = getComputedStyle(canvas).color;
47
- target.lineWidth = Math.max(4, Math.round(canvas.width / 240));
48
- target.lineCap = "round";
49
- target.lineJoin = "round";
50
- for (const mark of current ? [...marks, current.mark] : marks) stroke(target, mark);
51
- }
52
- function point(event) {
53
- const box = canvas.getBoundingClientRect();
54
- const x = (event.clientX - box.left - canvas.clientLeft) * canvas.width / canvas.clientWidth;
55
- const y = (event.clientY - box.top - canvas.clientTop) * canvas.height / canvas.clientHeight;
56
- return [Math.round(Math.min(canvas.width, Math.max(0, x))), Math.round(Math.min(canvas.height, Math.max(0, y)))];
57
- }
58
- function shape(tool, start, end) {
59
- if (tool === "pen") return {
60
- tool,
61
- points: [start]
62
- };
63
- if (tool === "arrow") return {
64
- tool,
65
- from: start,
66
- to: end
67
- };
68
- return {
69
- tool,
70
- x: Math.min(start[0], end[0]),
71
- y: Math.min(start[1], end[1]),
72
- width: Math.abs(end[0] - start[0]),
73
- height: Math.abs(end[1] - start[1])
74
- };
75
- }
76
- canvas.addEventListener("pointerdown", (event) => {
77
- if (current || !event.isPrimary || event.button !== 0) return;
78
- event.preventDefault();
79
- canvas.setPointerCapture(event.pointerId);
80
- const start = point(event);
81
- current = {
82
- id: event.pointerId,
83
- start,
84
- mark: shape(annotator.tool, start, start)
85
- };
86
- });
87
- function extend(event) {
88
- if (!current) return;
89
- const end = point(event);
90
- const last = current.mark.tool === "pen" ? current.mark.points.at(-1) : void 0;
91
- if (current.mark.tool === "pen") {
92
- if (last?.[0] !== end[0] || last?.[1] !== end[1]) current.mark.points.push(end);
93
- } else current.mark = shape(current.mark.tool, current.start, end);
94
- }
95
- function end() {
96
- if (!current) return;
97
- if (drawn(current.mark)) marks.push(current.mark);
98
- current = null;
99
- paint();
100
- onChange();
101
- }
102
- canvas.addEventListener("pointermove", (event) => {
103
- if (current?.id !== event.pointerId) return;
104
- if (event.buttons === 0) return end();
105
- extend(event);
106
- paint();
107
- });
108
- canvas.addEventListener("pointerup", (event) => {
109
- if (current?.id !== event.pointerId) return;
110
- extend(event);
111
- end();
112
- });
113
- for (const type of ["pointercancel", "lostpointercapture"]) canvas.addEventListener(type, (event) => {
114
- if (current?.id !== event.pointerId) return;
115
- current = null;
116
- paint();
117
- });
118
- const annotator = {
119
- tool: "pen",
120
- marks,
121
- end,
122
- undo() {
123
- marks.pop();
124
- paint();
125
- onChange();
126
- },
127
- clear() {
128
- marks.length = 0;
129
- paint();
130
- onChange();
131
- }
132
- };
133
- return annotator;
134
- }
135
- //#endregion
136
- //#region src/dialog.ts
137
- const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;box-sizing:border-box;background:none;color:inherit;display:grid;place-items:center;overflow:auto}.backdrop:not([open]){display:none}.backdrop::backdrop{background:none}.dialog{box-sizing:border-box;width:min(560px,100% - 32px);display:grid;gap:8px}.dialog.wide{width:min(1100px,100% - 32px)}.image{display:block;max-width:100%;max-height:60vh;margin:0 auto;color:var(--_mark)}.toolbar+.image{touch-action:none}.toolbar{display:flex;flex-wrap:wrap;gap:8px}.note{box-sizing:border-box;width:100%;min-height:96px;resize:vertical}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.live{position:fixed;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`;
138
- const THEME = `.backdrop{background:var(--_bd)}.dialog{background:var(--_s);color:var(--_t);border-radius:var(--_r);box-shadow:var(--_sh);padding:16px}.title{margin:0 0 4px;font-size:1.15em}.label{font-weight:600}.image{border:1px solid var(--_b);border-radius:calc(var(--_r) / 2)}.error{margin:0}.note,.category,.actions button,.toolbar button{font:inherit;color:inherit;background:var(--_s);border:1px solid var(--_b);border-radius:calc(var(--_r) / 2)}.note,.category{padding:8px}.note::placeholder{color:var(--_m)}.actions button,.toolbar button{padding:6px 14px;cursor:pointer}.toolbar button:disabled{opacity:.5;cursor:default}.toolbar [aria-pressed=true]{border-color:var(--_a);box-shadow:inset 0 0 0 1px var(--_a)}.toolbar+.image{cursor:crosshair}.actions .send{font-weight:600;background:var(--_a);color:var(--_at);border-color:var(--_a)}.actions .send:where([data-ink=dark]){color:#000}.actions [aria-disabled=true]{opacity:.6;cursor:progress}.note:disabled,.category:disabled{opacity:.6}.status{margin:0}`;
139
- const ENGLISH = {
140
- title: "Send feedback",
141
- capturing: "Taking a screenshot…",
142
- captureError: "Couldn't take a screenshot.",
143
- sendWithout: "Send without screenshot",
144
- noteLabel: "Your note",
145
- notePlaceholder: "What happened, or what would you change?",
146
- noteRequired: "Write a few words first.",
147
- categoryLabel: "Type",
148
- pen: "Draw",
149
- box: "Box",
150
- arrow: "Arrow",
151
- undo: "Undo",
152
- clear: "Clear",
153
- cancel: "Cancel",
154
- send: "Send",
155
- sending: "Sending…",
156
- sent: "Thanks, it's sent.",
157
- error: "It didn't send. Your note is kept.",
158
- retry: "Try again"
159
- };
160
- const texts = (strings = {}) => Object.assign({}, ENGLISH, ...Object.entries(strings).map(([key, text]) => text === void 0 ? {} : { [key]: text }));
161
- /** Tells a screen reader the screenshot is being taken; returns what removes it. */
162
- function announce(root, strings) {
163
- const live = element("p", "live", { role: "status" });
164
- root.append(live);
165
- const frame = requestAnimationFrame(() => {
166
- live.textContent = texts(strings).capturing;
167
- });
168
- return () => {
169
- cancelAnimationFrame(frame);
170
- live.remove();
171
- };
172
- }
173
- function css(unstyled) {
174
- return unstyled ? [LAYOUT] : [LAYOUT, THEME];
175
- }
176
- function element(tag, name, attributes = {}) {
177
- const result = document.createElement(tag);
178
- result.className = name;
179
- result.setAttribute("part", name);
180
- for (const [key, value] of Object.entries(attributes)) result.setAttribute(key, value);
181
- return result;
182
- }
183
- function button(name, text, onClick) {
184
- const result = element("button", name, { type: "button" });
185
- result.textContent = text;
186
- result.addEventListener("click", onClick);
187
- return result;
188
- }
189
- function ink(button) {
190
- const style = getComputedStyle(button);
191
- if (style.getPropertyValue("--vf-accent-text").trim()) return;
192
- let [r = 0, g = 0, b = 0] = /^rgba?\(([\d.]+), ([\d.]+), ([\d.]+)/.exec(style.backgroundColor)?.slice(1).map(Number) ?? [];
193
- if (!/^rgb/.test(style.backgroundColor)) {
194
- const pixel = document.createElement("canvas").getContext("2d");
195
- if (!pixel) return;
196
- pixel.fillStyle = style.backgroundColor;
197
- pixel.fillRect(0, 0, 1, 1);
198
- [r = 0, g = 0, b = 0] = pixel.getImageData(0, 0, 1, 1).data;
199
- }
200
- const linear = (c) => c / 255 <= .04045 ? c / 255 / 12.92 : ((c / 255 + .055) / 1.055) ** 2.4;
201
- if ((.2126 * linear(r) + .7152 * linear(g) + .0722 * linear(b) + .05) ** 2 > .0525) button.dataset.ink = "dark";
202
- }
203
- const LOCK = Symbol.for("visual-feedback.scroll-lock");
204
- function lock() {
205
- const html = document.documentElement;
206
- html[LOCK] ??= {
207
- count: 0,
208
- saved: [],
209
- attribute: false
210
- };
211
- return html[LOCK];
212
- }
213
- function lockScroll() {
214
- const state = lock();
215
- if (state.count++ > 0) return;
216
- const style = document.documentElement.style;
217
- state.attribute = document.documentElement.hasAttribute("style");
218
- state.saved = ["overflow-x", "overflow-y"].map((name) => [
219
- name,
220
- style.getPropertyValue(name),
221
- style.getPropertyPriority(name)
222
- ]);
223
- style.setProperty("overflow", "hidden", "important");
224
- }
225
- function unlockScroll() {
226
- const state = lock();
227
- if (--state.count > 0) return;
228
- const style = document.documentElement.style;
229
- for (const [name, value, priority] of state.saved) if (value) style.setProperty(name, value, priority);
230
- else style.removeProperty(name);
231
- state.saved = [];
232
- if (!state.attribute && !style.length) document.documentElement.removeAttribute("style");
233
- }
234
- const FOCUSABLE = "button,textarea,input,select,a[href],[tabindex]:not([tabindex='-1'])";
235
- const NOTE_LIMIT = 2e3;
236
- function mount(parent, handlers, settings = {}) {
237
- const tools = settings.tools ?? [
238
- "pen",
239
- "box",
240
- "arrow"
241
- ];
242
- const text = texts(settings.strings);
243
- const backdrop = element("dialog", "backdrop", {
244
- "aria-modal": "true",
245
- "aria-labelledby": "vf-title"
246
- });
247
- const panel = element("div", "dialog");
248
- const title = element("h2", "title", { id: "vf-title" });
249
- title.textContent = text.title;
250
- backdrop.append(panel);
251
- let marks = () => [];
252
- let locked = [];
253
- let inert = [];
254
- let primary;
255
- let draft;
256
- let actions;
257
- let failure;
258
- function toolbar(image) {
259
- const choices = [...new Set(tools)].filter((tool) => [
260
- "pen",
261
- "box",
262
- "arrow"
263
- ].includes(tool));
264
- if (choices.length === 0) return [];
265
- const bar = element("div", "toolbar");
266
- const undo = button("undo", text.undo, () => annotator.undo());
267
- const clear = button("clear", text.clear, () => annotator.clear());
268
- const update = () => {
269
- const none = annotator.marks.length === 0;
270
- const root = parent.getRootNode();
271
- if (none && (root.activeElement === undo || root.activeElement === clear)) buttons.find((choice) => choice.getAttribute("aria-pressed") === "true")?.focus();
272
- undo.disabled = clear.disabled = none;
273
- };
274
- const annotator = annotate(image, update);
275
- const pick = (tool) => {
276
- annotator.tool = tool;
277
- for (const [i, choice] of buttons.entries()) {
278
- const active = choices[i] === tool;
279
- choice.setAttribute("aria-pressed", String(active));
280
- choice.setAttribute("part", active ? "tool tool-active" : "tool");
281
- }
282
- };
283
- const buttons = choices.map((tool) => button("tool", text[tool], () => pick(tool)));
284
- pick(choices[0]);
285
- update();
286
- marks = () => {
287
- annotator.end();
288
- return [...annotator.marks];
289
- };
290
- bar.append(...buttons, undo, clear);
291
- locked.push(...buttons, undo, clear);
292
- return [bar];
293
- }
294
- let current = null;
295
- let seen;
296
- let report;
297
- let mounted = true;
298
- async function deliver(draft) {
299
- sending();
300
- const options = handlers.options();
301
- let module;
302
- try {
303
- module = await import("./report-BRZX6sWL.mjs");
304
- if (!report) {
305
- const capture = await import("./capture-CufRosj3.mjs");
306
- const image = current ? await capture.encode(current, options.capture) : null;
307
- const built = await module.build(draft, image, seen, options);
308
- if (mounted) report = built;
309
- }
310
- if (!mounted || !report) return;
311
- handlers.emit("submit", report);
312
- await module.deliver(report, options);
313
- handlers.emit("sent", report);
314
- if (mounted) sent();
315
- } catch (error) {
316
- handlers.emit("error", error);
317
- if (mounted) failed(module?.shown(error));
318
- }
319
- }
320
- function show(view) {
321
- current = view.error ? null : view.image;
322
- marks = () => [];
323
- locked = [];
324
- inert = [];
325
- primary = void 0;
326
- draft = void 0;
327
- failure = void 0;
328
- actions = element("div", "actions");
329
- const cancel = button("cancel", text.cancel, handlers.close);
330
- if (view.error) {
331
- const message = element("p", "error", { role: "alert" });
332
- message.textContent = text.captureError;
333
- const retry = button("retry", text.retry, handlers.retry);
334
- actions.append(cancel, button("send-without", text.sendWithout, () => show({ image: null })), retry);
335
- panel.classList.remove("wide");
336
- panel.replaceChildren(title, message, actions);
337
- retry.focus();
338
- return;
339
- }
340
- const label = element("label", "label", { for: "vf-note" });
341
- label.textContent = text.noteLabel;
342
- const note = element("textarea", "note", {
343
- id: "vf-note",
344
- placeholder: text.notePlaceholder,
345
- maxlength: String(NOTE_LIMIT)
346
- });
347
- const required = element("p", "error", {
348
- id: "vf-note-error",
349
- role: "alert"
350
- });
351
- required.textContent = text.noteRequired;
352
- note.addEventListener("input", () => {
353
- if (!note.value.trim()) return;
354
- note.removeAttribute("aria-invalid");
355
- note.removeAttribute("aria-describedby");
356
- required.remove();
357
- });
358
- const fields = [label, note];
359
- let category;
360
- if (settings.categories?.length) {
361
- const name = element("label", "label", { for: "vf-category" });
362
- name.textContent = text.categoryLabel;
363
- category = element("select", "category", { id: "vf-category" });
364
- for (const choice of settings.categories) {
365
- const option = document.createElement("option");
366
- option.value = choice.id;
367
- option.textContent = choice.label;
368
- category.append(option);
369
- }
370
- fields.push(name, category);
371
- }
372
- const image = view.image;
373
- if (image) {
374
- image.className = "image";
375
- image.setAttribute("part", "image");
376
- image.removeAttribute("style");
377
- inert.push(image);
378
- }
379
- const drawing = image ? [...toolbar(image), image] : [];
380
- const send = button("send", text.send, () => {
381
- if (draft) {
382
- if (send.getAttribute("aria-disabled") !== "true") deliver(draft);
383
- return;
384
- }
385
- if (settings.requireNote !== false && !note.value.trim()) {
386
- note.setAttribute("aria-invalid", "true");
387
- note.setAttribute("aria-describedby", required.id);
388
- note.after(required);
389
- note.focus();
390
- return;
391
- }
392
- draft = {
393
- note: note.value,
394
- category: category?.value,
395
- marks: marks()
396
- };
397
- deliver(draft);
398
- });
399
- primary = send;
400
- locked.push(note, ...category ? [category] : []);
401
- actions.append(cancel, send);
402
- panel.classList.toggle("wide", Boolean(image));
403
- panel.replaceChildren(title, ...drawing, ...fields, actions);
404
- ink(send);
405
- note.focus();
406
- }
407
- function sending() {
408
- if (!primary) return;
409
- for (const control of locked) control.disabled = true;
410
- for (const item of inert) item.inert = true;
411
- failure?.remove();
412
- primary.setAttribute("aria-disabled", "true");
413
- primary.textContent = text.sending;
414
- primary.setAttribute("part", "send");
415
- primary.focus();
416
- }
417
- function sent() {
418
- const status = element("p", "status", {
419
- role: "status",
420
- tabindex: "-1"
421
- });
422
- status.textContent = text.sent;
423
- panel.classList.remove("wide");
424
- panel.replaceChildren(title, status);
425
- status.focus();
426
- done = setTimeout(handlers.close, 3e3);
427
- }
428
- function failed(message = text.error) {
429
- if (!primary || !actions) return;
430
- failure = element("p", "error", { role: "alert" });
431
- failure.textContent = message;
432
- actions.before(failure);
433
- primary.removeAttribute("aria-disabled");
434
- primary.textContent = text.retry;
435
- primary.setAttribute("part", "retry");
436
- primary.focus();
437
- }
438
- backdrop.addEventListener("cancel", (event) => {
439
- event.preventDefault();
440
- handlers.close();
441
- });
442
- backdrop.addEventListener("close", handlers.close);
443
- backdrop.addEventListener("keydown", (event) => {
444
- if (event.key !== "Tab") return;
445
- event.preventDefault();
446
- const items = [...panel.querySelectorAll(FOCUSABLE)].filter((item) => !item.matches(":disabled") && item.getClientRects().length > 0);
447
- const active = parent.getRootNode().activeElement;
448
- const index = active ? items.indexOf(active) : -1;
449
- const step = event.shiftKey ? -1 : 1;
450
- const next = index < 0 ? event.shiftKey ? items.length - 1 : 0 : index + step;
451
- items.at(next % items.length)?.focus();
452
- });
453
- parent.append(backdrop);
454
- try {
455
- backdrop.showModal();
456
- } catch (error) {
457
- backdrop.remove();
458
- throw error;
459
- }
460
- let done;
461
- let scrollLocked = false;
462
- const lock = (on) => {
463
- if (on !== scrollLocked) (on ? lockScroll : unlockScroll)();
464
- scrollLocked = on;
465
- };
466
- return {
467
- show,
468
- capture(image) {
469
- seen = (image && shots.get(image)) ?? look();
470
- show(image ? { image } : { error: true });
471
- lock(true);
472
- },
473
- lock,
474
- unmount() {
475
- mounted = false;
476
- clearTimeout(done);
477
- lock(false);
478
- backdrop.removeEventListener("close", handlers.close);
479
- if (backdrop.open) backdrop.close();
480
- backdrop.remove();
481
- }
482
- };
483
- }
484
- //#endregion
485
- export { announce, css, mount };