feedbackkit-web 1.0.37

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.
@@ -0,0 +1,2075 @@
1
+ async function captureViewport(options = {}) {
2
+ const pixelRatio = Math.min(window.devicePixelRatio || 1, options.maxPixelRatio ?? 2);
3
+ if (options.mode === "display") {
4
+ try {
5
+ const display = await captureDisplay(pixelRatio);
6
+ if (display) return display;
7
+ } catch {
8
+ }
9
+ }
10
+ return captureDom(pixelRatio, options.exclude);
11
+ }
12
+ const PIN_ATTR = "data-feedbackkit-pin";
13
+ async function captureDom(pixelRatio, exclude) {
14
+ const width = window.innerWidth;
15
+ const height = window.innerHeight;
16
+ const pins = pinFixedAndSticky(exclude);
17
+ const root = document.documentElement;
18
+ const bodyBackground = getComputedStyle(document.body).backgroundColor;
19
+ const rootBackground = getComputedStyle(root).backgroundColor;
20
+ const background = isTransparent(rootBackground) ? isTransparent(bodyBackground) ? "#ffffff" : bodyBackground : rootBackground;
21
+ try {
22
+ const { domToCanvas } = await import("modern-screenshot");
23
+ const canvas = await domToCanvas(root, {
24
+ width,
25
+ height,
26
+ scale: pixelRatio,
27
+ backgroundColor: background,
28
+ filter: exclude ? (node) => !exclude(node) : null,
29
+ features: { restoreScrollPosition: true },
30
+ timeout: 8e3,
31
+ onCloneEachNode: (cloned) => {
32
+ if (!(cloned instanceof HTMLElement)) return;
33
+ if (cloned.tagName === "BODY" && !cloned.style.transform) cloned.style.transform = "translate(0px, 0px)";
34
+ const id = cloned.getAttribute(PIN_ATTR);
35
+ if (id === null) return;
36
+ cloned.removeAttribute(PIN_ATTR);
37
+ const pin = pins.info.get(id);
38
+ if (!pin) return;
39
+ const s = cloned.style;
40
+ if (pin.kind === "sticky") {
41
+ s.setProperty("position", "relative", "important");
42
+ s.setProperty("inset", "auto", "important");
43
+ s.setProperty("top", `${pin.top}px`, "important");
44
+ s.setProperty("left", `${pin.left}px`, "important");
45
+ } else {
46
+ s.setProperty("position", "fixed", "important");
47
+ s.setProperty("inset", "auto", "important");
48
+ s.setProperty("top", `${pin.top}px`, "important");
49
+ s.setProperty("left", `${pin.left}px`, "important");
50
+ s.setProperty("width", `${pin.width}px`, "important");
51
+ s.setProperty("height", `${pin.height}px`, "important");
52
+ s.setProperty("margin", "0", "important");
53
+ s.setProperty("box-sizing", "border-box", "important");
54
+ s.setProperty("transform", "none", "important");
55
+ s.setProperty("translate", "none", "important");
56
+ }
57
+ }
58
+ });
59
+ return { canvas, width, height, pixelRatio };
60
+ } finally {
61
+ pins.cleanup();
62
+ }
63
+ }
64
+ function pinFixedAndSticky(exclude) {
65
+ const info = /* @__PURE__ */ new Map();
66
+ const tagged = [];
67
+ const body = document.body;
68
+ if (!body) return { info, cleanup: () => {
69
+ } };
70
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT, {
71
+ acceptNode: (node) => exclude?.(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
72
+ });
73
+ let counter = 0;
74
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
75
+ const el2 = node;
76
+ const style = getComputedStyle(el2);
77
+ if (style.position !== "fixed" && style.position !== "sticky") continue;
78
+ if (style.display === "none") continue;
79
+ const rect = el2.getBoundingClientRect();
80
+ const id = String(counter++);
81
+ if (style.position === "sticky") {
82
+ const previous = el2.style.getPropertyValue("position");
83
+ const previousPriority = el2.style.getPropertyPriority("position");
84
+ el2.style.setProperty("position", "static", "important");
85
+ const natural = el2.getBoundingClientRect();
86
+ el2.style.setProperty("position", previous, previousPriority);
87
+ if (!previous) el2.style.removeProperty("position");
88
+ const dy = rect.top - natural.top;
89
+ const dx = rect.left - natural.left;
90
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
91
+ info.set(id, { kind: "sticky", top: dy, left: dx, width: rect.width, height: rect.height });
92
+ } else {
93
+ const block = containingBlockInClone(el2);
94
+ const blockRect = block.getBoundingClientRect();
95
+ info.set(id, {
96
+ kind: "fixed",
97
+ top: rect.top - (blockRect.top + block.clientTop),
98
+ left: rect.left - (blockRect.left + block.clientLeft),
99
+ width: rect.width,
100
+ height: rect.height
101
+ });
102
+ }
103
+ el2.setAttribute(PIN_ATTR, id);
104
+ tagged.push(el2);
105
+ }
106
+ return {
107
+ info,
108
+ cleanup: () => {
109
+ for (const el2 of tagged) el2.removeAttribute(PIN_ATTR);
110
+ }
111
+ };
112
+ }
113
+ function containingBlockInClone(el2) {
114
+ for (let node = el2.parentElement; node && node !== document.body; node = node.parentElement) {
115
+ const s = getComputedStyle(node);
116
+ if (s.transform !== "none" || s.filter !== "none" || s.perspective !== "none" || s.backdropFilter !== void 0 && s.backdropFilter !== "none" || /paint|layout|strict|content/.test(s.contain) || /transform|filter|perspective/.test(s.willChange)) {
117
+ return node;
118
+ }
119
+ const parent = node.parentElement;
120
+ if (parent && (parent.scrollTop !== 0 || parent.scrollLeft !== 0)) return node;
121
+ }
122
+ return document.body;
123
+ }
124
+ function isTransparent(color) {
125
+ return !color || color === "transparent" || /rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/.test(color);
126
+ }
127
+ async function captureDisplay(pixelRatio) {
128
+ if (!navigator.mediaDevices?.getDisplayMedia) return null;
129
+ const options = {
130
+ video: { displaySurface: "browser" },
131
+ audio: false,
132
+ preferCurrentTab: true,
133
+ selfBrowserSurface: "include"
134
+ };
135
+ const stream = await navigator.mediaDevices.getDisplayMedia(options);
136
+ try {
137
+ const video = document.createElement("video");
138
+ video.muted = true;
139
+ video.playsInline = true;
140
+ video.srcObject = stream;
141
+ await video.play();
142
+ await new Promise((r) => setTimeout(r, 250));
143
+ const width = window.innerWidth;
144
+ const height = window.innerHeight;
145
+ const canvas = document.createElement("canvas");
146
+ canvas.width = Math.round(width * pixelRatio);
147
+ canvas.height = Math.round(height * pixelRatio);
148
+ const ctx = canvas.getContext("2d");
149
+ if (!ctx) return null;
150
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
151
+ return { canvas, width, height, pixelRatio };
152
+ } finally {
153
+ for (const track of stream.getTracks()) track.stop();
154
+ }
155
+ }
156
+ function canvasToPngBlob(canvas) {
157
+ return new Promise((resolve, reject) => {
158
+ canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Failed to encode screenshot")), "image/png");
159
+ });
160
+ }
161
+ function parseUserAgent(ua) {
162
+ let osName = "Unknown";
163
+ let osVersion = "";
164
+ let m;
165
+ if (m = ua.match(/(?:iPhone|iPad|iPod).*?OS (\d+[_.\d]*)/)) {
166
+ osName = /iPad/.test(ua) ? "iPadOS" : "iOS";
167
+ osVersion = m[1].replace(/_/g, ".");
168
+ } else if (m = ua.match(/Android (\d+(?:\.\d+)*)/)) {
169
+ osName = "Android";
170
+ osVersion = m[1];
171
+ } else if (m = ua.match(/Windows NT (\d+\.\d+)/)) {
172
+ osName = "Windows";
173
+ osVersion = { "10.0": "10", "6.3": "8.1", "6.2": "8", "6.1": "7" }[m[1]] ?? m[1];
174
+ } else if (m = ua.match(/Mac OS X (\d+[_.\d]*)/)) {
175
+ osName = "macOS";
176
+ osVersion = m[1].replace(/_/g, ".");
177
+ } else if (/CrOS/.test(ua)) {
178
+ osName = "ChromeOS";
179
+ } else if (/Linux/.test(ua)) {
180
+ osName = "Linux";
181
+ }
182
+ let browserName = "Unknown";
183
+ let browserVersion = "";
184
+ const browsers = [
185
+ ["Edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
186
+ ["Opera", /(?:OPR|Opera)\/([\d.]+)/],
187
+ ["Samsung Internet", /SamsungBrowser\/([\d.]+)/],
188
+ ["Firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
189
+ ["Chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
190
+ ["Safari", /Version\/([\d.]+).*Safari/]
191
+ ];
192
+ for (const [name, re] of browsers) {
193
+ const match = ua.match(re);
194
+ if (match) {
195
+ browserName = name;
196
+ browserVersion = match[1];
197
+ break;
198
+ }
199
+ }
200
+ return { osName, osVersion, browserName, browserVersion };
201
+ }
202
+ const SENSITIVE_PARAM = /token|secret|password|passwd|pwd|auth|session|key|code|signature|sig|jwt|otp|email/i;
203
+ function redactUrl(href) {
204
+ try {
205
+ const url = new URL(href);
206
+ for (const name of [...url.searchParams.keys()]) {
207
+ if (SENSITIVE_PARAM.test(name)) url.searchParams.set(name, "[redacted]");
208
+ }
209
+ if (url.hash && SENSITIVE_PARAM.test(url.hash)) url.hash = "#[redacted]";
210
+ return url.toString();
211
+ } catch {
212
+ return href;
213
+ }
214
+ }
215
+ async function clientHints() {
216
+ const uaData = navigator.userAgentData;
217
+ if (!uaData?.getHighEntropyValues) return null;
218
+ try {
219
+ return await Promise.race([
220
+ uaData.getHighEntropyValues(["platform", "platformVersion", "fullVersionList"]),
221
+ new Promise((resolve) => setTimeout(() => resolve(null), 300))
222
+ ]);
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ function osFromHints(hints, fallback) {
228
+ const platform = hints.platform;
229
+ const version = hints.platformVersion ?? "";
230
+ if (!platform) return fallback;
231
+ if (platform === "macOS") return { osName: "macOS", osVersion: trimVersion(version) };
232
+ if (platform === "Windows") {
233
+ const major = parseInt(version, 10);
234
+ return { osName: "Windows", osVersion: major >= 13 ? "11" : major > 0 ? "10" : fallback.osVersion };
235
+ }
236
+ if (platform === "Android") return { osName: "Android", osVersion: trimVersion(version) || fallback.osVersion };
237
+ if (platform === "Chrome OS" || platform === "ChromeOS") return { osName: "ChromeOS", osVersion: trimVersion(version) };
238
+ return { osName: platform, osVersion: trimVersion(version) || fallback.osVersion };
239
+ }
240
+ function trimVersion(v) {
241
+ return v.replace(/(\.0)+$/, "");
242
+ }
243
+ function browserFromHints(hints) {
244
+ const list = hints.fullVersionList ?? [];
245
+ const known = ["Microsoft Edge", "Opera", "Google Chrome", "Chromium"];
246
+ for (const brand of known) {
247
+ const hit = list.find((b) => b.brand === brand);
248
+ if (hit) {
249
+ const name = brand === "Microsoft Edge" ? "Edge" : brand === "Google Chrome" ? "Chrome" : brand;
250
+ return { name, version: hit.version };
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+ async function collectEnvironment(inputs = {}) {
256
+ const ua = navigator.userAgent;
257
+ const parsed = parseUserAgent(ua);
258
+ const hints = await clientHints();
259
+ const os = hints ? osFromHints(hints, parsed) : parsed;
260
+ const browser = hints && browserFromHints(hints) || { name: parsed.browserName, version: parsed.browserVersion };
261
+ const major = browser.version.split(".")[0];
262
+ return {
263
+ osName: os.osName,
264
+ osVersion: os.osVersion,
265
+ deviceModel: major ? `${browser.name} ${major}` : browser.name,
266
+ appVersion: inputs.appVersion ?? "",
267
+ appBuild: inputs.appBuild ?? "",
268
+ bundleIdentifier: location.host,
269
+ screenName: inputs.screenName || location.pathname || null,
270
+ locale: navigator.language || "en",
271
+ screenWidthPoints: window.innerWidth,
272
+ screenHeightPoints: window.innerHeight,
273
+ screenScale: window.devicePixelRatio || 1,
274
+ platform: "web",
275
+ pageUrl: redactUrl(location.href),
276
+ userAgent: ua,
277
+ browserName: browser.name,
278
+ browserVersion: browser.version
279
+ };
280
+ }
281
+ const MAX_MESSAGE_LENGTH = 1e3;
282
+ function redactMessage(message) {
283
+ return message.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [redacted]").replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[redacted-jwt]").replace(/https?:\/\/[^\s"'<>)]+/g, (url) => redactUrl(url)).replace(/("?(?:password|passwd|secret|token|api[_-]?key|access[_-]?token|refresh[_-]?token)"?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,}&]+)/gi, "$1[redacted]");
284
+ }
285
+ function stringifyArg(arg) {
286
+ if (typeof arg === "string") return arg;
287
+ if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
288
+ if (arg === void 0) return "undefined";
289
+ if (typeof arg === "function") return `[function ${arg.name || "anonymous"}]`;
290
+ if (typeof Element !== "undefined" && arg instanceof Element) return `<${arg.tagName.toLowerCase()}>`;
291
+ try {
292
+ const seen = /* @__PURE__ */ new WeakSet();
293
+ return JSON.stringify(arg, (_k, v) => {
294
+ if (typeof v === "object" && v !== null) {
295
+ if (seen.has(v)) return "[circular]";
296
+ seen.add(v);
297
+ }
298
+ return typeof v === "bigint" ? v.toString() : v;
299
+ });
300
+ } catch {
301
+ return String(arg);
302
+ }
303
+ }
304
+ class LogBuffer {
305
+ constructor(maxEntries = 50) {
306
+ this.maxEntries = maxEntries;
307
+ this.entries = [];
308
+ }
309
+ push(level, message) {
310
+ let text = redactMessage(message);
311
+ if (text.length > MAX_MESSAGE_LENGTH) text = `${text.slice(0, MAX_MESSAGE_LENGTH)}… [truncated]`;
312
+ this.entries.push({ level, message: text, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
313
+ if (this.entries.length > this.maxEntries) this.entries.splice(0, this.entries.length - this.maxEntries);
314
+ }
315
+ snapshot() {
316
+ return this.entries.slice();
317
+ }
318
+ clear() {
319
+ this.entries = [];
320
+ }
321
+ }
322
+ function installLogCapture(options = {}) {
323
+ const buffer = new LogBuffer(options.maxEntries ?? 50);
324
+ const restores = [];
325
+ const methods = options.includeVerbose ? ["log", "info", "warn", "error", "debug"] : ["warn", "error"];
326
+ for (const method of methods) {
327
+ const original = console[method];
328
+ console[method] = function(...args) {
329
+ try {
330
+ buffer.push(method, args.map(stringifyArg).join(" "));
331
+ } catch {
332
+ }
333
+ return original.apply(this, args);
334
+ };
335
+ restores.push(() => {
336
+ console[method] = original;
337
+ });
338
+ }
339
+ const onError = (event) => {
340
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
341
+ buffer.push("error", `Uncaught ${event.error ? stringifyArg(event.error) : event.message}${where}`);
342
+ };
343
+ const onRejection = (event) => {
344
+ buffer.push("error", `Unhandled promise rejection: ${stringifyArg(event.reason)}`);
345
+ };
346
+ window.addEventListener("error", onError);
347
+ window.addEventListener("unhandledrejection", onRejection);
348
+ restores.push(() => {
349
+ window.removeEventListener("error", onError);
350
+ window.removeEventListener("unhandledrejection", onRejection);
351
+ });
352
+ if (options.network !== false) {
353
+ restores.push(patchFetch(buffer), patchXHR(buffer));
354
+ }
355
+ return {
356
+ buffer,
357
+ uninstall: () => {
358
+ for (const restore of restores.reverse()) restore();
359
+ }
360
+ };
361
+ }
362
+ let ignoredUrlPrefix = null;
363
+ function setIgnoredUrlPrefix(prefix) {
364
+ ignoredUrlPrefix = prefix;
365
+ }
366
+ function isIgnored(url) {
367
+ return !!ignoredUrlPrefix && url.startsWith(ignoredUrlPrefix);
368
+ }
369
+ function patchFetch(buffer) {
370
+ if (typeof window.fetch !== "function") return () => {
371
+ };
372
+ const original = window.fetch;
373
+ window.fetch = async function(input, init) {
374
+ const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
375
+ const url = input instanceof Request ? input.url : String(input);
376
+ try {
377
+ const response = await original.call(window, input, init);
378
+ if (response.status >= 400 && !isIgnored(url)) {
379
+ buffer.push("network", `${method} ${url} → ${response.status} ${response.statusText}`.trim());
380
+ }
381
+ return response;
382
+ } catch (error) {
383
+ if (!isIgnored(url) && !(error instanceof DOMException && error.name === "AbortError")) {
384
+ buffer.push("network", `${method} ${url} → failed: ${stringifyArg(error)}`);
385
+ }
386
+ throw error;
387
+ }
388
+ };
389
+ return () => {
390
+ window.fetch = original;
391
+ };
392
+ }
393
+ function patchXHR(buffer) {
394
+ if (typeof XMLHttpRequest === "undefined") return () => {
395
+ };
396
+ const proto = XMLHttpRequest.prototype;
397
+ const originalOpen = proto.open;
398
+ const originalSend = proto.send;
399
+ const meta = /* @__PURE__ */ new WeakMap();
400
+ proto.open = function(method, url, ...rest) {
401
+ meta.set(this, { method: String(method).toUpperCase(), url: String(url) });
402
+ return originalOpen.call(this, method, url, ...rest);
403
+ };
404
+ proto.send = function(body) {
405
+ this.addEventListener("loadend", () => {
406
+ const info = meta.get(this);
407
+ if (!info || isIgnored(info.url)) return;
408
+ if (this.status === 0) buffer.push("network", `${info.method} ${info.url} → failed (network error)`);
409
+ else if (this.status >= 400) buffer.push("network", `${info.method} ${info.url} → ${this.status} ${this.statusText}`.trim());
410
+ });
411
+ return originalSend.call(this, body);
412
+ };
413
+ return () => {
414
+ proto.open = originalOpen;
415
+ proto.send = originalSend;
416
+ };
417
+ }
418
+ const DEFAULT_ENDPOINT = "https://gpucoladcyvijefdjudf.supabase.co/functions/v1/ingest-feedback";
419
+ class FeedbackSubmissionError extends Error {
420
+ constructor(message, kind, status) {
421
+ super(message);
422
+ this.kind = kind;
423
+ this.status = status;
424
+ this.name = "FeedbackSubmissionError";
425
+ }
426
+ }
427
+ async function blobToBase64(blob) {
428
+ const bytes = new Uint8Array(await blob.arrayBuffer());
429
+ let binary = "";
430
+ const chunk = 32768;
431
+ for (let i = 0; i < bytes.length; i += chunk) {
432
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
433
+ }
434
+ return btoa(binary);
435
+ }
436
+ async function encodePayload(report, projectKey) {
437
+ const payload = {
438
+ project_key: projectKey,
439
+ id: report.id,
440
+ created_at: report.createdAt.toISOString(),
441
+ text: report.text,
442
+ annotations: report.annotations,
443
+ environment: report.environment
444
+ };
445
+ if (report.screenshotRaw && report.screenshotAnnotated) {
446
+ const [raw, annotated] = await Promise.all([
447
+ blobToBase64(report.screenshotRaw),
448
+ blobToBase64(report.screenshotAnnotated)
449
+ ]);
450
+ payload.screenshot_raw_png_base64 = raw;
451
+ payload.screenshot_annotated_png_base64 = annotated;
452
+ }
453
+ if (report.attachment) {
454
+ payload.attachment_filename = report.attachment.filename;
455
+ payload.attachment_mime_type = report.attachment.mimeType;
456
+ payload.attachment_data_base64 = await blobToBase64(report.attachment.data);
457
+ }
458
+ if (report.products.length > 0) {
459
+ payload.product_keys = report.products.map((p) => p.key);
460
+ payload.products = report.products.map((p) => ({
461
+ key: p.key,
462
+ name: p.name,
463
+ description: p.description ?? "",
464
+ is_default: p.isDefault ?? false
465
+ }));
466
+ }
467
+ if (report.logs.length > 0) payload.logs = report.logs;
468
+ return payload;
469
+ }
470
+ async function submitReport(report, configuration2) {
471
+ if (!configuration2.projectKey) {
472
+ throw new FeedbackSubmissionError("FeedbackKit is not configured with a project key.", "not_configured");
473
+ }
474
+ const body = JSON.stringify(await encodePayload(report, configuration2.projectKey));
475
+ let response;
476
+ try {
477
+ response = await fetch(configuration2.endpoint ?? DEFAULT_ENDPOINT, {
478
+ method: "POST",
479
+ headers: { "Content-Type": "application/json" },
480
+ body
481
+ });
482
+ } catch (error) {
483
+ throw new FeedbackSubmissionError(
484
+ `Network error: ${error instanceof Error ? error.message : String(error)}`,
485
+ "network"
486
+ );
487
+ }
488
+ if (!response.ok) {
489
+ let code = "";
490
+ try {
491
+ const json = await response.json();
492
+ code = json.error ?? "";
493
+ } catch {
494
+ }
495
+ const detail = code ? ` (${code})` : "";
496
+ const message = code === "origin_not_allowed" ? `This site (${location.origin}) isn't in the project's allowed origins.` : response.status === 401 || response.status === 403 ? `Server rejected the project key or origin (HTTP ${response.status})${detail}.` : response.status === 429 ? "Too many reports right now — please try again in a minute." : `Server returned HTTP ${response.status}${detail}.`;
497
+ throw new FeedbackSubmissionError(message, "server", response.status);
498
+ }
499
+ }
500
+ async function fetchProducts(configuration2) {
501
+ const url = new URL(configuration2.endpoint ?? DEFAULT_ENDPOINT);
502
+ url.searchParams.set("project_key", configuration2.projectKey);
503
+ const response = await fetch(url.toString());
504
+ if (!response.ok) throw new FeedbackSubmissionError(`HTTP ${response.status}`, "server", response.status);
505
+ const json = await response.json();
506
+ return (json.products ?? []).map((p) => ({
507
+ key: p.key,
508
+ name: p.name,
509
+ description: p.description ?? "",
510
+ isDefault: p.is_default ?? false
511
+ }));
512
+ }
513
+ const STROKE_WIDTH = 4;
514
+ const HIT_TEST_TOLERANCE = 16;
515
+ const HEAD_LENGTH = 18;
516
+ const HEAD_ANGLE = Math.PI / 7;
517
+ const BASE_FONT_SIZE = 16;
518
+ const TEXT_PADDING = 8;
519
+ const LINE_HEIGHT_RATIO = 1.19;
520
+ const FALLBACK_COLOR = "#FF0000";
521
+ function textFont(scale = 1) {
522
+ return `bold ${BASE_FONT_SIZE * scale}px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
523
+ }
524
+ function readPoint(p) {
525
+ if (Array.isArray(p)) return { x: Number(p[0]) || 0, y: Number(p[1]) || 0 };
526
+ if (p && typeof p === "object") {
527
+ const o = p;
528
+ return { x: Number(o.x) || 0, y: Number(o.y) || 0 };
529
+ }
530
+ return { x: 0, y: 0 };
531
+ }
532
+ function denormalize(annotation, size) {
533
+ return (annotation.points ?? []).map((p) => {
534
+ const { x, y } = readPoint(p);
535
+ return { x: x * size.width, y: y * size.height };
536
+ });
537
+ }
538
+ function normalizeColor(hex) {
539
+ if (!hex) return FALLBACK_COLOR;
540
+ const cleaned = hex.trim().replace(/^#/, "");
541
+ if (/^[0-9a-fA-F]{6}$/.test(cleaned) || /^[0-9a-fA-F]{8}$/.test(cleaned)) return `#${cleaned}`;
542
+ return FALLBACK_COLOR;
543
+ }
544
+ function drawAnnotations(ctx, annotations, size) {
545
+ for (const annotation of annotations) drawAnnotation(ctx, annotation, size);
546
+ }
547
+ function drawAnnotation(ctx, annotation, size) {
548
+ const color = normalizeColor(annotation.colorHex);
549
+ const points = denormalize(annotation, size);
550
+ const scale = annotation.scale ?? 1;
551
+ const rotation = annotation.rotation ?? 0;
552
+ switch (annotation.kind) {
553
+ case "freehand":
554
+ drawFreehand(ctx, points, color);
555
+ break;
556
+ case "rectangle":
557
+ if (points.length === 2) drawRectangle(ctx, points[0], points[1], color, scale, rotation);
558
+ break;
559
+ case "arrow":
560
+ if (points.length === 2) drawArrow(ctx, points[0], points[1], color, scale, rotation);
561
+ break;
562
+ case "text":
563
+ if (points.length >= 1 && annotation.label) drawText(ctx, annotation.label, points[0], color, scale);
564
+ break;
565
+ }
566
+ }
567
+ function drawFreehand(ctx, points, color) {
568
+ if (points.length === 0) return;
569
+ ctx.save();
570
+ ctx.lineWidth = STROKE_WIDTH;
571
+ ctx.lineJoin = "round";
572
+ ctx.lineCap = "round";
573
+ ctx.strokeStyle = color;
574
+ ctx.beginPath();
575
+ ctx.moveTo(points[0].x, points[0].y);
576
+ if (points.length === 1) ctx.lineTo(points[0].x + 0.01, points[0].y);
577
+ for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
578
+ ctx.stroke();
579
+ ctx.restore();
580
+ }
581
+ function drawRectangle(ctx, start, end, color, scale, rotation) {
582
+ const center = midpoint(start, end);
583
+ const corners = rectangleCorners(
584
+ center,
585
+ Math.abs(end.x - start.x) / 2 * scale,
586
+ Math.abs(end.y - start.y) / 2 * scale,
587
+ rotation
588
+ );
589
+ ctx.save();
590
+ ctx.lineWidth = STROKE_WIDTH;
591
+ ctx.lineJoin = "miter";
592
+ ctx.strokeStyle = color;
593
+ ctx.beginPath();
594
+ ctx.moveTo(corners[0].x, corners[0].y);
595
+ for (const c of corners.slice(1)) ctx.lineTo(c.x, c.y);
596
+ ctx.closePath();
597
+ ctx.stroke();
598
+ ctx.restore();
599
+ }
600
+ function drawArrow(ctx, rawStart, rawEnd, color, scale, rotation) {
601
+ const { start, end, left, right } = arrowGeometry(rawStart, rawEnd, scale, rotation);
602
+ ctx.save();
603
+ ctx.lineWidth = STROKE_WIDTH;
604
+ ctx.lineJoin = "round";
605
+ ctx.lineCap = "round";
606
+ ctx.strokeStyle = color;
607
+ ctx.beginPath();
608
+ ctx.moveTo(start.x, start.y);
609
+ ctx.lineTo(end.x, end.y);
610
+ ctx.lineTo(left.x, left.y);
611
+ ctx.moveTo(end.x, end.y);
612
+ ctx.lineTo(right.x, right.y);
613
+ ctx.stroke();
614
+ ctx.restore();
615
+ }
616
+ function arrowGeometry(rawStart, rawEnd, scale = 1, rotation = 0) {
617
+ const center = midpoint(rawStart, rawEnd);
618
+ const start = rotate(scaled(rawStart, scale, center), rotation, center);
619
+ const end = rotate(scaled(rawEnd, scale, center), rotation, center);
620
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
621
+ const left = {
622
+ x: end.x - HEAD_LENGTH * Math.cos(angle - HEAD_ANGLE),
623
+ y: end.y - HEAD_LENGTH * Math.sin(angle - HEAD_ANGLE)
624
+ };
625
+ const right = {
626
+ x: end.x - HEAD_LENGTH * Math.cos(angle + HEAD_ANGLE),
627
+ y: end.y - HEAD_LENGTH * Math.sin(angle + HEAD_ANGLE)
628
+ };
629
+ return { start, end, left, right };
630
+ }
631
+ function drawText(ctx, text, point, color, scale) {
632
+ const rect = textBubbleRect(ctx, text, point, scale);
633
+ ctx.save();
634
+ ctx.fillStyle = color;
635
+ ctx.beginPath();
636
+ roundedRectPath(ctx, rect.x, rect.y, rect.width, rect.height, 8);
637
+ ctx.fill();
638
+ ctx.font = textFont(scale);
639
+ ctx.fillStyle = "#FFFFFF";
640
+ ctx.textBaseline = "top";
641
+ ctx.fillText(text, rect.x + TEXT_PADDING, rect.y + TEXT_PADDING + BASE_FONT_SIZE * scale * (LINE_HEIGHT_RATIO - 1) / 2);
642
+ ctx.restore();
643
+ }
644
+ function roundedRectPath(ctx, x, y, w, h, r) {
645
+ const radius = Math.min(r, w / 2, h / 2);
646
+ ctx.moveTo(x + radius, y);
647
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
648
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
649
+ ctx.arcTo(x, y + h, x, y, radius);
650
+ ctx.arcTo(x, y, x + w, y, radius);
651
+ ctx.closePath();
652
+ }
653
+ function textBubbleRect(measurer, text, point, scale = 1) {
654
+ const previous = measurer.font;
655
+ measurer.font = textFont(scale);
656
+ const width = measurer.measureText(text).width;
657
+ measurer.font = previous;
658
+ const height = BASE_FONT_SIZE * scale * LINE_HEIGHT_RATIO;
659
+ const padding = TEXT_PADDING * scale;
660
+ return { x: point.x, y: point.y, width: width + padding * 2, height: height + padding * 2 };
661
+ }
662
+ function rectangleCorners(center, halfWidth, halfHeight, rotation) {
663
+ return [
664
+ { x: center.x - halfWidth, y: center.y - halfHeight },
665
+ { x: center.x + halfWidth, y: center.y - halfHeight },
666
+ { x: center.x + halfWidth, y: center.y + halfHeight },
667
+ { x: center.x - halfWidth, y: center.y + halfHeight }
668
+ ].map((p) => rotate(p, rotation, center));
669
+ }
670
+ function hitTest(annotation, point, size, measurer) {
671
+ const points = denormalize(annotation, size);
672
+ const scale = annotation.scale ?? 1;
673
+ switch (annotation.kind) {
674
+ case "rectangle": {
675
+ if (points.length !== 2) return false;
676
+ const center = midpoint(points[0], points[1]);
677
+ const hw = Math.abs(points[1].x - points[0].x) / 2 * scale + HIT_TEST_TOLERANCE;
678
+ const hh = Math.abs(points[1].y - points[0].y) / 2 * scale + HIT_TEST_TOLERANCE;
679
+ return Math.abs(point.x - center.x) <= hw && Math.abs(point.y - center.y) <= hh;
680
+ }
681
+ case "arrow": {
682
+ if (points.length !== 2) return false;
683
+ const center = midpoint(points[0], points[1]);
684
+ return distanceToSegment(point, scaled(points[0], scale, center), scaled(points[1], scale, center)) <= HIT_TEST_TOLERANCE;
685
+ }
686
+ case "text": {
687
+ if (points.length < 1 || !annotation.label) return false;
688
+ const rect = textBubbleRect(measurer, annotation.label, points[0], scale);
689
+ const t = HIT_TEST_TOLERANCE / 2;
690
+ return point.x >= rect.x - t && point.x <= rect.x + rect.width + t && point.y >= rect.y - t && point.y <= rect.y + rect.height + t;
691
+ }
692
+ case "freehand":
693
+ return false;
694
+ }
695
+ }
696
+ function midpoint(a, b) {
697
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
698
+ }
699
+ function rotate(p, angle, center) {
700
+ const dx = p.x - center.x;
701
+ const dy = p.y - center.y;
702
+ return {
703
+ x: center.x + dx * Math.cos(angle) - dy * Math.sin(angle),
704
+ y: center.y + dx * Math.sin(angle) + dy * Math.cos(angle)
705
+ };
706
+ }
707
+ function scaled(p, scale, center) {
708
+ return { x: center.x + (p.x - center.x) * scale, y: center.y + (p.y - center.y) * scale };
709
+ }
710
+ function distanceToSegment(p, a, b) {
711
+ const dx = b.x - a.x;
712
+ const dy = b.y - a.y;
713
+ const lengthSquared = dx * dx + dy * dy;
714
+ if (lengthSquared === 0) return Math.hypot(p.x - a.x, p.y - a.y);
715
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared));
716
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
717
+ }
718
+ const COLORS = ["#FF3B30", "#FFCC00", "#34C759", "#007AFF", "#000000"];
719
+ const MIN_SCALE = 0.3;
720
+ const MAX_SCALE = 5;
721
+ class AnnotationEditor {
722
+ constructor(shot) {
723
+ this.shot = shot;
724
+ this.annotations = [];
725
+ this.tool = "pen";
726
+ this.color = COLORS[0];
727
+ this.onChange = () => {
728
+ };
729
+ this.history = [];
730
+ this.draft = null;
731
+ this.draftStart = null;
732
+ this.pointers = /* @__PURE__ */ new Map();
733
+ this.drag = null;
734
+ this.gesture = null;
735
+ this.selected = null;
736
+ this.textInput = null;
737
+ this.displayScale = 1;
738
+ this.element = document.createElement("div");
739
+ this.element.className = "fk-canvas-box";
740
+ this.base = document.createElement("canvas");
741
+ this.ink = document.createElement("canvas");
742
+ this.ink.className = "fk-ink";
743
+ this.ink.setAttribute("role", "img");
744
+ this.ink.setAttribute("aria-label", "Screenshot — draw on it to highlight the problem");
745
+ this.element.append(this.base, this.ink);
746
+ const ctx = this.ink.getContext("2d");
747
+ if (!ctx) throw new Error("Canvas 2D is unavailable");
748
+ this.inkCtx = ctx;
749
+ this.ink.addEventListener("pointerdown", (e) => this.onPointerDown(e));
750
+ this.ink.addEventListener("pointermove", (e) => this.onPointerMove(e));
751
+ this.ink.addEventListener("pointerup", (e) => this.onPointerUp(e));
752
+ this.ink.addEventListener("pointercancel", (e) => this.onPointerUp(e));
753
+ this.ink.addEventListener("wheel", (e) => this.onWheel(e), { passive: false });
754
+ }
755
+ /** Fits the screenshot inside `maxWidth × maxHeight` (CSS px) keeping its aspect ratio. */
756
+ layout(maxWidth, maxHeight) {
757
+ const scale = Math.max(0.05, Math.min(maxWidth / this.shot.width, maxHeight / this.shot.height, 1));
758
+ this.displayScale = scale;
759
+ const w = Math.round(this.shot.width * scale);
760
+ const h = Math.round(this.shot.height * scale);
761
+ const dpr = window.devicePixelRatio || 1;
762
+ for (const canvas of [this.base, this.ink]) {
763
+ canvas.style.width = `${w}px`;
764
+ canvas.style.height = `${h}px`;
765
+ canvas.width = Math.round(w * dpr);
766
+ canvas.height = Math.round(h * dpr);
767
+ }
768
+ const baseCtx = this.base.getContext("2d");
769
+ if (baseCtx) {
770
+ baseCtx.imageSmoothingQuality = "high";
771
+ baseCtx.drawImage(this.shot.canvas, 0, 0, this.base.width, this.base.height);
772
+ }
773
+ this.cancelTextInput();
774
+ this.render();
775
+ }
776
+ setTool(tool) {
777
+ this.commitTextInput();
778
+ this.tool = tool;
779
+ this.selected = null;
780
+ this.ink.style.cursor = tool === "move" ? "grab" : tool === "text" ? "text" : "crosshair";
781
+ this.render();
782
+ }
783
+ undo() {
784
+ this.commitTextInput();
785
+ const previous = this.history.pop();
786
+ if (!previous) return;
787
+ this.annotations = previous;
788
+ this.selected = null;
789
+ this.changed();
790
+ }
791
+ clear() {
792
+ if (this.annotations.length === 0) return;
793
+ this.snapshot();
794
+ this.annotations = [];
795
+ this.selected = null;
796
+ this.changed();
797
+ }
798
+ get canUndo() {
799
+ return this.history.length > 0;
800
+ }
801
+ /** Deletes the annotation last moved/selected with the move tool. */
802
+ deleteSelected() {
803
+ if (this.selected === null || !this.annotations[this.selected]) return false;
804
+ this.snapshot();
805
+ this.annotations.splice(this.selected, 1);
806
+ this.selected = null;
807
+ this.changed();
808
+ return true;
809
+ }
810
+ /** The screenshot at full capture resolution with every annotation burned in. */
811
+ flatten() {
812
+ this.commitTextInput();
813
+ const out = document.createElement("canvas");
814
+ out.width = this.shot.canvas.width;
815
+ out.height = this.shot.canvas.height;
816
+ const ctx = out.getContext("2d");
817
+ if (!ctx) throw new Error("Canvas 2D is unavailable");
818
+ ctx.drawImage(this.shot.canvas, 0, 0);
819
+ ctx.scale(out.width / this.shot.width, out.height / this.shot.height);
820
+ drawAnnotations(ctx, this.annotations, { width: this.shot.width, height: this.shot.height });
821
+ return out;
822
+ }
823
+ commitTextInput() {
824
+ const input = this.textInput;
825
+ if (!input) return;
826
+ this.textInput = null;
827
+ const label = input.value.trim();
828
+ const origin = JSON.parse(input.dataset.origin ?? "[0,0]");
829
+ input.remove();
830
+ if (!label) return;
831
+ this.snapshot();
832
+ this.annotations.push({ kind: "text", points: [origin], colorHex: this.color, label, scale: 1, rotation: 0 });
833
+ this.changed();
834
+ }
835
+ cancelTextInput() {
836
+ this.textInput?.remove();
837
+ this.textInput = null;
838
+ }
839
+ // ---------------------------------------------------------------- drawing
840
+ render() {
841
+ const ctx = this.inkCtx;
842
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
843
+ ctx.clearRect(0, 0, this.ink.width, this.ink.height);
844
+ const k = this.ink.width / this.shot.width;
845
+ ctx.setTransform(k, 0, 0, k, 0, 0);
846
+ const size = { width: this.shot.width, height: this.shot.height };
847
+ drawAnnotations(ctx, this.annotations, size);
848
+ if (this.draft) drawAnnotation(ctx, this.draft, size);
849
+ }
850
+ changed() {
851
+ this.render();
852
+ this.onChange();
853
+ }
854
+ snapshot() {
855
+ this.history.push(this.annotations.map((a) => ({ ...a, points: a.points.map((p) => [p[0], p[1]]) })));
856
+ if (this.history.length > 100) this.history.shift();
857
+ }
858
+ // ---------------------------------------------------------------- input
859
+ /** Pointer position in capture coordinates (viewport CSS px of the original page). */
860
+ toCapture(e) {
861
+ const rect = this.ink.getBoundingClientRect();
862
+ return {
863
+ x: Math.max(0, Math.min(this.shot.width, (e.clientX - rect.left) / this.displayScale)),
864
+ y: Math.max(0, Math.min(this.shot.height, (e.clientY - rect.top) / this.displayScale))
865
+ };
866
+ }
867
+ normalize(p) {
868
+ return [round4(p.x / this.shot.width), round4(p.y / this.shot.height)];
869
+ }
870
+ hitIndex(p) {
871
+ const size = { width: this.shot.width, height: this.shot.height };
872
+ for (let i = this.annotations.length - 1; i >= 0; i--) {
873
+ if (hitTest(this.annotations[i], p, size, this.inkCtx)) return i;
874
+ }
875
+ return null;
876
+ }
877
+ onPointerDown(e) {
878
+ if (e.button !== 0 && e.pointerType === "mouse") return;
879
+ e.preventDefault();
880
+ const p = this.toCapture(e);
881
+ this.ink.setPointerCapture(e.pointerId);
882
+ this.pointers.set(e.pointerId, p);
883
+ if (this.tool === "move") {
884
+ if (this.pointers.size === 2 && this.selected !== null) {
885
+ this.drag = null;
886
+ this.startGesture();
887
+ return;
888
+ }
889
+ const index = this.hitIndex(p);
890
+ this.selected = index;
891
+ if (index !== null) {
892
+ this.snapshot();
893
+ this.drag = { index, last: p, moved: false };
894
+ this.ink.style.cursor = "grabbing";
895
+ }
896
+ return;
897
+ }
898
+ if (this.pointers.size > 1) return;
899
+ if (this.tool === "text") {
900
+ this.commitTextInput();
901
+ this.openTextInput(p);
902
+ return;
903
+ }
904
+ this.draftStart = p;
905
+ const n = this.normalize(p);
906
+ this.draft = {
907
+ kind: this.tool === "pen" ? "freehand" : this.tool,
908
+ points: this.tool === "pen" ? [n] : [n, n],
909
+ colorHex: this.color,
910
+ scale: 1,
911
+ rotation: 0
912
+ };
913
+ this.render();
914
+ }
915
+ onPointerMove(e) {
916
+ if (!this.pointers.has(e.pointerId)) {
917
+ if (this.tool === "move" && e.pointerType === "mouse") {
918
+ this.ink.style.cursor = this.hitIndex(this.toCapture(e)) !== null ? "grab" : "default";
919
+ }
920
+ return;
921
+ }
922
+ const p = this.toCapture(e);
923
+ this.pointers.set(e.pointerId, p);
924
+ if (this.gesture) {
925
+ this.updateGesture();
926
+ return;
927
+ }
928
+ if (this.drag) {
929
+ const dx = (p.x - this.drag.last.x) / this.shot.width;
930
+ const dy = (p.y - this.drag.last.y) / this.shot.height;
931
+ this.drag.last = p;
932
+ this.drag.moved = true;
933
+ const a = this.annotations[this.drag.index];
934
+ a.points = a.points.map(([x, y]) => [round4(x + dx), round4(y + dy)]);
935
+ this.render();
936
+ return;
937
+ }
938
+ if (!this.draft || !this.draftStart) return;
939
+ const n = this.normalize(p);
940
+ if (this.draft.kind === "freehand") {
941
+ const last = this.draft.points[this.draft.points.length - 1];
942
+ if (Math.hypot((n[0] - last[0]) * this.shot.width, (n[1] - last[1]) * this.shot.height) >= 1.5) {
943
+ this.draft.points.push(n);
944
+ }
945
+ } else {
946
+ this.draft.points = [this.draft.points[0], n];
947
+ }
948
+ this.render();
949
+ }
950
+ onPointerUp(e) {
951
+ if (!this.pointers.has(e.pointerId)) return;
952
+ this.pointers.delete(e.pointerId);
953
+ if (this.ink.hasPointerCapture(e.pointerId)) this.ink.releasePointerCapture(e.pointerId);
954
+ if (this.gesture) {
955
+ if (this.pointers.size < 2) {
956
+ this.gesture = null;
957
+ this.onChange();
958
+ }
959
+ return;
960
+ }
961
+ if (this.drag) {
962
+ if (!this.drag.moved) this.history.pop();
963
+ this.drag = null;
964
+ this.ink.style.cursor = "grab";
965
+ this.onChange();
966
+ return;
967
+ }
968
+ const draft = this.draft;
969
+ this.draft = null;
970
+ const start = this.draftStart;
971
+ this.draftStart = null;
972
+ if (!draft || !start) return;
973
+ if (draft.kind !== "freehand") {
974
+ const [a, b] = draft.points;
975
+ const tiny = Math.hypot((b[0] - a[0]) * this.shot.width, (b[1] - a[1]) * this.shot.height) < 6;
976
+ if (tiny) {
977
+ this.render();
978
+ return;
979
+ }
980
+ }
981
+ this.snapshot();
982
+ this.annotations.push(draft);
983
+ this.changed();
984
+ }
985
+ /** Mouse/trackpad stand-in for pinch/twist: wheel scales, Shift/Alt+wheel rotates. */
986
+ onWheel(e) {
987
+ if (this.tool !== "move") return;
988
+ const p = this.toCapture(e);
989
+ const index = this.hitIndex(p) ?? this.selected;
990
+ if (index === null || !this.annotations[index]) return;
991
+ e.preventDefault();
992
+ const a = this.annotations[index];
993
+ if (a.kind === "freehand") return;
994
+ this.selected = index;
995
+ if (e.shiftKey || e.altKey) {
996
+ if (a.kind === "text") return;
997
+ const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
998
+ a.rotation = round4(a.rotation + delta * 4e-3);
999
+ } else {
1000
+ a.scale = round4(clamp(a.scale * Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 15e-4)), MIN_SCALE, MAX_SCALE));
1001
+ }
1002
+ this.render();
1003
+ this.onChange();
1004
+ }
1005
+ startGesture() {
1006
+ if (this.selected === null) return;
1007
+ const [a, b] = [...this.pointers.values()];
1008
+ const annotation = this.annotations[this.selected];
1009
+ this.snapshot();
1010
+ this.gesture = {
1011
+ index: this.selected,
1012
+ startDistance: Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)),
1013
+ startAngle: Math.atan2(b.y - a.y, b.x - a.x),
1014
+ startScale: annotation.scale,
1015
+ startRotation: annotation.rotation
1016
+ };
1017
+ }
1018
+ updateGesture() {
1019
+ const g = this.gesture;
1020
+ if (!g || this.pointers.size < 2) return;
1021
+ const [a, b] = [...this.pointers.values()];
1022
+ const annotation = this.annotations[g.index];
1023
+ if (!annotation || annotation.kind === "freehand") return;
1024
+ const distance = Math.hypot(b.x - a.x, b.y - a.y);
1025
+ annotation.scale = round4(clamp(g.startScale * (distance / g.startDistance), MIN_SCALE, MAX_SCALE));
1026
+ if (annotation.kind !== "text") {
1027
+ annotation.rotation = round4(g.startRotation + (Math.atan2(b.y - a.y, b.x - a.x) - g.startAngle));
1028
+ }
1029
+ this.render();
1030
+ }
1031
+ openTextInput(p) {
1032
+ const input = document.createElement("input");
1033
+ input.type = "text";
1034
+ input.className = "fk-text-input";
1035
+ input.placeholder = "Add a note…";
1036
+ input.setAttribute("aria-label", "Annotation text");
1037
+ input.dataset.origin = JSON.stringify(this.normalize(p));
1038
+ input.style.left = `${Math.min(p.x * this.displayScale, this.shot.width * this.displayScale - 130)}px`;
1039
+ input.style.top = `${Math.min(p.y * this.displayScale, this.shot.height * this.displayScale - 36)}px`;
1040
+ input.style.borderColor = this.color;
1041
+ input.addEventListener("keydown", (e) => {
1042
+ e.stopPropagation();
1043
+ if (e.key === "Enter") this.commitTextInput();
1044
+ else if (e.key === "Escape") this.cancelTextInput();
1045
+ });
1046
+ input.addEventListener("blur", () => this.commitTextInput());
1047
+ this.element.append(input);
1048
+ this.textInput = input;
1049
+ requestAnimationFrame(() => input.focus());
1050
+ }
1051
+ }
1052
+ function clamp(v, lo, hi) {
1053
+ return Math.max(lo, Math.min(hi, v));
1054
+ }
1055
+ function round4(v) {
1056
+ return Math.round(v * 1e4) / 1e4;
1057
+ }
1058
+ const svg = (body) => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
1059
+ const icons = {
1060
+ feedback: svg(
1061
+ '<path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z"/><path d="M8.5 10.5h7M8.5 14h4.5"/>'
1062
+ ),
1063
+ pen: svg('<path d="M4 20l4-1L19 8a2.1 2.1 0 0 0-3-3L5 16l-1 4Z"/><path d="M14.5 6.5l3 3"/>'),
1064
+ rectangle: svg('<rect x="4" y="6" width="16" height="12" rx="1.5"/>'),
1065
+ arrow: svg('<path d="M6 18L18 6"/><path d="M9 6h9v9"/>'),
1066
+ text: svg('<path d="M5 6V5h14v1"/><path d="M12 5v14"/><path d="M9 19h6"/>'),
1067
+ move: svg(
1068
+ '<path d="M12 3v18M3 12h18"/><path d="M9 6l3-3 3 3M9 18l3 3 3-3M6 9l-3 3 3 3M18 9l3 3-3 3"/>'
1069
+ ),
1070
+ undo: svg('<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>'),
1071
+ trash: svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 13h10l1-13"/><path d="M9 7V4h6v3"/>'),
1072
+ close: svg('<path d="M6 6l12 12M18 6L6 18"/>'),
1073
+ paperclip: svg(
1074
+ '<path d="M21 11.5l-8.6 8.6a5.5 5.5 0 0 1-7.8-7.8l8.6-8.6a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9"/>'
1075
+ ),
1076
+ send: svg('<path d="M4 12l16-8-6 16-3-7-7-1Z"/>'),
1077
+ check: svg('<path d="M5 12.5l4.5 4.5L19 7.5"/>')
1078
+ };
1079
+ const styles = (
1080
+ /* css */
1081
+ `
1082
+ :host {
1083
+ all: initial;
1084
+ --fk-primary: #007aff;
1085
+ --fk-secondary: #6b7280;
1086
+ --fk-bg: #ffffff;
1087
+ --fk-surface: #f5f5f7;
1088
+ --fk-border: rgba(0, 0, 0, 0.1);
1089
+ --fk-text: #111114;
1090
+ --fk-muted: #6b6b73;
1091
+ --fk-danger: #d92d20;
1092
+ --fk-backdrop: rgba(10, 10, 14, 0.55);
1093
+ --fk-shadow: 0 24px 64px rgba(0, 0, 0, 0.28), 0 2px 8px rgba(0, 0, 0, 0.12);
1094
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1095
+ font-size: 14px;
1096
+ line-height: 1.4;
1097
+ color: var(--fk-text);
1098
+ }
1099
+ @media (prefers-color-scheme: dark) {
1100
+ :host(:not([data-color-scheme="light"])) {
1101
+ --fk-bg: #1c1c1f;
1102
+ --fk-surface: #26262a;
1103
+ --fk-border: rgba(255, 255, 255, 0.12);
1104
+ --fk-text: #f2f2f5;
1105
+ --fk-muted: #a0a0a8;
1106
+ --fk-backdrop: rgba(0, 0, 0, 0.65);
1107
+ }
1108
+ }
1109
+ :host([data-color-scheme="dark"]) {
1110
+ --fk-bg: #1c1c1f;
1111
+ --fk-surface: #26262a;
1112
+ --fk-border: rgba(255, 255, 255, 0.12);
1113
+ --fk-text: #f2f2f5;
1114
+ --fk-muted: #a0a0a8;
1115
+ --fk-backdrop: rgba(0, 0, 0, 0.65);
1116
+ }
1117
+ *, *::before, *::after { box-sizing: border-box; }
1118
+ button { font: inherit; color: inherit; cursor: pointer; }
1119
+ button:disabled { cursor: not-allowed; opacity: 0.45; }
1120
+ :focus-visible { outline: 2px solid var(--fk-primary); outline-offset: 2px; }
1121
+
1122
+ /* ---------- floating trigger ---------- */
1123
+ .fk-trigger {
1124
+ position: fixed;
1125
+ z-index: 2147483000;
1126
+ bottom: 20px;
1127
+ right: 20px;
1128
+ display: inline-flex;
1129
+ align-items: center;
1130
+ gap: 8px;
1131
+ height: 44px;
1132
+ padding: 0 16px 0 14px;
1133
+ border: 0;
1134
+ border-radius: 22px;
1135
+ background: var(--fk-primary);
1136
+ color: #fff;
1137
+ font-weight: 600;
1138
+ font-size: 14px;
1139
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
1140
+ transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
1141
+ }
1142
+ .fk-trigger[data-position="bottom-left"] { right: auto; left: 20px; }
1143
+ .fk-trigger:hover { transform: translateY(-1px); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.26); }
1144
+ .fk-trigger[data-compact="true"] { width: 48px; height: 48px; padding: 0; justify-content: center; border-radius: 24px; }
1145
+ .fk-trigger[data-compact="true"] .fk-trigger-label { display: none; }
1146
+ .fk-trigger[aria-busy="true"] { opacity: 0.6; pointer-events: none; }
1147
+
1148
+ /* ---------- overlay ---------- */
1149
+ .fk-overlay {
1150
+ position: fixed;
1151
+ inset: 0;
1152
+ z-index: 2147483001;
1153
+ display: flex;
1154
+ align-items: center;
1155
+ justify-content: center;
1156
+ padding: 24px;
1157
+ background: var(--fk-backdrop);
1158
+ animation: fk-fade 0.16s ease-out;
1159
+ }
1160
+ @keyframes fk-fade { from { opacity: 0; } to { opacity: 1; } }
1161
+ @keyframes fk-rise { from { opacity: 0; transform: translateY(8px) scale(0.99); } to { opacity: 1; transform: none; } }
1162
+ .fk-dialog {
1163
+ display: grid;
1164
+ grid-template-columns: minmax(0, 1fr) 340px;
1165
+ width: min(1180px, 100%);
1166
+ height: min(780px, 100%);
1167
+ overflow: hidden;
1168
+ border-radius: 16px;
1169
+ background: var(--fk-bg);
1170
+ box-shadow: var(--fk-shadow);
1171
+ animation: fk-rise 0.2s ease-out;
1172
+ }
1173
+ .fk-stage {
1174
+ display: flex;
1175
+ flex-direction: column;
1176
+ min-width: 0;
1177
+ min-height: 0;
1178
+ background: var(--fk-surface);
1179
+ border-right: 1px solid var(--fk-border);
1180
+ }
1181
+ .fk-toolbar {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ gap: 4px;
1185
+ flex-wrap: wrap;
1186
+ padding: 10px 12px;
1187
+ border-bottom: 1px solid var(--fk-border);
1188
+ }
1189
+ .fk-toolbar-sep { width: 1px; height: 22px; margin: 0 6px; background: var(--fk-border); }
1190
+ .fk-tool {
1191
+ display: inline-flex;
1192
+ align-items: center;
1193
+ justify-content: center;
1194
+ width: 34px;
1195
+ height: 34px;
1196
+ border: 0;
1197
+ border-radius: 8px;
1198
+ background: transparent;
1199
+ color: var(--fk-muted);
1200
+ }
1201
+ .fk-tool:hover:not(:disabled) { background: var(--fk-border); color: var(--fk-text); }
1202
+ .fk-tool[aria-pressed="true"] { background: var(--fk-primary); color: #fff; }
1203
+ .fk-swatch {
1204
+ width: 22px;
1205
+ height: 22px;
1206
+ margin: 0 2px;
1207
+ padding: 0;
1208
+ border-radius: 11px;
1209
+ border: 2px solid var(--fk-bg);
1210
+ box-shadow: 0 0 0 1px var(--fk-border);
1211
+ }
1212
+ .fk-swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--fk-text); }
1213
+ .fk-hint { margin-left: auto; font-size: 12px; color: var(--fk-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1214
+ .fk-canvas-wrap {
1215
+ position: relative;
1216
+ flex: 1;
1217
+ min-height: 0;
1218
+ display: flex;
1219
+ align-items: center;
1220
+ justify-content: center;
1221
+ padding: 16px;
1222
+ overflow: hidden;
1223
+ }
1224
+ .fk-canvas-box { position: relative; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.18); border-radius: 6px; overflow: hidden; }
1225
+ .fk-canvas-box canvas { display: block; }
1226
+ .fk-canvas-box .fk-ink { position: absolute; inset: 0; touch-action: none; }
1227
+ .fk-stage[data-screenshot="off"] .fk-canvas-box { opacity: 0.25; filter: grayscale(1); }
1228
+ .fk-stage[data-screenshot="off"] .fk-toolbar button { pointer-events: none; opacity: 0.35; }
1229
+ .fk-off-note {
1230
+ position: absolute;
1231
+ inset: 0;
1232
+ display: none;
1233
+ align-items: center;
1234
+ justify-content: center;
1235
+ font-weight: 600;
1236
+ color: var(--fk-muted);
1237
+ pointer-events: none;
1238
+ }
1239
+ .fk-stage[data-screenshot="off"] .fk-off-note { display: flex; }
1240
+ .fk-text-input {
1241
+ position: absolute;
1242
+ z-index: 2;
1243
+ min-width: 120px;
1244
+ padding: 6px 8px;
1245
+ border: 2px solid var(--fk-primary);
1246
+ border-radius: 8px;
1247
+ background: var(--fk-bg);
1248
+ color: var(--fk-text);
1249
+ font: bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1250
+ outline: none;
1251
+ }
1252
+
1253
+ /* ---------- composer ---------- */
1254
+ .fk-composer { display: flex; flex-direction: column; min-height: 0; padding: 18px; gap: 14px; overflow-y: auto; }
1255
+ .fk-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
1256
+ .fk-title { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
1257
+ .fk-close { display: inline-flex; width: 30px; height: 30px; align-items: center; justify-content: center; border: 0; border-radius: 15px; background: var(--fk-surface); color: var(--fk-muted); }
1258
+ .fk-close:hover { color: var(--fk-text); }
1259
+ .fk-label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; color: var(--fk-muted); text-transform: uppercase; letter-spacing: 0.04em; }
1260
+ .fk-textarea {
1261
+ width: 100%;
1262
+ min-height: 140px;
1263
+ resize: vertical;
1264
+ padding: 10px 12px;
1265
+ border: 1px solid var(--fk-border);
1266
+ border-radius: 10px;
1267
+ background: var(--fk-bg);
1268
+ color: var(--fk-text);
1269
+ font: inherit;
1270
+ line-height: 1.45;
1271
+ }
1272
+ .fk-textarea:focus { outline: none; border-color: var(--fk-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--fk-primary) 22%, transparent); }
1273
+ .fk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1274
+ .fk-chip {
1275
+ display: inline-flex;
1276
+ align-items: center;
1277
+ gap: 5px;
1278
+ height: 28px;
1279
+ padding: 0 11px;
1280
+ border: 1px solid var(--fk-border);
1281
+ border-radius: 14px;
1282
+ background: var(--fk-bg);
1283
+ font-size: 13px;
1284
+ }
1285
+ .fk-chip[aria-pressed="true"] { border-color: var(--fk-primary); background: color-mix(in srgb, var(--fk-primary) 12%, transparent); color: var(--fk-primary); font-weight: 600; }
1286
+ .fk-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
1287
+ .fk-switch { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; user-select: none; }
1288
+ .fk-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
1289
+ .fk-switch-track { position: relative; width: 36px; height: 22px; border-radius: 11px; background: var(--fk-border); transition: background 0.15s; }
1290
+ .fk-switch-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 9px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.15s; }
1291
+ .fk-switch input:checked + .fk-switch-track { background: var(--fk-primary); }
1292
+ .fk-switch input:checked + .fk-switch-track::after { transform: translateX(14px); }
1293
+ .fk-switch input:focus-visible + .fk-switch-track { outline: 2px solid var(--fk-primary); outline-offset: 2px; }
1294
+ .fk-attach {
1295
+ display: inline-flex;
1296
+ align-items: center;
1297
+ gap: 6px;
1298
+ height: 32px;
1299
+ padding: 0 12px;
1300
+ border: 1px dashed var(--fk-secondary);
1301
+ border-radius: 8px;
1302
+ background: transparent;
1303
+ color: var(--fk-secondary);
1304
+ font-size: 13px;
1305
+ }
1306
+ .fk-file { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 6px 4px 10px; border-radius: 8px; background: var(--fk-surface); font-size: 12px; }
1307
+ .fk-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1308
+ .fk-file button { display: inline-flex; border: 0; background: transparent; color: var(--fk-muted); padding: 2px; }
1309
+ .fk-meta { font-size: 12px; color: var(--fk-muted); }
1310
+ .fk-meta details summary { cursor: pointer; }
1311
+ .fk-meta pre { max-height: 140px; overflow: auto; margin: 6px 0 0; padding: 8px; border-radius: 8px; background: var(--fk-surface); font-size: 11px; white-space: pre-wrap; word-break: break-word; }
1312
+ .fk-spacer { flex: 1; }
1313
+ .fk-actions { display: flex; gap: 8px; }
1314
+ .fk-btn {
1315
+ display: inline-flex;
1316
+ flex: 1;
1317
+ align-items: center;
1318
+ justify-content: center;
1319
+ gap: 7px;
1320
+ height: 40px;
1321
+ border-radius: 10px;
1322
+ font-weight: 600;
1323
+ }
1324
+ .fk-btn-secondary { border: 1px solid var(--fk-border); background: transparent; color: var(--fk-secondary); }
1325
+ .fk-btn-primary { border: 0; background: var(--fk-primary); color: #fff; }
1326
+ .fk-btn-primary:hover:not(:disabled) { filter: brightness(1.06); }
1327
+ .fk-error { margin: 0; padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--fk-danger) 12%, transparent); color: var(--fk-danger); font-size: 13px; }
1328
+ .fk-spinner { width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.45); border-top-color: #fff; border-radius: 50%; animation: fk-spin 0.7s linear infinite; }
1329
+ @keyframes fk-spin { to { transform: rotate(360deg); } }
1330
+ .fk-done { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; text-align: center; }
1331
+ .fk-done-icon { display: inline-flex; width: 48px; height: 48px; align-items: center; justify-content: center; border-radius: 24px; background: var(--fk-primary); color: #fff; }
1332
+ .fk-done-icon svg { width: 26px; height: 26px; }
1333
+ .fk-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1334
+
1335
+ /* ---------- narrow screens: stack stage over composer ---------- */
1336
+ @media (max-width: 760px) {
1337
+ .fk-overlay { padding: 0; }
1338
+ .fk-dialog { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; width: 100%; height: 100%; border-radius: 0; }
1339
+ .fk-stage { border-right: 0; border-bottom: 1px solid var(--fk-border); }
1340
+ .fk-hint { display: none; }
1341
+ .fk-canvas-wrap { padding: 10px; }
1342
+ .fk-composer { max-height: 55vh; padding: 14px; gap: 10px; }
1343
+ .fk-textarea { min-height: 84px; }
1344
+ }
1345
+ @media (prefers-reduced-motion: reduce) {
1346
+ .fk-overlay, .fk-dialog { animation: none; }
1347
+ }
1348
+ `
1349
+ );
1350
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
1351
+ const TOOLS = [
1352
+ { tool: "pen", label: "Draw", icon: icons.pen },
1353
+ { tool: "rectangle", label: "Rectangle", icon: icons.rectangle },
1354
+ { tool: "arrow", label: "Arrow", icon: icons.arrow },
1355
+ { tool: "text", label: "Text", icon: icons.text },
1356
+ { tool: "move", label: "Move / resize", icon: icons.move }
1357
+ ];
1358
+ const HINTS = {
1359
+ pen: "Draw on the screenshot to highlight the problem",
1360
+ rectangle: "Drag to box an area",
1361
+ arrow: "Drag to point at something",
1362
+ text: "Click to add a note",
1363
+ move: "Drag to move · scroll to resize · Shift+scroll to rotate · Delete to remove"
1364
+ };
1365
+ class Widget {
1366
+ constructor(deps) {
1367
+ this.deps = deps;
1368
+ this.host = null;
1369
+ this.shadow = null;
1370
+ this.trigger = null;
1371
+ this.pending = null;
1372
+ this.isOwnNode = (node) => node === this.host;
1373
+ }
1374
+ get isOpen() {
1375
+ return this.pending !== null;
1376
+ }
1377
+ showTrigger(options, onClick) {
1378
+ const shadow = this.mount();
1379
+ this.trigger?.remove();
1380
+ const button = document.createElement("button");
1381
+ button.type = "button";
1382
+ button.className = "fk-trigger";
1383
+ button.dataset.position = options.position ?? "bottom-right";
1384
+ button.dataset.compact = String(!!options.compact);
1385
+ const label = options.label ?? "Feedback";
1386
+ button.setAttribute("aria-label", label);
1387
+ button.innerHTML = `${icons.feedback}<span class="fk-trigger-label"></span>`;
1388
+ button.querySelector(".fk-trigger-label").textContent = label;
1389
+ button.addEventListener("click", onClick);
1390
+ shadow.append(button);
1391
+ this.trigger = button;
1392
+ this.applyTheme();
1393
+ }
1394
+ hideTrigger() {
1395
+ this.trigger?.remove();
1396
+ this.trigger = null;
1397
+ }
1398
+ destroy() {
1399
+ this.hideTrigger();
1400
+ this.host?.remove();
1401
+ this.host = null;
1402
+ this.shadow = null;
1403
+ }
1404
+ open(options) {
1405
+ if (this.pending) return this.pending;
1406
+ this.pending = this.run(options).finally(() => {
1407
+ this.pending = null;
1408
+ });
1409
+ return this.pending;
1410
+ }
1411
+ mount() {
1412
+ if (this.shadow && this.host?.isConnected) return this.shadow;
1413
+ const host = document.createElement("div");
1414
+ host.setAttribute("data-feedbackkit-root", "");
1415
+ host.style.cssText = "position:static!important;display:contents!important;";
1416
+ const shadow = host.attachShadow({ mode: "open" });
1417
+ const style = document.createElement("style");
1418
+ style.textContent = styles;
1419
+ shadow.append(style);
1420
+ document.body.append(host);
1421
+ this.host = host;
1422
+ this.shadow = shadow;
1423
+ this.applyTheme();
1424
+ return shadow;
1425
+ }
1426
+ applyTheme() {
1427
+ const theme = this.deps.theme();
1428
+ const host = this.host;
1429
+ if (!host) return;
1430
+ const primary = validHex(theme?.primaryColorHex);
1431
+ const secondary = validHex(theme?.secondaryColorHex);
1432
+ if (primary) host.style.setProperty("--fk-primary", primary);
1433
+ else host.style.removeProperty("--fk-primary");
1434
+ if (secondary) host.style.setProperty("--fk-secondary", secondary);
1435
+ else host.style.removeProperty("--fk-secondary");
1436
+ }
1437
+ async run(options) {
1438
+ const shadow = this.mount();
1439
+ this.applyTheme();
1440
+ this.trigger?.setAttribute("aria-busy", "true");
1441
+ const captureOptions = this.deps.captureOptions();
1442
+ const [shot, environment] = await Promise.all([
1443
+ captureViewport({ ...captureOptions, exclude: this.isOwnNode }).catch((error) => {
1444
+ console.warn("[FeedbackKit] Screenshot capture failed; continuing without one.", error);
1445
+ return null;
1446
+ }),
1447
+ collectEnvironment({
1448
+ screenName: this.deps.currentScreen(),
1449
+ appVersion: this.deps.configuration()?.appVersion,
1450
+ appBuild: this.deps.configuration()?.appBuild
1451
+ })
1452
+ ]);
1453
+ const logs = this.deps.logs();
1454
+ this.trigger?.removeAttribute("aria-busy");
1455
+ return new Promise((resolve) => {
1456
+ const dialog = new Dialog(shadow, shot, environment, logs, this.deps, options, (result) => {
1457
+ resolve(result);
1458
+ });
1459
+ dialog.show();
1460
+ });
1461
+ }
1462
+ }
1463
+ class Dialog {
1464
+ constructor(shadow, shot, environment, logs, deps, options, done) {
1465
+ this.shadow = shadow;
1466
+ this.shot = shot;
1467
+ this.environment = environment;
1468
+ this.logs = logs;
1469
+ this.deps = deps;
1470
+ this.options = options;
1471
+ this.done = done;
1472
+ this.editor = null;
1473
+ this.includeLogs = true;
1474
+ this.attachment = null;
1475
+ this.selectedProducts = /* @__PURE__ */ new Set();
1476
+ this.products = [];
1477
+ this.sending = false;
1478
+ this.finished = false;
1479
+ this.resizeObserver = null;
1480
+ this.previousFocus = null;
1481
+ this.previousOverflow = "";
1482
+ this.toolButtons = /* @__PURE__ */ new Map();
1483
+ this.swatches = [];
1484
+ this.onKeyDown = (e) => this.handleKey(e);
1485
+ this.includeScreenshot = shot !== null;
1486
+ }
1487
+ show() {
1488
+ this.previousFocus = document.activeElement;
1489
+ this.previousOverflow = document.documentElement.style.overflow;
1490
+ document.documentElement.style.overflow = "hidden";
1491
+ const overlay = el("div", "fk-overlay");
1492
+ const dialog = el("div", "fk-dialog");
1493
+ dialog.setAttribute("role", "dialog");
1494
+ dialog.setAttribute("aria-modal", "true");
1495
+ dialog.setAttribute("aria-labelledby", "fk-title");
1496
+ dialog.append(this.buildStage(), this.buildComposer());
1497
+ overlay.append(dialog);
1498
+ overlay.addEventListener("mousedown", (e) => {
1499
+ if (e.target === overlay) this.cancel();
1500
+ });
1501
+ this.overlay = overlay;
1502
+ this.shadow.append(overlay);
1503
+ document.addEventListener("keydown", this.onKeyDown, true);
1504
+ this.layoutEditor();
1505
+ requestAnimationFrame(() => this.textarea.focus());
1506
+ void this.loadProducts();
1507
+ }
1508
+ // ---------------------------------------------------------------- stage
1509
+ buildStage() {
1510
+ const stage = el("div", "fk-stage");
1511
+ this.stage = stage;
1512
+ stage.dataset.screenshot = this.includeScreenshot ? "on" : "off";
1513
+ const toolbar = el("div", "fk-toolbar");
1514
+ toolbar.setAttribute("role", "toolbar");
1515
+ toolbar.setAttribute("aria-label", "Annotation tools");
1516
+ for (const { tool, label, icon } of TOOLS) {
1517
+ const b = iconButton("fk-tool", icon, label);
1518
+ b.setAttribute("aria-pressed", String(tool === "pen"));
1519
+ b.addEventListener("click", () => this.selectTool(tool));
1520
+ this.toolButtons.set(tool, b);
1521
+ toolbar.append(b);
1522
+ }
1523
+ toolbar.append(el("span", "fk-toolbar-sep"));
1524
+ COLORS.forEach((color, i) => {
1525
+ const b = document.createElement("button");
1526
+ b.type = "button";
1527
+ b.className = "fk-swatch";
1528
+ b.style.background = color;
1529
+ b.setAttribute("aria-label", `Color ${["red", "yellow", "green", "blue", "black"][i]}`);
1530
+ b.setAttribute("aria-pressed", String(i === 0));
1531
+ b.addEventListener("click", () => {
1532
+ if (this.editor) this.editor.color = color;
1533
+ this.swatches.forEach((s) => s.setAttribute("aria-pressed", String(s === b)));
1534
+ });
1535
+ this.swatches.push(b);
1536
+ toolbar.append(b);
1537
+ });
1538
+ toolbar.append(el("span", "fk-toolbar-sep"));
1539
+ this.undoButton = iconButton("fk-tool", icons.undo, "Undo");
1540
+ this.undoButton.addEventListener("click", () => this.editor?.undo());
1541
+ this.clearButton = iconButton("fk-tool", icons.trash, "Clear all annotations");
1542
+ this.clearButton.addEventListener("click", () => this.editor?.clear());
1543
+ toolbar.append(this.undoButton, this.clearButton);
1544
+ this.hint = el("span", "fk-hint");
1545
+ this.hint.textContent = HINTS.pen;
1546
+ toolbar.append(this.hint);
1547
+ const wrap = el("div", "fk-canvas-wrap");
1548
+ if (this.shot) {
1549
+ const editor = new AnnotationEditor(this.shot);
1550
+ editor.onChange = () => this.refreshState();
1551
+ editor.setTool("pen");
1552
+ this.editor = editor;
1553
+ wrap.append(editor.element);
1554
+ this.resizeObserver = new ResizeObserver(() => this.layoutEditor());
1555
+ this.resizeObserver.observe(wrap);
1556
+ }
1557
+ const note = el("div", "fk-off-note");
1558
+ note.textContent = this.shot ? "Screenshot won't be included" : "Screenshot unavailable on this page";
1559
+ wrap.append(note);
1560
+ stage.append(toolbar, wrap);
1561
+ return stage;
1562
+ }
1563
+ layoutEditor() {
1564
+ if (!this.editor) return;
1565
+ const wrap = this.editor.element.parentElement;
1566
+ if (!wrap) return;
1567
+ const styles2 = getComputedStyle(wrap);
1568
+ const padX = parseFloat(styles2.paddingLeft) + parseFloat(styles2.paddingRight);
1569
+ const padY = parseFloat(styles2.paddingTop) + parseFloat(styles2.paddingBottom);
1570
+ this.editor.layout(Math.max(40, wrap.clientWidth - padX), Math.max(40, wrap.clientHeight - padY));
1571
+ }
1572
+ selectTool(tool) {
1573
+ this.editor?.setTool(tool);
1574
+ this.toolButtons.forEach((b, t) => b.setAttribute("aria-pressed", String(t === tool)));
1575
+ this.hint.textContent = HINTS[tool];
1576
+ }
1577
+ // ---------------------------------------------------------------- composer
1578
+ buildComposer() {
1579
+ const composer = el("div", "fk-composer");
1580
+ const header = el("div", "fk-header");
1581
+ const title = el("h2", "fk-title");
1582
+ title.id = "fk-title";
1583
+ title.textContent = "Send feedback";
1584
+ const close = iconButton("fk-close", icons.close, "Close");
1585
+ close.addEventListener("click", () => this.cancel());
1586
+ header.append(title, close);
1587
+ const textLabel = el("label", "fk-label");
1588
+ textLabel.textContent = "What's the problem?";
1589
+ textLabel.setAttribute("for", "fk-text");
1590
+ this.textarea = document.createElement("textarea");
1591
+ this.textarea.id = "fk-text";
1592
+ this.textarea.className = "fk-textarea";
1593
+ this.textarea.placeholder = "Describe what happened and what you expected instead…";
1594
+ this.textarea.addEventListener("input", () => this.refreshState());
1595
+ const textGroup = el("div");
1596
+ textGroup.append(textLabel, this.textarea);
1597
+ const productsGroup = el("div");
1598
+ productsGroup.dataset.role = "products";
1599
+ productsGroup.hidden = true;
1600
+ const optionsRow = el("div", "fk-row");
1601
+ const toggle = switchControl("Screenshot", this.includeScreenshot, (on) => {
1602
+ this.includeScreenshot = on;
1603
+ this.stage.dataset.screenshot = on ? "on" : "off";
1604
+ this.refreshState();
1605
+ });
1606
+ if (!this.shot) toggle.querySelector("input").disabled = true;
1607
+ const attachGroup = el("div");
1608
+ const fileInput = document.createElement("input");
1609
+ fileInput.type = "file";
1610
+ fileInput.hidden = true;
1611
+ const attachButton = document.createElement("button");
1612
+ attachButton.type = "button";
1613
+ attachButton.className = "fk-attach";
1614
+ attachButton.innerHTML = `${icons.paperclip}<span>Attach file</span>`;
1615
+ attachButton.addEventListener("click", () => fileInput.click());
1616
+ fileInput.addEventListener("change", () => {
1617
+ const file = fileInput.files?.[0];
1618
+ fileInput.value = "";
1619
+ if (!file) return;
1620
+ if (file.size > MAX_ATTACHMENT_BYTES) {
1621
+ this.showError(`"${file.name}" is larger than 10 MB.`);
1622
+ return;
1623
+ }
1624
+ this.attachment = { filename: file.name, mimeType: file.type || "application/octet-stream", data: file };
1625
+ renderAttachment();
1626
+ });
1627
+ const renderAttachment = () => {
1628
+ attachGroup.replaceChildren(fileInput);
1629
+ if (this.attachment) {
1630
+ const chip = el("span", "fk-file");
1631
+ const name = el("span");
1632
+ name.textContent = this.attachment.filename;
1633
+ const remove = iconButton("", icons.close, `Remove ${this.attachment.filename}`);
1634
+ remove.addEventListener("click", () => {
1635
+ this.attachment = null;
1636
+ renderAttachment();
1637
+ });
1638
+ chip.append(name, remove);
1639
+ attachGroup.append(chip);
1640
+ } else {
1641
+ attachGroup.append(attachButton);
1642
+ }
1643
+ };
1644
+ renderAttachment();
1645
+ optionsRow.append(attachGroup, toggle);
1646
+ const meta = this.buildDiagnostics();
1647
+ this.errorBox = el("p", "fk-error");
1648
+ this.errorBox.setAttribute("role", "alert");
1649
+ this.errorBox.hidden = true;
1650
+ const actions = el("div", "fk-actions");
1651
+ const cancel = document.createElement("button");
1652
+ cancel.type = "button";
1653
+ cancel.className = "fk-btn fk-btn-secondary";
1654
+ cancel.textContent = "Cancel";
1655
+ cancel.addEventListener("click", () => this.cancel());
1656
+ this.sendButton = document.createElement("button");
1657
+ this.sendButton.type = "button";
1658
+ this.sendButton.className = "fk-btn fk-btn-primary";
1659
+ this.sendButton.innerHTML = `${icons.send}<span>Send</span>`;
1660
+ this.sendButton.title = "Send (⌘/Ctrl + Enter)";
1661
+ this.sendButton.addEventListener("click", () => void this.send());
1662
+ actions.append(cancel, this.sendButton);
1663
+ composer.append(header, textGroup, productsGroup, optionsRow);
1664
+ if (meta) composer.append(meta);
1665
+ composer.append(el("div", "fk-spacer"), this.errorBox, actions);
1666
+ this.refreshState();
1667
+ return composer;
1668
+ }
1669
+ /** Shows the user exactly what page context will be sent, with an opt-out for logs. */
1670
+ buildDiagnostics() {
1671
+ const box = el("div", "fk-meta");
1672
+ const env = this.environment;
1673
+ const summary = `${env.deviceModel} · ${env.osName} ${env.osVersion}`.trim();
1674
+ const details = document.createElement("details");
1675
+ const s = document.createElement("summary");
1676
+ s.textContent = `Included: page URL, ${summary}${this.logs.length ? `, ${this.logs.length} log entr${this.logs.length === 1 ? "y" : "ies"}` : ""}`;
1677
+ const pre = document.createElement("pre");
1678
+ const lines = [`URL: ${env.pageUrl ?? ""}`, `Viewport: ${env.screenWidthPoints}×${env.screenHeightPoints} @${env.screenScale}x`];
1679
+ if (this.logs.length) {
1680
+ lines.push("", ...this.logs.map((l) => `[${l.level}] ${l.message}`));
1681
+ }
1682
+ pre.textContent = lines.join("\n");
1683
+ details.append(s, pre);
1684
+ box.append(details);
1685
+ if (this.logs.length) {
1686
+ const logsToggle = switchControl("Include console & network logs", true, (on) => {
1687
+ this.includeLogs = on;
1688
+ });
1689
+ logsToggle.style.marginTop = "8px";
1690
+ box.append(logsToggle);
1691
+ }
1692
+ return box;
1693
+ }
1694
+ async loadProducts() {
1695
+ let products2 = [];
1696
+ try {
1697
+ products2 = await this.deps.products();
1698
+ } catch {
1699
+ return;
1700
+ }
1701
+ if (this.finished || products2.length === 0) return;
1702
+ this.products = products2;
1703
+ const preferred = this.deps.defaultProductKey();
1704
+ const initial = preferred && products2.find((p) => p.key === preferred) || products2.find((p) => p.isDefault) || null;
1705
+ if (initial) this.selectedProducts.add(initial.key);
1706
+ const group = this.overlay.querySelector('[data-role="products"]');
1707
+ if (!group) return;
1708
+ const label = el("span", "fk-label");
1709
+ label.textContent = "Affected product";
1710
+ const chips = el("div", "fk-chips");
1711
+ chips.setAttribute("role", "group");
1712
+ chips.setAttribute("aria-label", "Affected products");
1713
+ for (const product of products2) {
1714
+ const chip = document.createElement("button");
1715
+ chip.type = "button";
1716
+ chip.className = "fk-chip";
1717
+ chip.textContent = product.name || product.key;
1718
+ if (product.description) chip.title = product.description;
1719
+ chip.setAttribute("aria-pressed", String(this.selectedProducts.has(product.key)));
1720
+ chip.addEventListener("click", () => {
1721
+ if (this.selectedProducts.has(product.key)) this.selectedProducts.delete(product.key);
1722
+ else this.selectedProducts.add(product.key);
1723
+ chip.setAttribute("aria-pressed", String(this.selectedProducts.has(product.key)));
1724
+ });
1725
+ chips.append(chip);
1726
+ }
1727
+ group.replaceChildren(label, chips);
1728
+ group.hidden = false;
1729
+ }
1730
+ refreshState() {
1731
+ const hasText = this.textarea?.value.trim().length > 0;
1732
+ const hasMarks = this.includeScreenshot && (this.editor?.annotations.length ?? 0) > 0;
1733
+ if (this.sendButton) this.sendButton.disabled = this.sending || !hasText && !hasMarks;
1734
+ if (this.undoButton) this.undoButton.disabled = !this.editor?.canUndo;
1735
+ if (this.clearButton) this.clearButton.disabled = !(this.editor && this.editor.annotations.length > 0);
1736
+ }
1737
+ // ---------------------------------------------------------------- keyboard
1738
+ handleKey(e) {
1739
+ if (this.finished) return;
1740
+ const mod = e.metaKey || e.ctrlKey;
1741
+ const active = this.shadow.activeElement;
1742
+ const typing = active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement;
1743
+ if (active instanceof HTMLInputElement && active.classList.contains("fk-text-input")) return;
1744
+ if (e.key === "Escape") {
1745
+ e.preventDefault();
1746
+ e.stopPropagation();
1747
+ this.cancel();
1748
+ } else if (mod && e.key === "Enter") {
1749
+ e.preventDefault();
1750
+ e.stopPropagation();
1751
+ void this.send();
1752
+ } else if (mod && !e.shiftKey && e.key.toLowerCase() === "z" && !typing) {
1753
+ e.preventDefault();
1754
+ e.stopPropagation();
1755
+ this.editor?.undo();
1756
+ } else if ((e.key === "Backspace" || e.key === "Delete") && !typing) {
1757
+ if (this.editor?.deleteSelected()) {
1758
+ e.preventDefault();
1759
+ e.stopPropagation();
1760
+ }
1761
+ } else if (e.key === "Tab") {
1762
+ this.trapFocus(e);
1763
+ }
1764
+ }
1765
+ trapFocus(e) {
1766
+ const focusable = [
1767
+ ...this.overlay.querySelectorAll("button:not(:disabled), textarea, input:not([type=file]):not(:disabled), summary")
1768
+ ].filter((n) => n.offsetParent !== null || n.tagName === "INPUT");
1769
+ if (focusable.length === 0) return;
1770
+ const first = focusable[0];
1771
+ const last = focusable[focusable.length - 1];
1772
+ const active = this.shadow.activeElement;
1773
+ if (e.shiftKey && (active === first || !active)) {
1774
+ e.preventDefault();
1775
+ last.focus();
1776
+ } else if (!e.shiftKey && active === last) {
1777
+ e.preventDefault();
1778
+ first.focus();
1779
+ }
1780
+ }
1781
+ // ---------------------------------------------------------------- results
1782
+ showError(message) {
1783
+ this.errorBox.hidden = !message;
1784
+ this.errorBox.textContent = message ?? "";
1785
+ }
1786
+ async buildReport() {
1787
+ let screenshotRaw = null;
1788
+ let screenshotAnnotated = null;
1789
+ let annotations = [];
1790
+ if (this.includeScreenshot && this.shot && this.editor) {
1791
+ const flattened = this.editor.flatten();
1792
+ [screenshotRaw, screenshotAnnotated] = await Promise.all([
1793
+ canvasToPngBlob(this.shot.canvas),
1794
+ canvasToPngBlob(flattened)
1795
+ ]);
1796
+ annotations = this.editor.annotations.map((a) => ({ ...a }));
1797
+ }
1798
+ return {
1799
+ id: uuid(),
1800
+ createdAt: /* @__PURE__ */ new Date(),
1801
+ text: this.textarea.value.trim(),
1802
+ screenshotRaw,
1803
+ screenshotAnnotated,
1804
+ annotations,
1805
+ environment: this.environment,
1806
+ attachment: this.attachment,
1807
+ products: this.products.filter((p) => this.selectedProducts.has(p.key)),
1808
+ logs: this.includeLogs ? this.logs : []
1809
+ };
1810
+ }
1811
+ async send() {
1812
+ if (this.sending || this.sendButton.disabled) return;
1813
+ this.sending = true;
1814
+ this.showError(null);
1815
+ this.refreshState();
1816
+ this.sendButton.innerHTML = `<span class="fk-spinner" aria-hidden="true"></span><span>Sending…</span>`;
1817
+ try {
1818
+ const report = await this.buildReport();
1819
+ if (this.options.submit) {
1820
+ const configuration2 = this.deps.configuration();
1821
+ if (!configuration2) throw new Error("FeedbackKit.configure() hasn't been called.");
1822
+ await submitReport(report, configuration2);
1823
+ }
1824
+ await this.options.onReport?.(report);
1825
+ this.showThanks();
1826
+ setTimeout(() => this.finish(report), this.options.submit ? 1300 : 0);
1827
+ } catch (error) {
1828
+ this.sending = false;
1829
+ this.sendButton.innerHTML = `${icons.send}<span>Retry</span>`;
1830
+ this.showError(error instanceof Error ? error.message : "Something went wrong sending your feedback.");
1831
+ this.refreshState();
1832
+ this.options.onError?.(error);
1833
+ }
1834
+ }
1835
+ showThanks() {
1836
+ if (!this.options.submit) return;
1837
+ const composer = this.overlay.querySelector(".fk-composer");
1838
+ if (!composer) return;
1839
+ const done = el("div", "fk-done");
1840
+ done.setAttribute("role", "status");
1841
+ const icon = el("span", "fk-done-icon");
1842
+ icon.innerHTML = icons.check;
1843
+ const title = el("h2", "fk-title");
1844
+ title.textContent = "Thanks for the feedback!";
1845
+ const sub = el("p", "fk-meta");
1846
+ sub.textContent = "It's on its way to the team.";
1847
+ done.append(icon, title, sub);
1848
+ composer.replaceChildren(done);
1849
+ }
1850
+ cancel() {
1851
+ if (this.sending) return;
1852
+ this.finish(null);
1853
+ }
1854
+ finish(result) {
1855
+ if (this.finished) return;
1856
+ this.finished = true;
1857
+ this.resizeObserver?.disconnect();
1858
+ document.removeEventListener("keydown", this.onKeyDown, true);
1859
+ this.overlay.remove();
1860
+ document.documentElement.style.overflow = this.previousOverflow;
1861
+ if (this.previousFocus instanceof HTMLElement) this.previousFocus.focus({ preventScroll: true });
1862
+ this.done(result);
1863
+ }
1864
+ }
1865
+ function el(tag, className) {
1866
+ const node = document.createElement(tag);
1867
+ if (className) node.className = className;
1868
+ return node;
1869
+ }
1870
+ function iconButton(className, icon, label) {
1871
+ const b = document.createElement("button");
1872
+ b.type = "button";
1873
+ if (className) b.className = className;
1874
+ b.innerHTML = icon;
1875
+ b.setAttribute("aria-label", label);
1876
+ b.title = label;
1877
+ return b;
1878
+ }
1879
+ function switchControl(label, checked, onChange) {
1880
+ const wrap = el("label", "fk-switch");
1881
+ const input = document.createElement("input");
1882
+ input.type = "checkbox";
1883
+ input.checked = checked;
1884
+ input.setAttribute("role", "switch");
1885
+ input.addEventListener("change", () => onChange(input.checked));
1886
+ const track = el("span", "fk-switch-track");
1887
+ const text = el("span");
1888
+ text.textContent = label;
1889
+ wrap.append(input, track, text);
1890
+ return wrap;
1891
+ }
1892
+ function validHex(hex) {
1893
+ if (!hex) return null;
1894
+ const cleaned = hex.trim().replace(/^#/, "");
1895
+ return /^[0-9a-fA-F]{6}$/.test(cleaned) ? `#${cleaned}` : null;
1896
+ }
1897
+ function uuid() {
1898
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
1899
+ const bytes = new Uint8Array(16);
1900
+ crypto.getRandomValues(bytes);
1901
+ bytes[6] = bytes[6] & 15 | 64;
1902
+ bytes[8] = bytes[8] & 63 | 128;
1903
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
1904
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1905
+ }
1906
+ let configuration = null;
1907
+ let products = [];
1908
+ let productsPromise = null;
1909
+ let logCapture = null;
1910
+ let shortcutHandler = null;
1911
+ const widget = new Widget({
1912
+ configuration: () => configuration,
1913
+ theme: () => FeedbackKit.theme,
1914
+ products: () => resolveProducts(),
1915
+ defaultProductKey: () => configuration?.defaultProductKey,
1916
+ logs: () => logCapture?.buffer.snapshot() ?? [],
1917
+ captureOptions: () => FeedbackKit.captureOptions,
1918
+ currentScreen: () => FeedbackKit.currentScreen
1919
+ });
1920
+ function resolveProducts() {
1921
+ if (products.length > 0 || !configuration) return Promise.resolve(products);
1922
+ if (!productsPromise) {
1923
+ const config = configuration;
1924
+ productsPromise = fetchProducts(config).then((fetched) => {
1925
+ if (configuration === config) products = fetched;
1926
+ return fetched;
1927
+ }).catch(() => {
1928
+ productsPromise = null;
1929
+ return [];
1930
+ });
1931
+ }
1932
+ return productsPromise;
1933
+ }
1934
+ function whenBodyReady(fn) {
1935
+ if (document.body) fn();
1936
+ else document.addEventListener("DOMContentLoaded", fn, { once: true });
1937
+ }
1938
+ const FeedbackKit = {
1939
+ /**
1940
+ * The current screen/page name included in reports. Falls back to
1941
+ * `location.pathname` when unset. Set it from your router for nicer names.
1942
+ */
1943
+ currentScreen: null,
1944
+ /** Brand colors for the trigger button and dialog. */
1945
+ theme: null,
1946
+ /** Screenshot capture settings. See `CaptureOptions`. */
1947
+ captureOptions: {},
1948
+ /** The hosted dashboard's ingestion endpoint, used when `configure` gets no `endpoint`. */
1949
+ DEFAULT_ENDPOINT,
1950
+ /**
1951
+ * Configures delivery to the FeedbackKit dashboard (or your own deployment
1952
+ * of it). Pass `null` to go back to local-only delivery via `present`.
1953
+ * Also starts console/network log capture unless `captureLogs: false`.
1954
+ */
1955
+ configure(config) {
1956
+ configuration = config ? { ...config, endpoint: config.endpoint ?? DEFAULT_ENDPOINT } : null;
1957
+ products = config?.products ?? [];
1958
+ productsPromise = null;
1959
+ setIgnoredUrlPrefix(configuration?.endpoint ?? null);
1960
+ if (configuration && products.length === 0) void resolveProducts();
1961
+ if (config && config.captureLogs !== false && !logCapture) {
1962
+ FeedbackKit.enableLogCapture(typeof config.captureLogs === "object" ? config.captureLogs : {});
1963
+ }
1964
+ },
1965
+ get isConfigured() {
1966
+ return configuration !== null;
1967
+ },
1968
+ get configuration() {
1969
+ return configuration;
1970
+ },
1971
+ /** Products for the composer's chips — static from `configure`, else fetched from the backend. */
1972
+ fetchProducts() {
1973
+ return resolveProducts();
1974
+ },
1975
+ /**
1976
+ * Opens the feedback dialog and resolves with the finished report — or
1977
+ * `null` if the user cancels. Nothing is sent anywhere; delivery is yours.
1978
+ */
1979
+ present(options = {}) {
1980
+ return widget.open({ submit: false, onReport: options.onReport });
1981
+ },
1982
+ /**
1983
+ * Opens the dialog and, on Send, delivers the report to the configured
1984
+ * endpoint (showing progress, errors with retry, and a thank-you state).
1985
+ * Resolves with the submitted report, or `null` if cancelled.
1986
+ */
1987
+ presentAndSubmit(options = {}) {
1988
+ if (!configuration) {
1989
+ return Promise.reject(new Error("FeedbackKit.configure({ projectKey }) must be called before presentAndSubmit()."));
1990
+ }
1991
+ return widget.open({ submit: true, onReport: options.onSubmitted, onError: options.onError });
1992
+ },
1993
+ /** Sends an already-built report (e.g. one from `present`) to the configured endpoint. */
1994
+ submit(report) {
1995
+ if (!configuration) {
1996
+ return Promise.reject(new Error("FeedbackKit.configure({ projectKey }) must be called before submit()."));
1997
+ }
1998
+ return submitReport(report, configuration);
1999
+ },
2000
+ get isPresenting() {
2001
+ return widget.isOpen;
2002
+ },
2003
+ /**
2004
+ * A floating "Feedback" button. Clicking it runs `presentAndSubmit` when
2005
+ * configured, otherwise `present` and logs the report to the console.
2006
+ */
2007
+ showFloatingTriggerButton(options = {}) {
2008
+ whenBodyReady(
2009
+ () => widget.showTrigger(options, () => {
2010
+ if (configuration) void FeedbackKit.presentAndSubmit();
2011
+ else void FeedbackKit.present({ onReport: (r) => console.info("[FeedbackKit] report", r) });
2012
+ })
2013
+ );
2014
+ },
2015
+ hideFloatingTriggerButton() {
2016
+ widget.hideTrigger();
2017
+ },
2018
+ /**
2019
+ * The web's stand-in for the native SDKs' shake-to-report: a keyboard
2020
+ * shortcut, ⌘⇧F on macOS / Ctrl+Shift+F elsewhere by default.
2021
+ */
2022
+ enableKeyboardShortcut(options = {}) {
2023
+ FeedbackKit.disableKeyboardShortcut();
2024
+ const key = (options.key ?? "F").toLowerCase();
2025
+ shortcutHandler = (e) => {
2026
+ if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey || e.key.toLowerCase() !== key) return;
2027
+ e.preventDefault();
2028
+ if (configuration) void FeedbackKit.presentAndSubmit();
2029
+ else void FeedbackKit.present({ onReport: (r) => console.info("[FeedbackKit] report", r) });
2030
+ };
2031
+ window.addEventListener("keydown", shortcutHandler);
2032
+ },
2033
+ disableKeyboardShortcut() {
2034
+ if (shortcutHandler) window.removeEventListener("keydown", shortcutHandler);
2035
+ shortcutHandler = null;
2036
+ },
2037
+ /**
2038
+ * Starts recording recent console warnings/errors, uncaught exceptions,
2039
+ * and failed network requests, attached to each report (users can opt out
2040
+ * per report). Call as early as possible so errors before the first
2041
+ * report are caught. `false` turns it off.
2042
+ */
2043
+ enableLogCapture(options = {}) {
2044
+ logCapture?.uninstall();
2045
+ logCapture = options === false ? null : installLogCapture(options);
2046
+ },
2047
+ /** Captures the current viewport as a PNG, without opening any UI. */
2048
+ async captureScreenshot(options = {}) {
2049
+ const shot = await captureViewport({ ...FeedbackKit.captureOptions, ...options, exclude: widget.isOwnNode });
2050
+ return canvasToPngBlob(shot.canvas);
2051
+ },
2052
+ /** Removes every trace of the SDK from the page (trigger, dialog host, listeners, log hooks). */
2053
+ destroy() {
2054
+ FeedbackKit.disableKeyboardShortcut();
2055
+ logCapture?.uninstall();
2056
+ logCapture = null;
2057
+ widget.destroy();
2058
+ }
2059
+ };
2060
+ export {
2061
+ DEFAULT_ENDPOINT,
2062
+ FeedbackKit,
2063
+ FeedbackSubmissionError,
2064
+ STROKE_WIDTH,
2065
+ FeedbackKit as default,
2066
+ drawAnnotation,
2067
+ drawAnnotations,
2068
+ encodePayload,
2069
+ hitTest,
2070
+ parseUserAgent,
2071
+ readPoint,
2072
+ redactUrl,
2073
+ textBubbleRect
2074
+ };
2075
+ //# sourceMappingURL=feedbackkit.js.map