visual-feedback 0.0.0-stage → 0.1.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 +96 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/dist/capture-CufRosj3.mjs +83 -0
- package/dist/dialog-BTY6FKLo.mjs +485 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/react.d.mts +6 -0
- package/dist/react.mjs +29 -0
- package/dist/report-BRZX6sWL.mjs +83 -0
- package/dist/report-BxonlIdW.mjs +97 -0
- package/dist/report-CIgy7uzP.d.mts +43 -0
- package/dist/seen-BpIWldOP.mjs +21 -0
- package/dist/server.d.mts +15 -0
- package/dist/server.mjs +95 -0
- package/dist/widget-CVnusNlY.mjs +278 -0
- package/dist/widget-Ca1heF8n.d.mts +56 -0
- package/docs/API.md +119 -0
- package/docs/SPEC.md +128 -0
- package/docs/THEMING.md +65 -0
- package/docs/recipes/github-issues.md +185 -0
- package/docs/recipes/local.md +71 -0
- package/docs/recipes/postgres.md +92 -0
- package/docs/recipes/s3.md +124 -0
- package/docs/recipes/supabase.md +124 -0
- package/docs/recipes/vercel-blob.md +123 -0
- package/package.json +98 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/shared/report.ts
|
|
3
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
4
|
+
function fail(path) {
|
|
5
|
+
throw new TypeError(`report.${path} has the wrong shape`);
|
|
6
|
+
}
|
|
7
|
+
function object(value, path) {
|
|
8
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
const UNSTORABLE = /\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
12
|
+
const storable = (text) => text.replace(UNSTORABLE, (found) => found === "\0" ? "" : "�");
|
|
13
|
+
function string(value, path) {
|
|
14
|
+
if (typeof value !== "string") fail(path);
|
|
15
|
+
return storable(value);
|
|
16
|
+
}
|
|
17
|
+
function storableDeep(value) {
|
|
18
|
+
if (typeof value === "string") return storable(value);
|
|
19
|
+
if (Array.isArray(value)) return value.map(storableDeep);
|
|
20
|
+
if (value === null || typeof value !== "object") return value;
|
|
21
|
+
const proto = Object.getPrototypeOf(value);
|
|
22
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [storable(key), storableDeep(item)]).filter(([key]) => key !== "__proto__"));
|
|
24
|
+
}
|
|
25
|
+
function number(value, path) {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail(path);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function point(value, path) {
|
|
30
|
+
if (!Array.isArray(value) || value.length !== 2) fail(path);
|
|
31
|
+
return [number(value[0], path), number(value[1], path)];
|
|
32
|
+
}
|
|
33
|
+
function mark(value, path) {
|
|
34
|
+
const fields = object(value, path);
|
|
35
|
+
if (fields.tool === "pen") {
|
|
36
|
+
if (!Array.isArray(fields.points)) fail(`${path}.points`);
|
|
37
|
+
return {
|
|
38
|
+
tool: "pen",
|
|
39
|
+
points: fields.points.map((item, i) => point(item, `${path}.points[${i}]`))
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (fields.tool === "box") return {
|
|
43
|
+
tool: "box",
|
|
44
|
+
x: number(fields.x, `${path}.x`),
|
|
45
|
+
y: number(fields.y, `${path}.y`),
|
|
46
|
+
width: number(fields.width, `${path}.width`),
|
|
47
|
+
height: number(fields.height, `${path}.height`)
|
|
48
|
+
};
|
|
49
|
+
if (fields.tool === "arrow") return {
|
|
50
|
+
tool: "arrow",
|
|
51
|
+
from: point(fields.from, `${path}.from`),
|
|
52
|
+
to: point(fields.to, `${path}.to`)
|
|
53
|
+
};
|
|
54
|
+
return fail(`${path}.tool`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The one shape check for a report without its image, as JSON carries it. Returns a copy that
|
|
58
|
+
* holds only the known fields, or throws a TypeError that names the first wrong field.
|
|
59
|
+
*/
|
|
60
|
+
function checkReport(value) {
|
|
61
|
+
const fields = object(value, "");
|
|
62
|
+
if (typeof fields.id !== "string" || !UUID_V4.test(fields.id)) fail("id");
|
|
63
|
+
const createdAt = string(fields.createdAt, "createdAt");
|
|
64
|
+
const time = Date.parse(createdAt);
|
|
65
|
+
if (!(time >= 0 && time < Date.UTC(1e4, 0, 1)) || new Date(time).toISOString() !== createdAt) fail("createdAt");
|
|
66
|
+
if (fields.image !== void 0 && fields.image !== null) fail("image");
|
|
67
|
+
if (!Array.isArray(fields.marks)) fail("marks");
|
|
68
|
+
const page = object(fields.page, "page");
|
|
69
|
+
const viewport = object(fields.viewport, "viewport");
|
|
70
|
+
const scroll = object(fields.scroll, "scroll");
|
|
71
|
+
return {
|
|
72
|
+
id: fields.id,
|
|
73
|
+
createdAt,
|
|
74
|
+
note: string(fields.note, "note"),
|
|
75
|
+
...fields.category === void 0 ? {} : { category: string(fields.category, "category") },
|
|
76
|
+
marks: fields.marks.map((item, i) => mark(item, `marks[${i}]`)),
|
|
77
|
+
page: {
|
|
78
|
+
url: string(page.url, "page.url"),
|
|
79
|
+
title: string(page.title, "page.title")
|
|
80
|
+
},
|
|
81
|
+
viewport: {
|
|
82
|
+
width: number(viewport.width, "viewport.width"),
|
|
83
|
+
height: number(viewport.height, "viewport.height"),
|
|
84
|
+
devicePixelRatio: number(viewport.devicePixelRatio, "viewport.devicePixelRatio")
|
|
85
|
+
},
|
|
86
|
+
scroll: {
|
|
87
|
+
x: number(scroll.x, "scroll.x"),
|
|
88
|
+
y: number(scroll.y, "scroll.y")
|
|
89
|
+
},
|
|
90
|
+
userAgent: string(fields.userAgent, "userAgent"),
|
|
91
|
+
language: string(fields.language, "language"),
|
|
92
|
+
timeZone: string(fields.timeZone, "timeZone"),
|
|
93
|
+
context: storableDeep(object(fields.context, "context"))
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { checkReport as t };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/shared/report.d.ts
|
|
3
|
+
type Mark = {
|
|
4
|
+
tool: "pen";
|
|
5
|
+
points: [number, number][];
|
|
6
|
+
} | {
|
|
7
|
+
tool: "box";
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
} | {
|
|
13
|
+
tool: "arrow";
|
|
14
|
+
from: [number, number];
|
|
15
|
+
to: [number, number];
|
|
16
|
+
};
|
|
17
|
+
type FeedbackReport = {
|
|
18
|
+
id: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
note: string;
|
|
21
|
+
category?: string;
|
|
22
|
+
image: Blob | null;
|
|
23
|
+
marks: Mark[];
|
|
24
|
+
page: {
|
|
25
|
+
url: string;
|
|
26
|
+
title: string;
|
|
27
|
+
};
|
|
28
|
+
viewport: {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
devicePixelRatio: number;
|
|
32
|
+
};
|
|
33
|
+
scroll: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
};
|
|
37
|
+
userAgent: string;
|
|
38
|
+
language: string;
|
|
39
|
+
timeZone: string;
|
|
40
|
+
context: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { Mark as n, FeedbackReport as t };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/seen.ts
|
|
3
|
+
/** What the page looked like when each screenshot was taken. */
|
|
4
|
+
const shots = /* @__PURE__ */ new WeakMap();
|
|
5
|
+
function look() {
|
|
6
|
+
return {
|
|
7
|
+
url: location.href,
|
|
8
|
+
title: document.title,
|
|
9
|
+
viewport: {
|
|
10
|
+
width: innerWidth,
|
|
11
|
+
height: innerHeight,
|
|
12
|
+
devicePixelRatio
|
|
13
|
+
},
|
|
14
|
+
scroll: {
|
|
15
|
+
x: scrollX,
|
|
16
|
+
y: scrollY
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { shots as n, look as t };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
|
|
2
|
+
import { n as Mark, t as FeedbackReport } from "./report-CIgy7uzP.mjs";
|
|
3
|
+
//#region src/server/index.d.ts
|
|
4
|
+
export declare class FeedbackRequestError extends Error {
|
|
5
|
+
readonly status: 400 | 413;
|
|
6
|
+
constructor(status: 400 | 413, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function parseFeedbackRequest(request: Request, limits?: {
|
|
9
|
+
maxImageBytes?: number;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
report: FeedbackReport;
|
|
12
|
+
image: Blob | null;
|
|
13
|
+
}>;
|
|
14
|
+
//#endregion
|
|
15
|
+
export type { FeedbackReport, Mark };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
import { t as checkReport } from "./report-BxonlIdW.mjs";
|
|
3
|
+
//#region src/server/index.ts
|
|
4
|
+
const DEFAULT_MAX_IMAGE_BYTES = 1e7;
|
|
5
|
+
const REPORT_BYTES = 1048576;
|
|
6
|
+
const FRAMING_BYTES = 65536;
|
|
7
|
+
const PNG = [
|
|
8
|
+
137,
|
|
9
|
+
80,
|
|
10
|
+
78,
|
|
11
|
+
71,
|
|
12
|
+
13,
|
|
13
|
+
10,
|
|
14
|
+
26,
|
|
15
|
+
10
|
|
16
|
+
];
|
|
17
|
+
var FeedbackRequestError = class extends Error {
|
|
18
|
+
status;
|
|
19
|
+
constructor(status, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "FeedbackRequestError";
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
async function read(request, limit) {
|
|
26
|
+
if (Number(request.headers.get("content-length")) > limit) throw new FeedbackRequestError(413, "The request is too large.");
|
|
27
|
+
if (!request.body) throw new FeedbackRequestError(400, "The request has no body.");
|
|
28
|
+
const reader = request.body.getReader();
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
for (;;) {
|
|
32
|
+
let next;
|
|
33
|
+
try {
|
|
34
|
+
next = await reader.read();
|
|
35
|
+
} catch {
|
|
36
|
+
throw new FeedbackRequestError(400, "The request body broke off.");
|
|
37
|
+
}
|
|
38
|
+
const { done, value } = next;
|
|
39
|
+
if (done) return new Blob(chunks);
|
|
40
|
+
size += value.byteLength;
|
|
41
|
+
if (size > limit) {
|
|
42
|
+
await reader.cancel().catch(() => {});
|
|
43
|
+
throw new FeedbackRequestError(413, "The request is too large.");
|
|
44
|
+
}
|
|
45
|
+
chunks.push(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function imageType(bytes) {
|
|
49
|
+
if (PNG.every((byte, i) => bytes[i] === byte)) return "image/png";
|
|
50
|
+
const header = String.fromCharCode(...bytes.subarray(0, 12));
|
|
51
|
+
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP") return "image/webp";
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
async function checkImage(part, maxImageBytes) {
|
|
55
|
+
if (part.size > maxImageBytes) throw new FeedbackRequestError(413, "The image is too large.");
|
|
56
|
+
const type = imageType(new Uint8Array(await part.slice(0, 12).arrayBuffer()));
|
|
57
|
+
if (!type) throw new FeedbackRequestError(400, "The image is not a PNG or WebP.");
|
|
58
|
+
return part.slice(0, part.size, type);
|
|
59
|
+
}
|
|
60
|
+
async function parseFeedbackRequest(request, limits = {}) {
|
|
61
|
+
const maxImageBytes = limits.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
|
|
62
|
+
if (!(maxImageBytes >= 0)) throw new RangeError("maxImageBytes must be a number of bytes.");
|
|
63
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
64
|
+
if (!/^multipart\/form-data\s*;/i.test(contentType)) throw new FeedbackRequestError(400, "The request is not multipart/form-data.");
|
|
65
|
+
const body = await read(request, maxImageBytes + REPORT_BYTES + FRAMING_BYTES);
|
|
66
|
+
let form;
|
|
67
|
+
try {
|
|
68
|
+
form = await new Response(body, { headers: { "content-type": contentType } }).formData();
|
|
69
|
+
} catch {
|
|
70
|
+
throw new FeedbackRequestError(400, "The multipart body cannot be read.");
|
|
71
|
+
}
|
|
72
|
+
const reports = form.getAll("report");
|
|
73
|
+
const images = form.getAll("image");
|
|
74
|
+
const [json] = reports;
|
|
75
|
+
const [part] = images;
|
|
76
|
+
if (reports.length !== 1 || typeof json !== "string") throw new FeedbackRequestError(400, "The request needs one report part, as text.");
|
|
77
|
+
if (images.length > 1 || typeof part === "string") throw new FeedbackRequestError(400, "The request has more than one image, or an image as text.");
|
|
78
|
+
if (new Blob([json]).size > REPORT_BYTES) throw new FeedbackRequestError(413, "The report is too large.");
|
|
79
|
+
let fields;
|
|
80
|
+
try {
|
|
81
|
+
fields = checkReport(JSON.parse(json, (key, value) => key === "__proto__" ? void 0 : value));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new FeedbackRequestError(400, error instanceof Error ? error.message : "Bad report.");
|
|
84
|
+
}
|
|
85
|
+
const image = part ? await checkImage(part, maxImageBytes) : null;
|
|
86
|
+
return {
|
|
87
|
+
report: {
|
|
88
|
+
...fields,
|
|
89
|
+
image
|
|
90
|
+
},
|
|
91
|
+
image
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { FeedbackRequestError, parseFeedbackRequest };
|
|
@@ -0,0 +1,278 @@
|
|
|
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,12px);--_s:var(--vf-surface,Canvas);--_t:var(--vf-text,CanvasText);--_m:var(--vf-muted,color-mix(in srgb,var(--_t) 60%,transparent));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"});--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 4px 16px rgb(0 0 0/12%));--_size:var(--vf-trigger-size,36px);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
|
|
14
|
+
}
|
|
15
|
+
const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{position:fixed;z-index:var(--_z);box-sizing:border-box;width:var(--_size);height:var(--_size);margin:0;padding:0;display:grid;place-items:center}.trigger[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}.trigger[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}.trigger[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}.trigger[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:55%;height:55%}.capturing,.capturing .backdrop{visibility:hidden}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
|
|
16
|
+
const THEME = `.trigger{border:1px solid var(--_b);border-radius:50%;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}`;
|
|
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 noop = {
|
|
43
|
+
open: async () => {},
|
|
44
|
+
close() {},
|
|
45
|
+
destroy() {},
|
|
46
|
+
update() {},
|
|
47
|
+
on: () => () => {}
|
|
48
|
+
};
|
|
49
|
+
function createFeedback(options = {}) {
|
|
50
|
+
if (!supported()) return noop;
|
|
51
|
+
const tag = options.tagName ?? "visual-feedback";
|
|
52
|
+
if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
|
|
53
|
+
disconnectedCallback() {
|
|
54
|
+
this[DISCONNECTED]?.();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const host = document.createElement(tag);
|
|
58
|
+
const root = host.attachShadow({ mode: "open" });
|
|
59
|
+
const wrapper = document.createElement("div");
|
|
60
|
+
wrapper.className = "vf";
|
|
61
|
+
root.append(wrapper);
|
|
62
|
+
let view;
|
|
63
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
64
|
+
const call = (fns, args) => {
|
|
65
|
+
for (const fn of [...fns]) try {
|
|
66
|
+
fn(...args);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reportError(error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const emit = (event, ...args) => call(handlers.get(event) ?? [], args);
|
|
72
|
+
const start = () => void widget.open().catch(() => {});
|
|
73
|
+
let state = "idle";
|
|
74
|
+
let dialog;
|
|
75
|
+
let session = 0;
|
|
76
|
+
let returnFocus = null;
|
|
77
|
+
let pending;
|
|
78
|
+
let trigger;
|
|
79
|
+
let unlisten;
|
|
80
|
+
function listen(shortcut) {
|
|
81
|
+
const parts = shortcut.toLowerCase().split("+");
|
|
82
|
+
const key = parts.pop();
|
|
83
|
+
const names = [
|
|
84
|
+
"alt",
|
|
85
|
+
"shift",
|
|
86
|
+
"control",
|
|
87
|
+
"meta"
|
|
88
|
+
];
|
|
89
|
+
const mods = names.map((name) => parts.includes(name)).join();
|
|
90
|
+
const typing = !/control|meta/.test(shortcut.toLowerCase());
|
|
91
|
+
const on = (event) => {
|
|
92
|
+
if (event instanceof KeyboardEvent && state === "idle" && !event.repeat && !event.isComposing && !event.getModifierState("AltGraph") && parts.every((part) => names.includes(part)) && [
|
|
93
|
+
event.altKey,
|
|
94
|
+
event.shiftKey,
|
|
95
|
+
event.ctrlKey,
|
|
96
|
+
event.metaKey
|
|
97
|
+
].join() === mods && [event.code.replace(/^(Key|Digit)/, ""), event.key].some((name) => name.toLowerCase() === key) && !(typing && field(event.composedPath()[0]))) {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
start();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
addEventListener("keydown", on);
|
|
103
|
+
return () => removeEventListener("keydown", on);
|
|
104
|
+
}
|
|
105
|
+
let base = [];
|
|
106
|
+
let extra = [];
|
|
107
|
+
function sheets() {
|
|
108
|
+
const dialogCss = view?.css(Boolean(options.unstyled)).map(sheet) ?? [];
|
|
109
|
+
root.adoptedStyleSheets = [
|
|
110
|
+
...base,
|
|
111
|
+
...dialogCss,
|
|
112
|
+
...extra
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
function apply() {
|
|
116
|
+
wrapper.style.colorScheme = options.theme?.replace("auto", "") ?? "";
|
|
117
|
+
base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
|
|
118
|
+
extra = options.css ? [sheet(options.css)] : [];
|
|
119
|
+
sheets();
|
|
120
|
+
if (options.trigger === "none") {
|
|
121
|
+
trigger?.remove();
|
|
122
|
+
trigger = void 0;
|
|
123
|
+
} else {
|
|
124
|
+
if (!trigger) {
|
|
125
|
+
trigger = document.createElement("button");
|
|
126
|
+
trigger.type = "button";
|
|
127
|
+
trigger.className = "trigger";
|
|
128
|
+
trigger.setAttribute("part", "trigger");
|
|
129
|
+
trigger.append(icon());
|
|
130
|
+
trigger.addEventListener("click", start);
|
|
131
|
+
wrapper.prepend(trigger);
|
|
132
|
+
}
|
|
133
|
+
const [y, x] = (options.position ?? "bottom-right").split("-");
|
|
134
|
+
trigger.dataset.y = y;
|
|
135
|
+
trigger.dataset.x = x;
|
|
136
|
+
trigger.setAttribute("aria-label", options.strings?.trigger ?? "Send feedback");
|
|
137
|
+
}
|
|
138
|
+
unlisten?.();
|
|
139
|
+
unlisten = options.shortcut ? listen(options.shortcut) : void 0;
|
|
140
|
+
}
|
|
141
|
+
apply();
|
|
142
|
+
(options.container ?? document.body ?? document.documentElement).append(host);
|
|
143
|
+
function hide(on) {
|
|
144
|
+
wrapper.classList.toggle("capturing", on);
|
|
145
|
+
}
|
|
146
|
+
function requestDisplay() {
|
|
147
|
+
const media = navigator.mediaDevices;
|
|
148
|
+
if (options.capture?.mode !== "display" || typeof media?.getDisplayMedia !== "function") return;
|
|
149
|
+
hide(true);
|
|
150
|
+
const stream = media.getDisplayMedia({ video: true });
|
|
151
|
+
stream.catch(() => {});
|
|
152
|
+
return stream;
|
|
153
|
+
}
|
|
154
|
+
function stop(stream) {
|
|
155
|
+
stream?.then((media) => {
|
|
156
|
+
for (const track of media.getTracks()) track.stop();
|
|
157
|
+
}, () => {});
|
|
158
|
+
}
|
|
159
|
+
async function grab(module, mine, stream) {
|
|
160
|
+
hide(true);
|
|
161
|
+
const quiet = view?.announce(root, options.strings);
|
|
162
|
+
try {
|
|
163
|
+
return await module.capture(options.capture, stream);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
} finally {
|
|
167
|
+
quiet?.();
|
|
168
|
+
if (mine === session) hide(false);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function cancel() {
|
|
172
|
+
session++;
|
|
173
|
+
hide(false);
|
|
174
|
+
}
|
|
175
|
+
function teardown() {
|
|
176
|
+
dialog?.unmount();
|
|
177
|
+
dialog = void 0;
|
|
178
|
+
const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
|
|
179
|
+
returnFocus = null;
|
|
180
|
+
target?.focus?.();
|
|
181
|
+
}
|
|
182
|
+
async function show(mine) {
|
|
183
|
+
returnFocus = deepActive();
|
|
184
|
+
const stream = requestDisplay();
|
|
185
|
+
try {
|
|
186
|
+
const [loaded, capture] = await Promise.all([import("./dialog-BTY6FKLo.mjs"), import("./capture-CufRosj3.mjs")]);
|
|
187
|
+
if (mine !== session) return stop(stream);
|
|
188
|
+
view = loaded;
|
|
189
|
+
sheets();
|
|
190
|
+
const image = await grab(capture, mine, stream);
|
|
191
|
+
if (mine !== session) return;
|
|
192
|
+
if (!host.isConnected) {
|
|
193
|
+
state = "idle";
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
dialog = view.mount(wrapper, {
|
|
197
|
+
close: () => widget.close(),
|
|
198
|
+
retry: () => void retry(capture).catch((error) => {
|
|
199
|
+
widget.close();
|
|
200
|
+
emit("error", error);
|
|
201
|
+
}),
|
|
202
|
+
emit,
|
|
203
|
+
options: () => options
|
|
204
|
+
}, options);
|
|
205
|
+
dialog.capture(image);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
stop(stream);
|
|
208
|
+
if (mine === session) {
|
|
209
|
+
state = "idle";
|
|
210
|
+
hide(false);
|
|
211
|
+
teardown();
|
|
212
|
+
}
|
|
213
|
+
emit("error", error);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
state = "open";
|
|
217
|
+
emit("open");
|
|
218
|
+
}
|
|
219
|
+
async function retry(capture) {
|
|
220
|
+
if (wrapper.classList.contains("capturing")) return;
|
|
221
|
+
const mine = session;
|
|
222
|
+
dialog?.lock(false);
|
|
223
|
+
const image = await grab(capture, mine, requestDisplay());
|
|
224
|
+
if (mine !== session) return;
|
|
225
|
+
dialog?.capture(image);
|
|
226
|
+
}
|
|
227
|
+
const widget = {
|
|
228
|
+
open() {
|
|
229
|
+
if (state === "opening" && pending) return pending;
|
|
230
|
+
if (state !== "idle") return Promise.resolve();
|
|
231
|
+
state = "opening";
|
|
232
|
+
pending = show(++session);
|
|
233
|
+
return pending;
|
|
234
|
+
},
|
|
235
|
+
close() {
|
|
236
|
+
if (state !== "open" && state !== "opening") return;
|
|
237
|
+
const wasOpen = state === "open";
|
|
238
|
+
state = "idle";
|
|
239
|
+
cancel();
|
|
240
|
+
teardown();
|
|
241
|
+
if (wasOpen) emit("close");
|
|
242
|
+
},
|
|
243
|
+
destroy() {
|
|
244
|
+
if (state === "destroyed") return;
|
|
245
|
+
const wasOpen = state === "open";
|
|
246
|
+
state = "destroyed";
|
|
247
|
+
cancel();
|
|
248
|
+
unlisten?.();
|
|
249
|
+
if (wasOpen) teardown();
|
|
250
|
+
const closers = wasOpen ? [...handlers.get("close") ?? []] : [];
|
|
251
|
+
handlers.clear();
|
|
252
|
+
host.remove();
|
|
253
|
+
call(closers, []);
|
|
254
|
+
},
|
|
255
|
+
update(next) {
|
|
256
|
+
if (state === "destroyed") return;
|
|
257
|
+
options = {
|
|
258
|
+
...options,
|
|
259
|
+
...next
|
|
260
|
+
};
|
|
261
|
+
apply();
|
|
262
|
+
},
|
|
263
|
+
on(event, fn) {
|
|
264
|
+
if (state === "destroyed") return () => {};
|
|
265
|
+
const set = handlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
266
|
+
handlers.set(event, set);
|
|
267
|
+
const handler = fn;
|
|
268
|
+
set.add(handler);
|
|
269
|
+
return () => set.delete(handler);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
host[DISCONNECTED] = () => queueMicrotask(() => {
|
|
273
|
+
if (!host.isConnected) widget.close();
|
|
274
|
+
});
|
|
275
|
+
return widget;
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
export { createFeedback as t };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
|
|
2
|
+
import { n as Mark, t as FeedbackReport } from "./report-CIgy7uzP.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/widget.d.ts
|
|
12
|
+
/** What each event passes to its handlers. */
|
|
13
|
+
type FeedbackEvents = {
|
|
14
|
+
open: [];
|
|
15
|
+
close: [];
|
|
16
|
+
submit: [report: FeedbackReport];
|
|
17
|
+
sent: [report: FeedbackReport];
|
|
18
|
+
error: [error: unknown];
|
|
19
|
+
};
|
|
20
|
+
type FeedbackEvent = keyof FeedbackEvents;
|
|
21
|
+
/** 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>;
|
|
23
|
+
type FeedbackOptions = {
|
|
24
|
+
onSubmit?: (report: FeedbackReport) => Promise<void>;
|
|
25
|
+
endpoint?: string;
|
|
26
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
27
|
+
capture?: CaptureOptions;
|
|
28
|
+
tools?: Mark["tool"][];
|
|
29
|
+
categories?: {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
}[];
|
|
33
|
+
requireNote?: boolean;
|
|
34
|
+
redactUrl?: boolean;
|
|
35
|
+
context?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
36
|
+
trigger?: "button" | "none";
|
|
37
|
+
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
38
|
+
shortcut?: string | false;
|
|
39
|
+
strings?: Partial<FeedbackStrings>;
|
|
40
|
+
theme?: "auto" | "light" | "dark";
|
|
41
|
+
css?: string;
|
|
42
|
+
unstyled?: boolean;
|
|
43
|
+
container?: HTMLElement;
|
|
44
|
+
tagName?: string;
|
|
45
|
+
};
|
|
46
|
+
type FeedbackWidget = {
|
|
47
|
+
open(): Promise<void>;
|
|
48
|
+
close(): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
/** A key given as undefined goes back to its default. */
|
|
51
|
+
update(options: { [K in keyof FeedbackOptions]?: FeedbackOptions[K] | undefined; }): void;
|
|
52
|
+
on<E extends FeedbackEvent>(event: E, fn: (...args: FeedbackEvents[E]) => void): () => void;
|
|
53
|
+
};
|
|
54
|
+
declare function createFeedback(options?: FeedbackOptions): FeedbackWidget;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { FeedbackWidget as a, FeedbackStrings as i, FeedbackEvents as n, createFeedback as o, FeedbackOptions as r, CaptureOptions as s, FeedbackEvent as t };
|