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.
- package/AGENTS.md +12 -7
- package/CHANGELOG.md +110 -0
- package/README.md +5 -3
- package/dist/dialog-W6m1knfu.mjs +1382 -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-DvGTo0B4.mjs +565 -0
- package/dist/{widget-Ca1heF8n.d.mts → widget-Z1KOUKjx.d.mts} +14 -4
- package/docs/API.md +59 -31
- package/docs/SPEC.md +122 -30
- package/docs/THEMING.md +50 -19
- 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/dist/widget-CVnusNlY.mjs +0 -278
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/match.ts
|
|
3
|
+
const BUTTONS = "button,[role=\"button\"],input[type=\"submit\"],input[type=\"button\"]";
|
|
4
|
+
const SAMPLE = 200;
|
|
5
|
+
let pixel;
|
|
6
|
+
/** A colour as sRGB bytes and alpha; syntax other than rgb() (oklch, hex, names) goes through a canvas. */
|
|
7
|
+
function rgb(color) {
|
|
8
|
+
const match = /^rgba?\(([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:[ ,/]+([\d.]+)(%?))?\)/.exec(color);
|
|
9
|
+
if (match) {
|
|
10
|
+
const alpha = match[4] === void 0 ? 1 : Number(match[4]) / (match[5] ? 100 : 1);
|
|
11
|
+
return [
|
|
12
|
+
Number(match[1]),
|
|
13
|
+
Number(match[2]),
|
|
14
|
+
Number(match[3]),
|
|
15
|
+
alpha
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
if (pixel === void 0) pixel = Object.assign(document.createElement("canvas"), {
|
|
19
|
+
width: 1,
|
|
20
|
+
height: 1
|
|
21
|
+
}).getContext("2d", { willReadFrequently: true });
|
|
22
|
+
if (!pixel || !color) return void 0;
|
|
23
|
+
pixel.clearRect(0, 0, 1, 1);
|
|
24
|
+
pixel.fillStyle = "#0000";
|
|
25
|
+
pixel.fillStyle = color;
|
|
26
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
27
|
+
const [r = 0, g = 0, b = 0, a = 0] = pixel.getImageData(0, 0, 1, 1).data;
|
|
28
|
+
return [
|
|
29
|
+
r,
|
|
30
|
+
g,
|
|
31
|
+
b,
|
|
32
|
+
a / 255
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
function luminance([r, g, b]) {
|
|
36
|
+
const linear = (c) => c / 255 <= .04045 ? c / 255 / 12.92 : ((c / 255 + .055) / 1.055) ** 2.4;
|
|
37
|
+
return .2126 * linear(r) + .7152 * linear(g) + .0722 * linear(b);
|
|
38
|
+
}
|
|
39
|
+
function contrast(a, b) {
|
|
40
|
+
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
41
|
+
return ((light ?? 0) + .05) / ((dark ?? 0) + .05);
|
|
42
|
+
}
|
|
43
|
+
const css = ([r, g, b]) => `rgb(${r} ${g} ${b})`;
|
|
44
|
+
const opaque = (color) => (color?.[3] ?? 0) > .99;
|
|
45
|
+
function vivid(color) {
|
|
46
|
+
if (!opaque(color)) return false;
|
|
47
|
+
const max = Math.max(color[0], color[1], color[2]);
|
|
48
|
+
const min = Math.min(color[0], color[1], color[2]);
|
|
49
|
+
const light = (max + min) / 510;
|
|
50
|
+
return (max - min) / 255 >= .2 && light > .1 && light < .9;
|
|
51
|
+
}
|
|
52
|
+
function visible(element) {
|
|
53
|
+
return element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden";
|
|
54
|
+
}
|
|
55
|
+
function commonest(colors) {
|
|
56
|
+
const counts = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const color of colors) {
|
|
58
|
+
const entry = counts.get(css(color)) ?? [color, 0];
|
|
59
|
+
counts.set(css(color), [entry[0], entry[1] + 1]);
|
|
60
|
+
}
|
|
61
|
+
return [...counts.values()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
62
|
+
}
|
|
63
|
+
function themeColor() {
|
|
64
|
+
for (const meta of document.querySelectorAll("meta[name=\"theme-color\"]")) {
|
|
65
|
+
const media = meta.getAttribute("media");
|
|
66
|
+
if (!media || matchMedia(media).matches) return rgb(meta.content.trim());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Takes the page's own accent, corners and, with `theme: "auto"`, background and text, where the
|
|
71
|
+
* host set no --vf-* for them. Each guess that fails contrast falls back to the defaults.
|
|
72
|
+
*/
|
|
73
|
+
function match(wrapper, theme) {
|
|
74
|
+
const style = wrapper.style;
|
|
75
|
+
for (const name of [
|
|
76
|
+
"--_pa",
|
|
77
|
+
"--_pm",
|
|
78
|
+
"--_pr",
|
|
79
|
+
"--_ps",
|
|
80
|
+
"--_pt"
|
|
81
|
+
]) style.setProperty(name, "initial");
|
|
82
|
+
style.colorScheme = theme?.replace("auto", "") ?? "";
|
|
83
|
+
if (!document.body || !wrapper.isConnected) return;
|
|
84
|
+
try {
|
|
85
|
+
guess(wrapper, theme, document.body);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
function guess(wrapper, theme, body) {
|
|
89
|
+
const style = wrapper.style;
|
|
90
|
+
const own = getComputedStyle(wrapper);
|
|
91
|
+
const free = (variable) => !own.getPropertyValue(variable).trim();
|
|
92
|
+
if ((theme ?? "auto") === "auto" && free("--vf-surface") && free("--vf-text")) {
|
|
93
|
+
const background = [body, document.documentElement].map((element) => rgb(getComputedStyle(element).backgroundColor)).find(opaque);
|
|
94
|
+
const text = rgb(getComputedStyle(body).color);
|
|
95
|
+
if (background && opaque(text) && contrast(background, text) >= 4.5) {
|
|
96
|
+
style.setProperty("--_ps", css(background));
|
|
97
|
+
style.setProperty("--_pt", css(text));
|
|
98
|
+
const mixed = (share) => [
|
|
99
|
+
share * text[0] + (1 - share) * background[0],
|
|
100
|
+
share * text[1] + (1 - share) * background[1],
|
|
101
|
+
share * text[2] + (1 - share) * background[2],
|
|
102
|
+
1
|
|
103
|
+
];
|
|
104
|
+
let share = .6;
|
|
105
|
+
while (share < 1 && contrast(background, mixed(share)) < 4.5) share += .05;
|
|
106
|
+
if (share > .6) style.setProperty("--_pm", css(mixed(Math.min(1, share))));
|
|
107
|
+
style.colorScheme = luminance(background) > .179 ? "light" : "dark";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const browser = /* @__PURE__ */ new Map();
|
|
111
|
+
const plain = (scheme) => {
|
|
112
|
+
if (!browser.has(scheme)) {
|
|
113
|
+
const native = wrapper.appendChild(document.createElement("button"));
|
|
114
|
+
native.style.all = "revert";
|
|
115
|
+
native.style.colorScheme = scheme;
|
|
116
|
+
const look = getComputedStyle(native);
|
|
117
|
+
browser.set(scheme, `${look.backgroundColor} ${look.borderTopLeftRadius}`);
|
|
118
|
+
native.remove();
|
|
119
|
+
}
|
|
120
|
+
return browser.get(scheme);
|
|
121
|
+
};
|
|
122
|
+
const filled = [...document.querySelectorAll(BUTTONS)].slice(0, SAMPLE).filter(visible).map((button) => [button, getComputedStyle(button)]).filter(([, look]) => {
|
|
123
|
+
const face = `${look.backgroundColor} ${look.borderTopLeftRadius}`;
|
|
124
|
+
return face !== plain("light") && face !== plain("dark");
|
|
125
|
+
});
|
|
126
|
+
if (free("--vf-radius")) {
|
|
127
|
+
const shapes = filled.filter(([, look]) => opaque(rgb(look.backgroundColor))).map(([button, look]) => {
|
|
128
|
+
const radius = Number.parseFloat(look.borderTopLeftRadius) || 0;
|
|
129
|
+
return {
|
|
130
|
+
radius,
|
|
131
|
+
pill: radius * 2 >= button.getBoundingClientRect().height - 1
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
const corners = shapes.filter((shape) => !shape.pill).map((shape) => shape.radius);
|
|
135
|
+
corners.sort((a, b) => a - b);
|
|
136
|
+
const middle = corners[Math.floor((corners.length - 1) / 2)];
|
|
137
|
+
if (middle !== void 0 && corners.length * 2 > shapes.length) style.setProperty("--_pr", `${Math.min(24, middle * 1.6)}px`);
|
|
138
|
+
}
|
|
139
|
+
if (free("--vf-accent")) {
|
|
140
|
+
const links = () => [...document.querySelectorAll("a[href]")].slice(0, SAMPLE).filter(visible).map((link) => rgb(getComputedStyle(link).color)).filter(vivid);
|
|
141
|
+
const accent = [
|
|
142
|
+
themeColor(),
|
|
143
|
+
commonest(filled.map(([, look]) => rgb(look.backgroundColor)).filter(vivid)),
|
|
144
|
+
commonest(links())
|
|
145
|
+
].find(vivid);
|
|
146
|
+
const probe = wrapper.appendChild(document.createElement("i"));
|
|
147
|
+
probe.style.background = "var(--_s)";
|
|
148
|
+
const surface = rgb(getComputedStyle(probe).backgroundColor);
|
|
149
|
+
probe.remove();
|
|
150
|
+
if (accent && opaque(surface) && contrast(accent, surface) >= 3) style.setProperty("--_pa", css(accent));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/widget.ts
|
|
155
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
156
|
+
function supported() {
|
|
157
|
+
return typeof document !== "undefined" && "adoptedStyleSheets" in Document.prototype && "adoptedStyleSheets" in ShadowRoot.prototype && CSS.supports("color", "color-mix(in srgb, red, red)");
|
|
158
|
+
}
|
|
159
|
+
function sheet(text) {
|
|
160
|
+
const result = new CSSStyleSheet();
|
|
161
|
+
result.replaceSync(text);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
function variables() {
|
|
165
|
+
return `.vf{--_r:var(--vf-radius,var(--_pr,16px));--_s:var(--vf-surface,var(--_ps,Canvas));--_t:var(--vf-text,var(--_pt,CanvasText));--_m:var(--vf-muted,var(--_pm,color-mix(in srgb,var(--_t) 60%,transparent)));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,var(--_pa,${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)}`;
|
|
166
|
+
}
|
|
167
|
+
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}.still .trigger{transition:none!important}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
|
|
168
|
+
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))}`;
|
|
169
|
+
function icon() {
|
|
170
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
171
|
+
const path = document.createElementNS(SVG, "path");
|
|
172
|
+
for (const [name, value] of Object.entries({
|
|
173
|
+
viewBox: "0 0 24 24",
|
|
174
|
+
"aria-hidden": "true",
|
|
175
|
+
fill: "none",
|
|
176
|
+
stroke: "currentColor",
|
|
177
|
+
"stroke-width": "2",
|
|
178
|
+
"stroke-linejoin": "round",
|
|
179
|
+
part: "trigger-icon"
|
|
180
|
+
})) svg.setAttribute(name, value);
|
|
181
|
+
path.setAttribute("d", "M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.4A8 8 0 1 1 21 12z");
|
|
182
|
+
svg.append(path);
|
|
183
|
+
return svg;
|
|
184
|
+
}
|
|
185
|
+
function field(element) {
|
|
186
|
+
return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName);
|
|
187
|
+
}
|
|
188
|
+
function deepActive() {
|
|
189
|
+
let active = document.activeElement;
|
|
190
|
+
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
|
191
|
+
return active;
|
|
192
|
+
}
|
|
193
|
+
const DISCONNECTED = Symbol.for("visual-feedback.disconnected");
|
|
194
|
+
const CONNECTED = Symbol.for("visual-feedback.connected");
|
|
195
|
+
const owners = /* @__PURE__ */ new Map();
|
|
196
|
+
const enders = /* @__PURE__ */ new WeakMap();
|
|
197
|
+
let dialogs;
|
|
198
|
+
let discarding = Promise.resolve();
|
|
199
|
+
/**
|
|
200
|
+
* Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
|
|
201
|
+
* it back; a widget holding it, open or folded, ends it.
|
|
202
|
+
*/
|
|
203
|
+
async function discardDraft(tagName = "visual-feedback") {
|
|
204
|
+
const key = `visual-feedback:${tagName}`;
|
|
205
|
+
let id = null;
|
|
206
|
+
try {
|
|
207
|
+
id = sessionStorage.getItem(key);
|
|
208
|
+
sessionStorage.removeItem(key);
|
|
209
|
+
} catch {}
|
|
210
|
+
const owner = owners.get(tagName);
|
|
211
|
+
if (owner) enders.get(owner)?.();
|
|
212
|
+
const mine = discarding.then(async () => {
|
|
213
|
+
await (await dialogs?.catch(() => void 0))?.settled();
|
|
214
|
+
if (id) await (await import("./keep-BG5WxZPU.mjs")).forget(id);
|
|
215
|
+
});
|
|
216
|
+
discarding = mine.catch(() => {});
|
|
217
|
+
await mine;
|
|
218
|
+
}
|
|
219
|
+
const noop = {
|
|
220
|
+
open: async () => {},
|
|
221
|
+
close() {},
|
|
222
|
+
destroy() {},
|
|
223
|
+
update() {},
|
|
224
|
+
on: () => () => {}
|
|
225
|
+
};
|
|
226
|
+
function createFeedback(options = {}) {
|
|
227
|
+
if (!supported()) return noop;
|
|
228
|
+
const tag = options.tagName ?? "visual-feedback";
|
|
229
|
+
if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
|
|
230
|
+
connectedCallback() {
|
|
231
|
+
this[CONNECTED]?.();
|
|
232
|
+
}
|
|
233
|
+
disconnectedCallback() {
|
|
234
|
+
this[DISCONNECTED]?.();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
const host = document.createElement(tag);
|
|
238
|
+
const root = host.attachShadow({ mode: "open" });
|
|
239
|
+
const wrapper = document.createElement("div");
|
|
240
|
+
wrapper.className = "vf";
|
|
241
|
+
root.append(wrapper);
|
|
242
|
+
let view;
|
|
243
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
244
|
+
const call = (fns, args) => {
|
|
245
|
+
for (const fn of [...fns]) try {
|
|
246
|
+
fn(...args);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
reportError(error);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const emit = (event, ...args) => call(handlers.get(event) ?? [], args);
|
|
252
|
+
const start = () => void widget.open().catch(() => {});
|
|
253
|
+
let state = "idle";
|
|
254
|
+
let dialog;
|
|
255
|
+
let session = 0;
|
|
256
|
+
let returnFocus = null;
|
|
257
|
+
let pending;
|
|
258
|
+
let trigger;
|
|
259
|
+
let label;
|
|
260
|
+
let unlisten;
|
|
261
|
+
function listen(shortcut) {
|
|
262
|
+
const parts = shortcut.toLowerCase().split("+");
|
|
263
|
+
const key = parts.pop();
|
|
264
|
+
const names = [
|
|
265
|
+
"alt",
|
|
266
|
+
"shift",
|
|
267
|
+
"control",
|
|
268
|
+
"meta"
|
|
269
|
+
];
|
|
270
|
+
const mods = names.map((name) => parts.includes(name)).join();
|
|
271
|
+
const typing = !/control|meta/.test(shortcut.toLowerCase());
|
|
272
|
+
const on = (event) => {
|
|
273
|
+
if (event instanceof KeyboardEvent && state === "idle" && !event.repeat && !event.isComposing && !event.getModifierState("AltGraph") && parts.every((part) => names.includes(part)) && [
|
|
274
|
+
event.altKey,
|
|
275
|
+
event.shiftKey,
|
|
276
|
+
event.ctrlKey,
|
|
277
|
+
event.metaKey
|
|
278
|
+
].join() === mods && [event.code.replace(/^(Key|Digit)/, ""), event.key].some((name) => name.toLowerCase() === key) && !(typing && field(event.composedPath()[0]))) {
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
start();
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
addEventListener("keydown", on);
|
|
284
|
+
return () => removeEventListener("keydown", on);
|
|
285
|
+
}
|
|
286
|
+
let base = [];
|
|
287
|
+
let extra = [];
|
|
288
|
+
function sheets() {
|
|
289
|
+
const dialogCss = view?.css(Boolean(options.unstyled)).map(sheet) ?? [];
|
|
290
|
+
root.adoptedStyleSheets = [
|
|
291
|
+
...base,
|
|
292
|
+
...dialogCss,
|
|
293
|
+
...extra
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
let matched;
|
|
297
|
+
function apply() {
|
|
298
|
+
base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
|
|
299
|
+
extra = options.css ? [sheet(options.css)] : [];
|
|
300
|
+
sheets();
|
|
301
|
+
if (`${options.theme} ${options.css}` !== matched) {
|
|
302
|
+
matched = `${options.theme} ${options.css}`;
|
|
303
|
+
match(wrapper, options.theme);
|
|
304
|
+
dialog?.reink();
|
|
305
|
+
}
|
|
306
|
+
if (options.trigger === "none") {
|
|
307
|
+
trigger?.remove();
|
|
308
|
+
trigger = void 0;
|
|
309
|
+
} else {
|
|
310
|
+
if (!trigger) {
|
|
311
|
+
trigger = document.createElement("button");
|
|
312
|
+
trigger.type = "button";
|
|
313
|
+
trigger.className = "trigger";
|
|
314
|
+
trigger.setAttribute("part", "trigger");
|
|
315
|
+
label = document.createElement("span");
|
|
316
|
+
label.setAttribute("part", "trigger-text");
|
|
317
|
+
trigger.append(icon(), label);
|
|
318
|
+
trigger.addEventListener("click", start);
|
|
319
|
+
wrapper.prepend(trigger);
|
|
320
|
+
}
|
|
321
|
+
const [y, x] = (options.position ?? "bottom-right").split("-");
|
|
322
|
+
trigger.dataset.y = y;
|
|
323
|
+
trigger.dataset.x = x;
|
|
324
|
+
const { trigger: name, triggerText } = options.strings ?? {};
|
|
325
|
+
trigger.setAttribute("aria-label", name || triggerText || "Send feedback");
|
|
326
|
+
trigger.toggleAttribute("data-icon", !triggerText);
|
|
327
|
+
if (label) {
|
|
328
|
+
label.textContent = triggerText ?? "";
|
|
329
|
+
label.hidden = !triggerText;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
unlisten?.();
|
|
333
|
+
unlisten = options.shortcut ? listen(options.shortcut) : void 0;
|
|
334
|
+
}
|
|
335
|
+
apply();
|
|
336
|
+
(options.container ?? document.body ?? document.documentElement).append(host);
|
|
337
|
+
let seen = "";
|
|
338
|
+
const look = () => [document.documentElement, document.body].map((element) => {
|
|
339
|
+
const style = element && getComputedStyle(element);
|
|
340
|
+
return style ? `${style.backgroundColor} ${style.color} ${style.colorScheme}` : "";
|
|
341
|
+
}).join();
|
|
342
|
+
function restyle() {
|
|
343
|
+
wrapper.classList.add("still");
|
|
344
|
+
match(wrapper, options.theme);
|
|
345
|
+
if (trigger) getComputedStyle(trigger).backgroundColor;
|
|
346
|
+
wrapper.classList.remove("still");
|
|
347
|
+
dialog?.reink();
|
|
348
|
+
seen = look();
|
|
349
|
+
}
|
|
350
|
+
restyle();
|
|
351
|
+
let missed = false;
|
|
352
|
+
function follow(always = false) {
|
|
353
|
+
missed ||= always;
|
|
354
|
+
if (!host.isConnected) return;
|
|
355
|
+
const read = () => {
|
|
356
|
+
if (!host.isConnected || !(missed || look() !== seen)) return;
|
|
357
|
+
missed = false;
|
|
358
|
+
restyle();
|
|
359
|
+
};
|
|
360
|
+
const fading = document.getAnimations().filter((animation) => animation instanceof CSSTransition).filter((animation) => /color|background/.test(animation.transitionProperty));
|
|
361
|
+
if (fading.length) Promise.allSettled(fading.map((fade) => fade.finished)).then(read);
|
|
362
|
+
else read();
|
|
363
|
+
}
|
|
364
|
+
const system = matchMedia("(prefers-color-scheme: dark)");
|
|
365
|
+
const switched = () => follow(true);
|
|
366
|
+
system.addEventListener("change", switched);
|
|
367
|
+
let watched = null;
|
|
368
|
+
const observer = new MutationObserver(() => {
|
|
369
|
+
if (document.body !== watched) {
|
|
370
|
+
watched = document.body;
|
|
371
|
+
if (watched) observer.observe(watched, { attributes: true });
|
|
372
|
+
}
|
|
373
|
+
follow();
|
|
374
|
+
});
|
|
375
|
+
observer.observe(document.documentElement, {
|
|
376
|
+
attributes: true,
|
|
377
|
+
childList: true
|
|
378
|
+
});
|
|
379
|
+
watched = document.body;
|
|
380
|
+
if (watched) observer.observe(watched, { attributes: true });
|
|
381
|
+
const keeps = () => {
|
|
382
|
+
if (options.draft === "memory") return false;
|
|
383
|
+
if (!owners.get(tag)?.isConnected && host.isConnected) owners.set(tag, host);
|
|
384
|
+
return owners.get(tag) === host;
|
|
385
|
+
};
|
|
386
|
+
restore();
|
|
387
|
+
function restore() {
|
|
388
|
+
try {
|
|
389
|
+
if (!keeps() || !sessionStorage.getItem(`visual-feedback:${tag}`)) return false;
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
state = "opening";
|
|
394
|
+
pending = show(++session, true);
|
|
395
|
+
pending.catch(() => {});
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
function hide(on) {
|
|
399
|
+
wrapper.classList.toggle("capturing", on);
|
|
400
|
+
}
|
|
401
|
+
function requestDisplay() {
|
|
402
|
+
const media = navigator.mediaDevices;
|
|
403
|
+
if (options.capture?.mode !== "display" || typeof media?.getDisplayMedia !== "function") return;
|
|
404
|
+
hide(true);
|
|
405
|
+
const stream = media.getDisplayMedia({ video: true });
|
|
406
|
+
stream.catch(() => {});
|
|
407
|
+
return stream;
|
|
408
|
+
}
|
|
409
|
+
function stop(stream) {
|
|
410
|
+
stream?.then((media) => {
|
|
411
|
+
for (const track of media.getTracks()) track.stop();
|
|
412
|
+
}, () => {});
|
|
413
|
+
}
|
|
414
|
+
async function grab(module, mine, stream) {
|
|
415
|
+
hide(true);
|
|
416
|
+
const quiet = view?.announce(root, options.strings);
|
|
417
|
+
try {
|
|
418
|
+
return await module.capture(options.capture, stream);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
} finally {
|
|
422
|
+
quiet?.();
|
|
423
|
+
if (mine === session) hide(false);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function cancel() {
|
|
427
|
+
session++;
|
|
428
|
+
hide(false);
|
|
429
|
+
}
|
|
430
|
+
/** Returns whether the draft stays kept; when not, it has ended. */
|
|
431
|
+
function teardown(keep) {
|
|
432
|
+
const stays = dialog?.unmount(keep) ?? false;
|
|
433
|
+
dialog = void 0;
|
|
434
|
+
const target = returnFocus?.isConnected && returnFocus !== document.body ? returnFocus : trigger;
|
|
435
|
+
returnFocus = null;
|
|
436
|
+
target?.focus?.();
|
|
437
|
+
return stays;
|
|
438
|
+
}
|
|
439
|
+
async function show(mine, kept) {
|
|
440
|
+
returnFocus = deepActive();
|
|
441
|
+
const stream = kept ? void 0 : requestDisplay();
|
|
442
|
+
try {
|
|
443
|
+
dialogs ??= import("./dialog-W6m1knfu.mjs").catch((error) => {
|
|
444
|
+
dialogs = void 0;
|
|
445
|
+
throw error;
|
|
446
|
+
});
|
|
447
|
+
const [loaded, capture] = await Promise.all([dialogs, import("./capture-CufRosj3.mjs")]);
|
|
448
|
+
if (mine !== session) return stop(stream);
|
|
449
|
+
view = loaded;
|
|
450
|
+
sheets();
|
|
451
|
+
const image = kept ? null : await grab(capture, mine, stream);
|
|
452
|
+
if (mine !== session) return;
|
|
453
|
+
if (!host.isConnected) {
|
|
454
|
+
state = "idle";
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
match(wrapper, options.theme);
|
|
458
|
+
dialog = view.mount(wrapper, {
|
|
459
|
+
close: () => widget.close(),
|
|
460
|
+
retry: () => void retry(capture).catch((error) => {
|
|
461
|
+
widget.close();
|
|
462
|
+
emit("error", error);
|
|
463
|
+
}),
|
|
464
|
+
emit,
|
|
465
|
+
options: () => options,
|
|
466
|
+
keeps,
|
|
467
|
+
restyle: () => match(wrapper, options.theme)
|
|
468
|
+
}, options);
|
|
469
|
+
const mounted = dialog;
|
|
470
|
+
if (!kept) mounted.capture(image);
|
|
471
|
+
else {
|
|
472
|
+
const back = await mounted.revive();
|
|
473
|
+
if (mine !== session) {
|
|
474
|
+
mounted.unmount(true);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (!back) {
|
|
478
|
+
state = "idle";
|
|
479
|
+
mounted.unmount();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
stop(stream);
|
|
485
|
+
if (mine === session) {
|
|
486
|
+
state = "idle";
|
|
487
|
+
hide(false);
|
|
488
|
+
teardown();
|
|
489
|
+
}
|
|
490
|
+
emit("error", error);
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
state = "open";
|
|
494
|
+
emit("open");
|
|
495
|
+
}
|
|
496
|
+
async function retry(capture) {
|
|
497
|
+
if (wrapper.classList.contains("capturing")) return;
|
|
498
|
+
const mine = session;
|
|
499
|
+
dialog?.lock(false);
|
|
500
|
+
const image = await grab(capture, mine, requestDisplay());
|
|
501
|
+
if (mine !== session) return;
|
|
502
|
+
dialog?.capture(image);
|
|
503
|
+
}
|
|
504
|
+
const widget = {
|
|
505
|
+
open() {
|
|
506
|
+
if (state === "opening" && pending) {
|
|
507
|
+
const mine = session;
|
|
508
|
+
return pending.then(() => mine === session ? widget.open() : void 0);
|
|
509
|
+
}
|
|
510
|
+
if (state !== "idle") return dialog?.reopen(), Promise.resolve();
|
|
511
|
+
if (restore()) return widget.open();
|
|
512
|
+
state = "opening";
|
|
513
|
+
pending = show(++session);
|
|
514
|
+
return pending;
|
|
515
|
+
},
|
|
516
|
+
close() {
|
|
517
|
+
end();
|
|
518
|
+
},
|
|
519
|
+
destroy() {
|
|
520
|
+
if (state === "destroyed") return;
|
|
521
|
+
const wasOpen = state === "open";
|
|
522
|
+
state = "destroyed";
|
|
523
|
+
cancel();
|
|
524
|
+
unlisten?.();
|
|
525
|
+
observer.disconnect();
|
|
526
|
+
system.removeEventListener("change", switched);
|
|
527
|
+
const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
|
|
528
|
+
handlers.clear();
|
|
529
|
+
host.remove();
|
|
530
|
+
call(closers, []);
|
|
531
|
+
},
|
|
532
|
+
update(next) {
|
|
533
|
+
if (state === "destroyed") return;
|
|
534
|
+
options = {
|
|
535
|
+
...options,
|
|
536
|
+
...next
|
|
537
|
+
};
|
|
538
|
+
apply();
|
|
539
|
+
},
|
|
540
|
+
on(event, fn) {
|
|
541
|
+
if (state === "destroyed") return () => {};
|
|
542
|
+
const set = handlers.get(event) ?? /* @__PURE__ */ new Set();
|
|
543
|
+
handlers.set(event, set);
|
|
544
|
+
const handler = fn;
|
|
545
|
+
set.add(handler);
|
|
546
|
+
return () => set.delete(handler);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
function end(keep) {
|
|
550
|
+
if (state !== "open" && state !== "opening") return;
|
|
551
|
+
const wasOpen = state === "open";
|
|
552
|
+
state = "idle";
|
|
553
|
+
cancel();
|
|
554
|
+
const stays = teardown(keep);
|
|
555
|
+
if (wasOpen && !stays) emit("close");
|
|
556
|
+
}
|
|
557
|
+
enders.set(host, () => end());
|
|
558
|
+
host[CONNECTED] = () => follow();
|
|
559
|
+
host[DISCONNECTED] = () => queueMicrotask(() => {
|
|
560
|
+
if (!host.isConnected) end(true);
|
|
561
|
+
});
|
|
562
|
+
return widget;
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
export { discardDraft as n, createFeedback as t };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { n as Mark, t as FeedbackReport } from "./report-
|
|
2
|
+
import { n as Mark, t as FeedbackReport } from "./report-CaJegYyg.mjs";
|
|
3
3
|
//#region src/capture.d.ts
|
|
4
4
|
type CaptureOptions = {
|
|
5
5
|
mode?: "dom" | "display";
|
|
@@ -8,6 +8,9 @@ type CaptureOptions = {
|
|
|
8
8
|
quality?: number;
|
|
9
9
|
};
|
|
10
10
|
//#endregion
|
|
11
|
+
//#region src/annotate.d.ts
|
|
12
|
+
type Tool = Mark["tool"] | "select";
|
|
13
|
+
//#endregion
|
|
11
14
|
//#region src/widget.d.ts
|
|
12
15
|
/** What each event passes to its handlers. */
|
|
13
16
|
type FeedbackEvents = {
|
|
@@ -16,16 +19,17 @@ type FeedbackEvents = {
|
|
|
16
19
|
submit: [report: FeedbackReport];
|
|
17
20
|
sent: [report: FeedbackReport];
|
|
18
21
|
error: [error: unknown];
|
|
22
|
+
saved: [];
|
|
19
23
|
};
|
|
20
24
|
type FeedbackEvent = keyof FeedbackEvents;
|
|
21
25
|
/** The texts the widget shows, by SPEC §12 key. */
|
|
22
|
-
type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "undo" | "clear" | "cancel" | "send" | "sending" | "sent" | "error" | "retry", string>;
|
|
26
|
+
type FeedbackStrings = Record<"trigger" | "title" | "capturing" | "captureError" | "sendWithout" | "noteLabel" | "notePlaceholder" | "noteRequired" | "categoryLabel" | "pen" | "box" | "arrow" | "text" | "select" | "remove" | "undo" | "clear" | "cancel" | "send" | "sending" | "sent" | "error" | "retry" | "triggerText" | "escHint" | "minimise" | "screenshot" | "addScreenshot" | "removeScreenshot" | "draft" | "captureView" | "reopen" | "discard" | "discardConfirm" | "keepEditing" | "moreSend" | "savePdf" | "pdfError", string>;
|
|
23
27
|
type FeedbackOptions = {
|
|
24
28
|
onSubmit?: (report: FeedbackReport) => Promise<void>;
|
|
25
29
|
endpoint?: string;
|
|
26
30
|
headers?: Record<string, string> | (() => Record<string, string>);
|
|
27
31
|
capture?: CaptureOptions;
|
|
28
|
-
tools?:
|
|
32
|
+
tools?: Tool[];
|
|
29
33
|
categories?: {
|
|
30
34
|
id: string;
|
|
31
35
|
label: string;
|
|
@@ -42,6 +46,7 @@ type FeedbackOptions = {
|
|
|
42
46
|
unstyled?: boolean;
|
|
43
47
|
container?: HTMLElement;
|
|
44
48
|
tagName?: string;
|
|
49
|
+
draft?: "session" | "memory";
|
|
45
50
|
};
|
|
46
51
|
type FeedbackWidget = {
|
|
47
52
|
open(): Promise<void>;
|
|
@@ -51,6 +56,11 @@ type FeedbackWidget = {
|
|
|
51
56
|
update(options: { [K in keyof FeedbackOptions]?: FeedbackOptions[K] | undefined; }): void;
|
|
52
57
|
on<E extends FeedbackEvent>(event: E, fn: (...args: FeedbackEvents[E]) => void): () => void;
|
|
53
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Deletes the draft this tab keeps for a tag, as at a logout, so no later widget of the tag brings
|
|
61
|
+
* it back; a widget holding it, open or folded, ends it.
|
|
62
|
+
*/
|
|
63
|
+
declare function discardDraft(tagName?: string): Promise<void>;
|
|
54
64
|
declare function createFeedback(options?: FeedbackOptions): FeedbackWidget;
|
|
55
65
|
//#endregion
|
|
56
|
-
export { FeedbackWidget as a, FeedbackStrings as i, FeedbackEvents as n, createFeedback as o, FeedbackOptions as r,
|
|
66
|
+
export { FeedbackWidget as a, CaptureOptions as c, FeedbackStrings as i, FeedbackEvents as n, createFeedback as o, FeedbackOptions as r, discardDraft as s, FeedbackEvent as t };
|