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
package/docs/recipes/supabase.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# Recipe: Supabase
|
|
2
2
|
|
|
3
|
-
Each report becomes one row in a table and
|
|
4
|
-
|
|
5
|
-
(docs/recipes/postgres.md), except that `user_id` is the
|
|
6
|
-
Storage instead of
|
|
7
|
-
only your app's admins read either,
|
|
3
|
+
Each report becomes one row in a table and one image per screenshot in the private Storage bucket
|
|
4
|
+
`feedback`, named `<id>/screenshot-<n>.png` or `.webp`, numbered from 0. The row has the columns of
|
|
5
|
+
the Postgres recipe's `feedback_reports` (docs/recipes/postgres.md), except that `user_id` is the
|
|
6
|
+
Supabase user's uuid. The images are in Storage instead of a second table, and each screenshot's
|
|
7
|
+
`image` in `report` is its file name. Row-level security lets only your app's admins read either,
|
|
8
|
+
and nobody but your server can write.
|
|
8
9
|
|
|
9
10
|
`user_id` is the proof of who sent a report; `report.context` is not (docs/API.md). The images show
|
|
10
11
|
the screen as the user saw it, with nothing masked (SPEC §7).
|
|
@@ -26,8 +27,7 @@ create table feedback_reports (
|
|
|
26
27
|
id uuid primary key,
|
|
27
28
|
user_id uuid not null references auth.users (id) on delete cascade,
|
|
28
29
|
created_at timestamptz not null,
|
|
29
|
-
report jsonb not null
|
|
30
|
-
image_type text
|
|
30
|
+
report jsonb not null
|
|
31
31
|
);
|
|
32
32
|
alter table feedback_reports enable row level security;
|
|
33
33
|
grant select on feedback_reports to authenticated;
|
|
@@ -49,9 +49,9 @@ error, never a row, and a download from `feedback` is refused.
|
|
|
49
49
|
|
|
50
50
|
An admin screen reads with the admin's own session, never the service role key:
|
|
51
51
|
`from("feedback_reports").select()` for the rows, filtered and paged as the screen needs, and
|
|
52
|
-
`storage.from("feedback").createSignedUrl("<id
|
|
53
|
-
`
|
|
54
|
-
it, never share it.
|
|
52
|
+
`storage.from("feedback").createSignedUrl("<id>/<image>", 60)` for a screenshot, where `<image>` is
|
|
53
|
+
its `image` in `report`, such as `screenshot-0.png`. A signed URL works for anyone who holds it for
|
|
54
|
+
those 60 seconds: show it, never share it.
|
|
55
55
|
|
|
56
56
|
Set `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` on the server. The service role key bypasses
|
|
57
57
|
row-level security: never send it to the browser.
|
|
@@ -87,28 +87,28 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
87
87
|
const user = await currentUser(request);
|
|
88
88
|
if (!user) return new Response(null, { status: 401 });
|
|
89
89
|
try {
|
|
90
|
-
const { report
|
|
90
|
+
const { report } = await parseFeedbackRequest(request);
|
|
91
91
|
const db = supabase();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const name = (image: Blob, i: number) =>
|
|
93
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
94
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
95
|
+
// The images first, so a row never lacks its images.
|
|
96
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
95
97
|
const upload = await db.storage
|
|
96
98
|
.from("feedback")
|
|
97
|
-
.upload(name, image, { contentType: image.type, upsert: false });
|
|
99
|
+
.upload(`${report.id}/${name(image, i)}`, image, { contentType: image.type, upsert: false });
|
|
98
100
|
// A resend finds its image there (409); the id comes from the browser, so never replace it.
|
|
99
101
|
const error = upload.error;
|
|
100
102
|
const stored = error && "statusCode" in error && error.statusCode === "409";
|
|
101
103
|
if (error && !stored) throw error;
|
|
102
104
|
}
|
|
103
|
-
const { image: _, ...stored } = report;
|
|
104
105
|
// A "Try again" sends the same id, so the row it already wrote stays as it is.
|
|
105
106
|
const insert = await db.from("feedback_reports").upsert(
|
|
106
107
|
{
|
|
107
108
|
id: report.id,
|
|
108
109
|
user_id: user,
|
|
109
110
|
created_at: report.createdAt,
|
|
110
|
-
report:
|
|
111
|
-
image_type: image?.type ?? null,
|
|
111
|
+
report: { ...report, screenshots },
|
|
112
112
|
},
|
|
113
113
|
{ onConflict: "id", ignoreDuplicates: true },
|
|
114
114
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Recipe: Vercel Blob
|
|
2
2
|
|
|
3
3
|
Each report becomes private blobs, named and shaped as in the S3 recipe (docs/recipes/s3.md):
|
|
4
|
-
`feedback/<id>/report.json` and `feedback/<id>/
|
|
4
|
+
`feedback/<id>/report.json` and `feedback/<id>/screenshot-<n>.png` or `.webp` for each screenshot.
|
|
5
|
+
People read them only
|
|
5
6
|
through your own route, behind your own admin check. `report.context` comes from the browser
|
|
6
7
|
(docs/API.md), so an admin view must not show it as proof of who sent a report. The images show the
|
|
7
8
|
screen as the user saw it, with nothing masked (SPEC §7).
|
|
@@ -16,9 +17,9 @@ on the server. Never send it to the browser. Check it: open
|
|
|
16
17
|
`https://<store id>.private.blob.vercel-storage.com/feedback/<id>/report.json` without the token,
|
|
17
18
|
where the store id is the part after `vercel_blob_rw_` in the token, and Vercel refuses it.
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
(docs/API.md) keeps screenshots under
|
|
20
|
+
Vercel refuses a request body over 4.5 MB, all screenshots of a report together, with 413, and the
|
|
21
|
+
widget then shows its send error. The route takes images up to 3 MB each; a smaller
|
|
22
|
+
`capture.maxWidth` (docs/API.md) keeps several screenshots under the limit.
|
|
22
23
|
|
|
23
24
|
## 2. The route
|
|
24
25
|
|
|
@@ -72,8 +73,8 @@ function blob() {
|
|
|
72
73
|
|
|
73
74
|
export async function POST(request: Request): Promise<Response> {
|
|
74
75
|
try {
|
|
75
|
-
// Vercel refuses a
|
|
76
|
-
const { report
|
|
76
|
+
// Vercel refuses a request body over 4.5 MB, all screenshots together; this caps each image.
|
|
77
|
+
const { report } = await parseFeedbackRequest(request, { maxImageBytes: 3_000_000 });
|
|
77
78
|
const store = blob();
|
|
78
79
|
// The id comes from the browser: once a report is stored, a later request does not replace it.
|
|
79
80
|
// Two requests with one id at the same moment still can; the check and the write are separate.
|
|
@@ -84,10 +85,14 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
84
85
|
if (stored.status !== 404) {
|
|
85
86
|
throw new Error(`Vercel Blob answered ${stored.status} for report.json.`);
|
|
86
87
|
}
|
|
87
|
-
const name = image
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
const name = (image: Blob, i: number) =>
|
|
89
|
+
`screenshot-${i}.${image.type === "image/png" ? "png" : "webp"}`;
|
|
90
|
+
const screenshots = report.screenshots.map((shot, i) => ({ ...shot, image: name(shot.image, i) }));
|
|
91
|
+
// The images first: a report.json then always has its images beside it.
|
|
92
|
+
for (const [i, { image }] of report.screenshots.entries()) {
|
|
93
|
+
await store.put(report.id, name(image, i), image, image.type);
|
|
94
|
+
}
|
|
95
|
+
const json = JSON.stringify({ ...report, screenshots });
|
|
91
96
|
await store.put(report.id, "report.json", json, "application/json");
|
|
92
97
|
return new Response(null, { status: 204 });
|
|
93
98
|
} catch (error) {
|
|
@@ -98,14 +103,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
101
|
-
// GET /api/feedback?id=<report id>&file=report.json |
|
|
106
|
+
// GET /api/feedback?id=<report id>&file=report.json | screenshot-<n>.png | screenshot-<n>.webp
|
|
102
107
|
export async function GET(request: Request): Promise<Response> {
|
|
103
108
|
if (!(await canRead(request))) return new Response(null, { status: 403 });
|
|
104
109
|
const query = new URL(request.url).searchParams;
|
|
105
110
|
const id = query.get("id") ?? "";
|
|
106
111
|
const file = query.get("file") ?? "";
|
|
107
112
|
// Only a report id and a known file name, so the read cannot leave feedback/<id>/.
|
|
108
|
-
|
|
113
|
+
const known = file === "report.json" || /^screenshot-\d\.(png|webp)$/.test(file);
|
|
114
|
+
if (!/^[0-9a-f-]{36}$/.test(id) || !known) {
|
|
109
115
|
return new Response(null, { status: 400 });
|
|
110
116
|
}
|
|
111
117
|
const object = await blob().read(id, file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visual-feedback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"feedback",
|
|
@@ -60,10 +60,13 @@
|
|
|
60
60
|
"test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
|
|
61
61
|
"size": "size-limit",
|
|
62
62
|
"publint": "publint --strict",
|
|
63
|
-
"check": "npm run
|
|
63
|
+
"check": "npm run check:static && npm run check:browser",
|
|
64
|
+
"check:static": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run size && npm run publint",
|
|
65
|
+
"check:browser": "npm run test:e2e && npm run test:react && npm run test:pack",
|
|
64
66
|
"prepack": "npm run --silent build -- --logLevel warn",
|
|
65
67
|
"dev": "node tests/dev.mjs",
|
|
66
|
-
"demo": "node tests/demo.mjs"
|
|
68
|
+
"demo": "node tests/demo.mjs",
|
|
69
|
+
"release": "node tests/release.mjs"
|
|
67
70
|
},
|
|
68
71
|
"devDependencies": {
|
|
69
72
|
"@axe-core/playwright": "^4.13.0",
|
package/dist/dialog-BTY6FKLo.mjs
DELETED
|
@@ -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 };
|