reflet-sdk 0.1.5 → 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/CHANGELOG.md +28 -0
- package/README.md +71 -7
- package/dist/client-BYuSKJw5.js +270 -0
- package/dist/client-BYuSKJw5.js.map +1 -0
- package/dist/client-CSyPxFPK.d.ts +129 -0
- package/dist/client-CSyPxFPK.d.ts.map +1 -0
- package/dist/feedback.d.ts +130 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +2547 -0
- package/dist/feedback.js.map +1 -0
- package/dist/index.d.ts +3 -297
- package/dist/index.js +2 -3
- package/dist/react-context-DZw_HHjj.js +85 -0
- package/dist/react-context-DZw_HHjj.js.map +1 -0
- package/dist/react.d.ts +208 -89
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +1246 -338
- package/dist/react.js.map +1 -1
- package/dist/server.d.ts +18 -16
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +118 -117
- package/dist/server.js.map +1 -1
- package/dist/types-UKPPJGrA.d.ts +273 -0
- package/dist/types-UKPPJGrA.d.ts.map +1 -0
- package/package.json +30 -10
- package/dist/chunk-GD7FQQBT.js +0 -282
- package/dist/chunk-GD7FQQBT.js.map +0 -1
- package/dist/index.js.map +0 -1
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,2547 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { t as Reflet } from "./client-BYuSKJw5.js";
|
|
3
|
+
import { t as RefletContext } from "./react-context-DZw_HHjj.js";
|
|
4
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
7
|
+
//#region src/feedback/core/capture.ts
|
|
8
|
+
/** Marks every node the widget owns, so captures never include the panel. */
|
|
9
|
+
const WIDGET_MARKER = "data-reflet-widget";
|
|
10
|
+
const MAX_PIXEL_RATIO = 2;
|
|
11
|
+
const DEFAULT_MAX_WIDTH = 2400;
|
|
12
|
+
function buildSnapdomOptions(options) {
|
|
13
|
+
const ratio = options.devicePixelRatio ?? 1;
|
|
14
|
+
return {
|
|
15
|
+
cache: "disabled",
|
|
16
|
+
clip: options.element ? void 0 : "viewport",
|
|
17
|
+
dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
|
|
18
|
+
embedFonts: true,
|
|
19
|
+
exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
|
|
20
|
+
fast: true
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function fitWithin(size, maxWidth) {
|
|
24
|
+
if (size.width <= maxWidth) return {
|
|
25
|
+
height: size.height,
|
|
26
|
+
scale: 1,
|
|
27
|
+
width: size.width
|
|
28
|
+
};
|
|
29
|
+
const scale = maxWidth / size.width;
|
|
30
|
+
return {
|
|
31
|
+
height: Math.max(1, Math.round(size.height * scale)),
|
|
32
|
+
scale,
|
|
33
|
+
width: maxWidth
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function toBlob$1(canvas) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
canvas.toBlob(resolve, "image/png");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function downscale(canvas, target) {
|
|
42
|
+
const resized = document.createElement("canvas");
|
|
43
|
+
resized.width = target.width;
|
|
44
|
+
resized.height = target.height;
|
|
45
|
+
const context = resized.getContext("2d");
|
|
46
|
+
if (!context) return canvas;
|
|
47
|
+
context.imageSmoothingQuality = "high";
|
|
48
|
+
context.drawImage(canvas, 0, 0, target.width, target.height);
|
|
49
|
+
return resized;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Renders what the user is looking at right now.
|
|
53
|
+
*
|
|
54
|
+
* Runs off the DOM rather than `getDisplayMedia` on purpose: a screen-share
|
|
55
|
+
* permission prompt on every report would cost more reports than it captures.
|
|
56
|
+
* Returns null instead of throwing — a screenshot is never worth losing the
|
|
57
|
+
* written feedback.
|
|
58
|
+
*/
|
|
59
|
+
async function captureViewport(options = {}) {
|
|
60
|
+
try {
|
|
61
|
+
const { snapdom } = await import("@zumer/snapdom");
|
|
62
|
+
const target = options.element ?? document.documentElement;
|
|
63
|
+
const canvas = await snapdom.toCanvas(target, buildSnapdomOptions({
|
|
64
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
65
|
+
element: options.element,
|
|
66
|
+
excludeSelectors: options.excludeSelectors
|
|
67
|
+
}));
|
|
68
|
+
const fitted = fitWithin({
|
|
69
|
+
height: canvas.height,
|
|
70
|
+
width: canvas.width
|
|
71
|
+
}, options.maxWidth ?? DEFAULT_MAX_WIDTH);
|
|
72
|
+
const output = fitted.scale === 1 ? canvas : downscale(canvas, fitted);
|
|
73
|
+
const blob = await toBlob$1(output);
|
|
74
|
+
if (!blob) return null;
|
|
75
|
+
return {
|
|
76
|
+
blob,
|
|
77
|
+
height: output.height,
|
|
78
|
+
mimeType: "image/png",
|
|
79
|
+
objectUrl: URL.createObjectURL(blob),
|
|
80
|
+
width: output.width
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function releaseCapture(image) {
|
|
87
|
+
if (image) URL.revokeObjectURL(image.objectUrl);
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/feedback/core/react-source.ts
|
|
91
|
+
const FIBER_KEY_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
|
|
92
|
+
const MEMO_TYPE = Symbol.for("react.memo");
|
|
93
|
+
const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
|
94
|
+
const MAX_STACK_DEPTH = 6;
|
|
95
|
+
const MAX_FIBER_WALK = 60;
|
|
96
|
+
const MAX_OWNER_WALK = 8;
|
|
97
|
+
const MIN_NAME_LENGTH = 4;
|
|
98
|
+
const CAPITALIZED = /^[A-Z]/;
|
|
99
|
+
const WORD_INSIDE = /[a-z]{2}/;
|
|
100
|
+
const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
|
|
101
|
+
const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
|
|
102
|
+
const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
|
|
103
|
+
const NON_SOURCE_FRAME = /node_modules|\/_next\/|\/chunks?\/|\/\.vite\/|\/dist\/|\/build\/|hot-update/;
|
|
104
|
+
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
|
|
105
|
+
const SOURCE_ROOT_MARKERS = [
|
|
106
|
+
"src",
|
|
107
|
+
"app",
|
|
108
|
+
"pages",
|
|
109
|
+
"components",
|
|
110
|
+
"features",
|
|
111
|
+
"lib"
|
|
112
|
+
];
|
|
113
|
+
function isRecord(value) {
|
|
114
|
+
return typeof value === "object" && value !== null;
|
|
115
|
+
}
|
|
116
|
+
/** React attaches the fiber under a randomly suffixed key on the host node. */
|
|
117
|
+
function getFiberFromNode(node) {
|
|
118
|
+
for (const key of Object.getOwnPropertyNames(node)) for (const prefix of FIBER_KEY_PREFIXES) if (key.startsWith(prefix)) return Reflect.get(node, key) ?? null;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function readName(value) {
|
|
122
|
+
if (typeof value !== "function" && !isRecord(value)) return null;
|
|
123
|
+
const displayName = Reflect.get(value, "displayName");
|
|
124
|
+
if (typeof displayName === "string" && displayName) return displayName;
|
|
125
|
+
const name = Reflect.get(value, "name");
|
|
126
|
+
return typeof name === "string" && name ? name : null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
|
|
130
|
+
* owning component actively misleads an agent, so only real words get through.
|
|
131
|
+
*/
|
|
132
|
+
function isMeaningfulName(name) {
|
|
133
|
+
if (name.includes("/") || name.includes("_")) return true;
|
|
134
|
+
return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
|
|
135
|
+
}
|
|
136
|
+
function componentName(type, depth = 0) {
|
|
137
|
+
if (typeof type === "string" || type === null || depth > 3) return null;
|
|
138
|
+
if (isRecord(type)) {
|
|
139
|
+
const marker = Reflect.get(type, "$$typeof");
|
|
140
|
+
if (marker === MEMO_TYPE) return componentName(Reflect.get(type, "type"), depth + 1);
|
|
141
|
+
if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
|
|
142
|
+
}
|
|
143
|
+
const name = readName(type);
|
|
144
|
+
return name && isMeaningfulName(name) ? name : null;
|
|
145
|
+
}
|
|
146
|
+
/** Component names owning the fiber, innermost first. */
|
|
147
|
+
function resolveComponentStack(fiber) {
|
|
148
|
+
const stack = [];
|
|
149
|
+
let current = fiber;
|
|
150
|
+
let steps = 0;
|
|
151
|
+
while (isRecord(current) && steps < MAX_FIBER_WALK) {
|
|
152
|
+
const name = componentName(Reflect.get(current, "type")) ?? componentName(Reflect.get(current, "elementType"));
|
|
153
|
+
if (name && stack.at(-1) !== name) {
|
|
154
|
+
stack.push(name);
|
|
155
|
+
if (stack.length >= MAX_STACK_DEPTH) break;
|
|
156
|
+
}
|
|
157
|
+
current = Reflect.get(current, "return");
|
|
158
|
+
steps++;
|
|
159
|
+
}
|
|
160
|
+
return stack;
|
|
161
|
+
}
|
|
162
|
+
function shortenSourcePath(raw) {
|
|
163
|
+
const path = raw.replace(WEBPACK_PREFIX, "").replace(ORIGIN_PREFIX, "").split("?")[0];
|
|
164
|
+
if (!path) return raw;
|
|
165
|
+
for (const marker of SOURCE_ROOT_MARKERS) {
|
|
166
|
+
if (path.startsWith(`${marker}/`)) return path;
|
|
167
|
+
const index = path.lastIndexOf(`/${marker}/`);
|
|
168
|
+
if (index !== -1) return path.slice(index + 1);
|
|
169
|
+
}
|
|
170
|
+
return path.split("/").filter(Boolean).slice(-3).join("/");
|
|
171
|
+
}
|
|
172
|
+
function formatLocation(fileName, line, column) {
|
|
173
|
+
const base = `${shortenSourcePath(fileName)}:${line}`;
|
|
174
|
+
return column === void 0 ? base : `${base}:${column}`;
|
|
175
|
+
}
|
|
176
|
+
function fromDebugSource(source) {
|
|
177
|
+
if (!isRecord(source)) return;
|
|
178
|
+
const fileName = Reflect.get(source, "fileName");
|
|
179
|
+
const lineNumber = Reflect.get(source, "lineNumber");
|
|
180
|
+
if (typeof fileName !== "string" || typeof lineNumber !== "number") return;
|
|
181
|
+
const columnNumber = Reflect.get(source, "columnNumber");
|
|
182
|
+
return formatLocation(fileName, lineNumber, typeof columnNumber === "number" ? columnNumber : void 0);
|
|
183
|
+
}
|
|
184
|
+
function fromDebugStack(stack) {
|
|
185
|
+
const raw = isRecord(stack) ? Reflect.get(stack, "stack") : stack;
|
|
186
|
+
if (typeof raw !== "string") return;
|
|
187
|
+
for (const line of raw.split("\n")) {
|
|
188
|
+
const match = STACK_LOCATION.exec(line);
|
|
189
|
+
const file = match?.[1];
|
|
190
|
+
if (!(match && file) || NON_SOURCE_FRAME.test(file)) continue;
|
|
191
|
+
const path = file.replace(ORIGIN_PREFIX, "").split("?")[0] ?? "";
|
|
192
|
+
if (!SOURCE_EXTENSION.test(path)) continue;
|
|
193
|
+
return formatLocation(path, Number(match[2]), Number(match[3]));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* `src/components/login-form.tsx:46:19` when the React build exposes it.
|
|
198
|
+
* Production bundles strip debug info — callers must treat this as optional.
|
|
199
|
+
*/
|
|
200
|
+
function resolveSourceLocation(fiber) {
|
|
201
|
+
let current = fiber;
|
|
202
|
+
let steps = 0;
|
|
203
|
+
while (isRecord(current) && steps < MAX_OWNER_WALK) {
|
|
204
|
+
const located = fromDebugSource(Reflect.get(current, "_debugSource")) ?? fromDebugStack(Reflect.get(current, "_debugStack"));
|
|
205
|
+
if (located) return located;
|
|
206
|
+
current = Reflect.get(current, "_debugOwner");
|
|
207
|
+
steps++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function inspectReactElement(element) {
|
|
211
|
+
const fiber = getFiberFromNode(element);
|
|
212
|
+
return {
|
|
213
|
+
componentStack: resolveComponentStack(fiber),
|
|
214
|
+
sourceLocation: resolveSourceLocation(fiber)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/feedback/core/redact.ts
|
|
219
|
+
/**
|
|
220
|
+
* The picked element's markup leaves the reporter's browser and ends up in an
|
|
221
|
+
* agent prompt, so it is scrubbed here: typed-in values, credentials and
|
|
222
|
+
* anything marked `data-reflet-redact` never make the trip.
|
|
223
|
+
*/
|
|
224
|
+
const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
|
|
225
|
+
const LONG_TOKEN = /\b[\w-]{28,}\b/g;
|
|
226
|
+
const OPAQUE_URI = /^(?:data|blob|javascript):/i;
|
|
227
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
228
|
+
const URL_QUERY = /[?#]/;
|
|
229
|
+
const REDACTED = "[redacted]";
|
|
230
|
+
const ELLIPSIS = "…";
|
|
231
|
+
const MAX_ATTRIBUTE_LENGTH = 120;
|
|
232
|
+
const MAX_TEXT_LENGTH = 200;
|
|
233
|
+
const MAX_CHILD_DEPTH = 3;
|
|
234
|
+
const MAX_CLONED_NODES = 400;
|
|
235
|
+
const DROPPED_TAGS = /* @__PURE__ */ new Set([
|
|
236
|
+
"script",
|
|
237
|
+
"style",
|
|
238
|
+
"noscript",
|
|
239
|
+
"template"
|
|
240
|
+
]);
|
|
241
|
+
const VALUE_HOLDERS = /* @__PURE__ */ new Set([
|
|
242
|
+
"input",
|
|
243
|
+
"textarea",
|
|
244
|
+
"select",
|
|
245
|
+
"option"
|
|
246
|
+
]);
|
|
247
|
+
const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
248
|
+
"src",
|
|
249
|
+
"srcset",
|
|
250
|
+
"href",
|
|
251
|
+
"poster",
|
|
252
|
+
"action",
|
|
253
|
+
"formaction"
|
|
254
|
+
]);
|
|
255
|
+
function truncate$1(value, max) {
|
|
256
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
|
|
257
|
+
}
|
|
258
|
+
/** Emails and token-shaped strings, wherever they appear in reported text. */
|
|
259
|
+
function maskSecrets(value) {
|
|
260
|
+
return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
|
|
261
|
+
}
|
|
262
|
+
function sanitizeUrl(value) {
|
|
263
|
+
if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
|
|
264
|
+
return value.split(URL_QUERY)[0] ?? value;
|
|
265
|
+
}
|
|
266
|
+
function sanitizeAttributes(element) {
|
|
267
|
+
const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
|
|
268
|
+
for (const { name, value } of Array.from(element.attributes)) {
|
|
269
|
+
if (holdsTypedValue && name === "value") {
|
|
270
|
+
element.removeAttribute(name);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
|
|
274
|
+
element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function collapse(element) {
|
|
278
|
+
element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
|
|
279
|
+
}
|
|
280
|
+
function sanitizeTree(element, depth) {
|
|
281
|
+
sanitizeAttributes(element);
|
|
282
|
+
if (element.hasAttribute("data-reflet-redact")) {
|
|
283
|
+
element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (element.localName === "svg") {
|
|
287
|
+
element.replaceChildren();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const child of Array.from(element.childNodes)) {
|
|
291
|
+
if (child.nodeType === Node.COMMENT_NODE) {
|
|
292
|
+
child.remove();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
296
|
+
child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!(child instanceof Element)) continue;
|
|
300
|
+
if (DROPPED_TAGS.has(child.localName)) {
|
|
301
|
+
child.remove();
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (depth >= MAX_CHILD_DEPTH) {
|
|
305
|
+
sanitizeAttributes(child);
|
|
306
|
+
collapse(child);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
sanitizeTree(child, depth + 1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
|
|
313
|
+
function sanitizeMarkup(element, maxLength) {
|
|
314
|
+
const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
|
|
315
|
+
const clone = element.cloneNode(isSmallEnough);
|
|
316
|
+
if (!(clone instanceof Element)) return "";
|
|
317
|
+
sanitizeTree(clone, 0);
|
|
318
|
+
if (!isSmallEnough) collapse(clone);
|
|
319
|
+
return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/feedback/core/element-selector.ts
|
|
323
|
+
const SAFE_ID = /^[A-Za-z][\w-]*$/;
|
|
324
|
+
const WHITESPACE = /\s+/g;
|
|
325
|
+
const MAX_LABEL_LENGTH = 80;
|
|
326
|
+
const MAX_HTML_LENGTH = 600;
|
|
327
|
+
const MAX_REGION_LENGTH = 160;
|
|
328
|
+
const MAX_HANDLE_VALUE_LENGTH = 80;
|
|
329
|
+
const MAX_DEPTH = 12;
|
|
330
|
+
const MIN_PATH_SEGMENTS = 2;
|
|
331
|
+
const MAX_SELECTOR_LENGTH = 600;
|
|
332
|
+
const MAX_SOURCE_LOCATION_LENGTH = 400;
|
|
333
|
+
const TEST_ATTRIBUTES = [
|
|
334
|
+
"data-testid",
|
|
335
|
+
"data-test-id",
|
|
336
|
+
"data-test",
|
|
337
|
+
"data-cy",
|
|
338
|
+
"data-qa"
|
|
339
|
+
];
|
|
340
|
+
const NAMING_ATTRIBUTES = ["name", "aria-label"];
|
|
341
|
+
const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
|
|
342
|
+
const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
|
|
343
|
+
function isUnique(selector) {
|
|
344
|
+
try {
|
|
345
|
+
return document.querySelectorAll(selector).length === 1;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function escapeAttributeValue(value) {
|
|
351
|
+
return value.replace(/["\\]/g, "\\$&");
|
|
352
|
+
}
|
|
353
|
+
function attributeHandle(element, attribute, qualifier) {
|
|
354
|
+
const value = element.getAttribute(attribute);
|
|
355
|
+
if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
|
|
356
|
+
const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
|
|
357
|
+
return isUnique(selector) ? selector : null;
|
|
358
|
+
}
|
|
359
|
+
/** Something an engineer would have written on purpose, unlike a positional path. */
|
|
360
|
+
function uniqueHandle(element) {
|
|
361
|
+
const id = element.getAttribute("id");
|
|
362
|
+
if (id && SAFE_ID.test(id) && isUnique(`#${id}`)) return `#${id}`;
|
|
363
|
+
for (const attribute of TEST_ATTRIBUTES) {
|
|
364
|
+
const handle = attributeHandle(element, attribute, "");
|
|
365
|
+
if (handle) return handle;
|
|
366
|
+
}
|
|
367
|
+
for (const attribute of NAMING_ATTRIBUTES) {
|
|
368
|
+
const handle = attributeHandle(element, attribute, element.localName);
|
|
369
|
+
if (handle) return handle;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
function positionalSegment(element) {
|
|
374
|
+
const tag = element.localName;
|
|
375
|
+
const parent = element.parentElement;
|
|
376
|
+
if (!parent) return tag;
|
|
377
|
+
const sameTag = Array.from(parent.children).filter((child) => child.localName === tag);
|
|
378
|
+
if (sameTag.length <= 1) return tag;
|
|
379
|
+
return `${tag}:nth-of-type(${sameTag.indexOf(element) + 1})`;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Shortest selector that still resolves back to `element` from the document.
|
|
383
|
+
* Grows the path one ancestor at a time and stops as soon as it is unique, so
|
|
384
|
+
* a stable handle anywhere up the tree replaces the whole positional chain.
|
|
385
|
+
* A bare tag is never enough: positional paths keep a parent for context.
|
|
386
|
+
*/
|
|
387
|
+
function buildSelector(element) {
|
|
388
|
+
if (element === document.documentElement) return "html";
|
|
389
|
+
if (element === document.body) return "body";
|
|
390
|
+
const handle = uniqueHandle(element);
|
|
391
|
+
if (handle) return handle;
|
|
392
|
+
const segments = [];
|
|
393
|
+
let current = element;
|
|
394
|
+
for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
|
|
395
|
+
const ancestorHandle = current === element ? null : uniqueHandle(current);
|
|
396
|
+
segments.unshift(ancestorHandle ?? positionalSegment(current));
|
|
397
|
+
const candidate = segments.join(" > ");
|
|
398
|
+
const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
|
|
399
|
+
if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
|
|
400
|
+
current = current.parentElement;
|
|
401
|
+
}
|
|
402
|
+
return segments.join(" > ");
|
|
403
|
+
}
|
|
404
|
+
function truncate(value, max) {
|
|
405
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
406
|
+
}
|
|
407
|
+
function collapseText(value) {
|
|
408
|
+
return value.replace(WHITESPACE, " ").trim();
|
|
409
|
+
}
|
|
410
|
+
function accessibleLabel(element) {
|
|
411
|
+
const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
|
|
412
|
+
if (explicit?.trim()) return explicit.trim();
|
|
413
|
+
return collapseText(element.textContent ?? "");
|
|
414
|
+
}
|
|
415
|
+
/** Short human label such as `button "Sign in"` or `div.card`. */
|
|
416
|
+
function describeElement(element) {
|
|
417
|
+
const tag = element.localName;
|
|
418
|
+
const label = maskSecrets(accessibleLabel(element));
|
|
419
|
+
if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
|
|
420
|
+
const firstClass = element.classList.item(0);
|
|
421
|
+
return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
|
|
422
|
+
}
|
|
423
|
+
function describeLandmark(element) {
|
|
424
|
+
const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
|
|
425
|
+
if (!landmark) return null;
|
|
426
|
+
const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
|
|
427
|
+
return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
|
|
428
|
+
}
|
|
429
|
+
function headingAbove(element) {
|
|
430
|
+
let closest = null;
|
|
431
|
+
for (const heading of document.querySelectorAll(HEADING_SELECTOR)) if ((heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) closest = heading;
|
|
432
|
+
return collapseText(closest?.textContent ?? "") || null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Where the element sits from a reader's point of view — `dialog "Members" ›
|
|
436
|
+
* Danger zone` says more to an agent than any DOM path can.
|
|
437
|
+
*/
|
|
438
|
+
function describeRegion(element) {
|
|
439
|
+
const landmark = describeLandmark(element);
|
|
440
|
+
const heading = headingAbove(element);
|
|
441
|
+
const parts = [landmark];
|
|
442
|
+
if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
|
|
443
|
+
const region = parts.filter((part) => part !== null).join(" › ");
|
|
444
|
+
return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
|
|
445
|
+
}
|
|
446
|
+
function getElementRect(element) {
|
|
447
|
+
const rect = element.getBoundingClientRect();
|
|
448
|
+
return {
|
|
449
|
+
height: Math.round(rect.height),
|
|
450
|
+
width: Math.round(rect.width),
|
|
451
|
+
x: Math.round(rect.x),
|
|
452
|
+
y: Math.round(rect.y)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** Everything an agent needs to find the picked element back in the codebase. */
|
|
456
|
+
function buildElementSelection(element) {
|
|
457
|
+
const { componentStack, sourceLocation } = inspectReactElement(element);
|
|
458
|
+
return {
|
|
459
|
+
componentStack,
|
|
460
|
+
html: sanitizeMarkup(element, MAX_HTML_LENGTH),
|
|
461
|
+
label: describeElement(element),
|
|
462
|
+
rect: getElementRect(element),
|
|
463
|
+
region: describeRegion(element),
|
|
464
|
+
selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
|
|
465
|
+
sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/feedback/core/page-context.ts
|
|
470
|
+
const BROWSER_PATTERNS = [
|
|
471
|
+
["Edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
|
|
472
|
+
["Opera", /OPR\/([\d.]+)/],
|
|
473
|
+
["Samsung Internet", /SamsungBrowser\/([\d.]+)/],
|
|
474
|
+
["Firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
|
|
475
|
+
["Chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
|
|
476
|
+
["Safari", /Version\/([\d.]+).*Safari/]
|
|
477
|
+
];
|
|
478
|
+
const OS_PATTERNS = [
|
|
479
|
+
["iOS", /iPhone|iPad|iPod/],
|
|
480
|
+
["Android", /Android/],
|
|
481
|
+
["Windows", /Windows NT/],
|
|
482
|
+
["macOS", /Mac OS X|Macintosh/],
|
|
483
|
+
["ChromeOS", /CrOS/],
|
|
484
|
+
["Linux", /Linux|X11/]
|
|
485
|
+
];
|
|
486
|
+
const MOBILE_HINT = /Mobile|iPhone|iPod/;
|
|
487
|
+
const TABLET_HINT = /iPad|Tablet/;
|
|
488
|
+
const TRAILING_ZERO = /\.0$/;
|
|
489
|
+
const ANDROID = /Android/;
|
|
490
|
+
function shortenVersion(version) {
|
|
491
|
+
return version.split(".").slice(0, 2).join(".").replace(TRAILING_ZERO, "");
|
|
492
|
+
}
|
|
493
|
+
function detectDevice(userAgent) {
|
|
494
|
+
if (TABLET_HINT.test(userAgent)) return "tablet";
|
|
495
|
+
if (MOBILE_HINT.test(userAgent)) return "mobile";
|
|
496
|
+
return ANDROID.test(userAgent) ? "tablet" : "desktop";
|
|
497
|
+
}
|
|
498
|
+
function parseUserAgent(userAgent) {
|
|
499
|
+
const info = { device: detectDevice(userAgent) };
|
|
500
|
+
for (const [name, pattern] of BROWSER_PATTERNS) {
|
|
501
|
+
const version = pattern.exec(userAgent)?.[1];
|
|
502
|
+
if (version) {
|
|
503
|
+
info.browser = `${name} ${shortenVersion(version)}`;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const [name, pattern] of OS_PATTERNS) if (pattern.test(userAgent)) {
|
|
508
|
+
info.os = name;
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
return info;
|
|
512
|
+
}
|
|
513
|
+
const MAX_PAGE_TITLE_LENGTH = 300;
|
|
514
|
+
const MAX_URL_LENGTH = 2e3;
|
|
515
|
+
const MAX_USER_AGENT_LENGTH = 600;
|
|
516
|
+
function clip(value, max) {
|
|
517
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
518
|
+
}
|
|
519
|
+
function optionalText(value, max) {
|
|
520
|
+
return value?.trim() ? clip(value, max) : void 0;
|
|
521
|
+
}
|
|
522
|
+
function resolveTimezone() {
|
|
523
|
+
try {
|
|
524
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
/** Everything about the page that a bug report needs and the DOM already knows. */
|
|
528
|
+
function collectPageContext(options) {
|
|
529
|
+
const userAgent = navigator.userAgent;
|
|
530
|
+
const agent = parseUserAgent(userAgent);
|
|
531
|
+
return {
|
|
532
|
+
browser: agent.browser,
|
|
533
|
+
device: agent.device,
|
|
534
|
+
language: navigator.language,
|
|
535
|
+
os: agent.os,
|
|
536
|
+
pageTitle: optionalText(document.title, MAX_PAGE_TITLE_LENGTH),
|
|
537
|
+
referrer: optionalText(document.referrer, MAX_URL_LENGTH),
|
|
538
|
+
screen: {
|
|
539
|
+
height: window.screen.height,
|
|
540
|
+
width: window.screen.width
|
|
541
|
+
},
|
|
542
|
+
sdkVersion: options?.sdkVersion,
|
|
543
|
+
timezone: resolveTimezone(),
|
|
544
|
+
url: clip(window.location.href, MAX_URL_LENGTH),
|
|
545
|
+
userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
|
|
546
|
+
viewport: {
|
|
547
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
548
|
+
height: window.innerHeight,
|
|
549
|
+
width: window.innerWidth
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
554
|
+
//#region src/feedback/types.ts
|
|
555
|
+
/** Reported with every submission. */
|
|
556
|
+
const SDK_VERSION = "0.3.0";
|
|
557
|
+
const DEFAULT_WIDGET_LABELS = {
|
|
558
|
+
annotateHint: "Draw on the screenshot to point at the problem.",
|
|
559
|
+
attachScreenshot: "Attach a screenshot",
|
|
560
|
+
back: "Back",
|
|
561
|
+
cancel: "Cancel",
|
|
562
|
+
categoryBug: "Bug",
|
|
563
|
+
categoryIdea: "Idea",
|
|
564
|
+
categoryQuestion: "Question",
|
|
565
|
+
clearAnnotations: "Clear drawing",
|
|
566
|
+
descriptionPlaceholder: "What happened? What did you expect?",
|
|
567
|
+
dismissForDays: "Hide for {days} days",
|
|
568
|
+
done: "Done",
|
|
569
|
+
emailLabel: "Email",
|
|
570
|
+
emailPlaceholder: "you@company.com",
|
|
571
|
+
errorGeneric: "Something went wrong. Please try again.",
|
|
572
|
+
errorTitleRequired: "Tell us a bit about it first.",
|
|
573
|
+
pickElement: "Point at an element",
|
|
574
|
+
pickElementHint: "Click the element you are talking about — cancel with",
|
|
575
|
+
recapture: "Retake",
|
|
576
|
+
removeScreenshot: "Remove",
|
|
577
|
+
retakeHint: "Screenshot of this page",
|
|
578
|
+
submit: "Send feedback",
|
|
579
|
+
successMessage: "We read every report. Thanks for taking the time.",
|
|
580
|
+
successTitle: "Feedback sent",
|
|
581
|
+
title: "Send feedback",
|
|
582
|
+
titlePlaceholder: "Short summary",
|
|
583
|
+
trigger: "Feedback",
|
|
584
|
+
undo: "Undo"
|
|
585
|
+
};
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/feedback/core/annotations.ts
|
|
588
|
+
const ANNOTATION_COLORS = [
|
|
589
|
+
"#ef4444",
|
|
590
|
+
"#f59e0b",
|
|
591
|
+
"#22c55e",
|
|
592
|
+
"#3b82f6",
|
|
593
|
+
"#111827"
|
|
594
|
+
];
|
|
595
|
+
const BOX_TOOLS = [
|
|
596
|
+
"rectangle",
|
|
597
|
+
"highlight",
|
|
598
|
+
"blur"
|
|
599
|
+
];
|
|
600
|
+
const MIN_GESTURE_LENGTH = 8;
|
|
601
|
+
const ARROW_HEAD_ANGLE = Math.PI / 7;
|
|
602
|
+
const DEFAULT_PATH_TOLERANCE = 2.5;
|
|
603
|
+
function normalizeRect(start, end) {
|
|
604
|
+
return {
|
|
605
|
+
height: Math.abs(end.y - start.y),
|
|
606
|
+
width: Math.abs(end.x - start.x),
|
|
607
|
+
x: Math.min(start.x, end.x),
|
|
608
|
+
y: Math.min(start.y, end.y)
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** The two barbs closing the arrow tip, both `size` px away from `end`. */
|
|
612
|
+
function arrowHead(start, end, size) {
|
|
613
|
+
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
|
614
|
+
if (Math.hypot(end.x - start.x, end.y - start.y) === 0) return [end, end];
|
|
615
|
+
const barb = (offset) => ({
|
|
616
|
+
x: end.x - size * Math.cos(angle + offset),
|
|
617
|
+
y: end.y - size * Math.sin(angle + offset)
|
|
618
|
+
});
|
|
619
|
+
return [barb(-ARROW_HEAD_ANGLE), barb(ARROW_HEAD_ANGLE)];
|
|
620
|
+
}
|
|
621
|
+
/** Drops points a user cannot perceive, keeping the payload small. */
|
|
622
|
+
function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
|
|
623
|
+
if (points.length < 3) return points;
|
|
624
|
+
const [first, ...rest] = points;
|
|
625
|
+
if (!first) return points;
|
|
626
|
+
const kept = [first];
|
|
627
|
+
let anchor = first;
|
|
628
|
+
for (const point of rest.slice(0, -1)) if (Math.hypot(point.x - anchor.x, point.y - anchor.y) >= tolerance) {
|
|
629
|
+
kept.push(point);
|
|
630
|
+
anchor = point;
|
|
631
|
+
}
|
|
632
|
+
const last = points.at(-1);
|
|
633
|
+
if (last) kept.push(last);
|
|
634
|
+
return kept;
|
|
635
|
+
}
|
|
636
|
+
function isDegenerate(annotation) {
|
|
637
|
+
if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
|
|
638
|
+
const { start, end } = annotation;
|
|
639
|
+
return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
|
|
640
|
+
}
|
|
641
|
+
function toWire(annotation) {
|
|
642
|
+
const { color, start, end, tool } = annotation;
|
|
643
|
+
if (tool === "pen") return {
|
|
644
|
+
color,
|
|
645
|
+
points: simplifyPath(annotation.points ?? []),
|
|
646
|
+
type: tool,
|
|
647
|
+
x: start.x,
|
|
648
|
+
y: start.y
|
|
649
|
+
};
|
|
650
|
+
if (BOX_TOOLS.includes(tool)) {
|
|
651
|
+
const rect = normalizeRect(start, end);
|
|
652
|
+
return {
|
|
653
|
+
color,
|
|
654
|
+
height: rect.height,
|
|
655
|
+
type: tool,
|
|
656
|
+
width: rect.width,
|
|
657
|
+
x: rect.x,
|
|
658
|
+
y: rect.y
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
color,
|
|
663
|
+
endX: end.x,
|
|
664
|
+
endY: end.y,
|
|
665
|
+
type: tool,
|
|
666
|
+
x: start.x,
|
|
667
|
+
y: start.y
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/** Serializes to the shape the Reflet API stores next to the screenshot. */
|
|
671
|
+
function toWireAnnotations(annotations) {
|
|
672
|
+
return annotations.filter((item) => !isDegenerate(item)).map(toWire);
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/feedback/core/annotation-renderer.ts
|
|
676
|
+
const STROKE_WIDTH = 3;
|
|
677
|
+
const ARROW_HEAD_SIZE = 16;
|
|
678
|
+
const HIGHLIGHT_ALPHA = .3;
|
|
679
|
+
const REDACTION_FILL = "#111827";
|
|
680
|
+
const PIXEL_BLOCK = 10;
|
|
681
|
+
function scalePoint(point, scale) {
|
|
682
|
+
return {
|
|
683
|
+
x: point.x * scale,
|
|
684
|
+
y: point.y * scale
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function scaleRect(rect, scale) {
|
|
688
|
+
return {
|
|
689
|
+
height: rect.height * scale,
|
|
690
|
+
width: rect.width * scale,
|
|
691
|
+
x: rect.x * scale,
|
|
692
|
+
y: rect.y * scale
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function isCanvasElement(value) {
|
|
696
|
+
return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Downsamples the region and paints it back enlarged, so redacted pixels are
|
|
700
|
+
* unrecoverable. Falls back to an opaque block wherever 2d canvases are absent.
|
|
701
|
+
*/
|
|
702
|
+
function redact(context, rect) {
|
|
703
|
+
const source = context.canvas;
|
|
704
|
+
const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
|
|
705
|
+
const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
|
|
706
|
+
if (isCanvasElement(source)) {
|
|
707
|
+
const offscreen = document.createElement("canvas");
|
|
708
|
+
offscreen.width = width;
|
|
709
|
+
offscreen.height = height;
|
|
710
|
+
const offscreenContext = offscreen.getContext("2d");
|
|
711
|
+
if (offscreenContext) {
|
|
712
|
+
offscreenContext.imageSmoothingEnabled = true;
|
|
713
|
+
offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
|
|
714
|
+
context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
context.fillStyle = REDACTION_FILL;
|
|
719
|
+
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
720
|
+
}
|
|
721
|
+
function drawArrow(context, start, end, scale) {
|
|
722
|
+
context.beginPath();
|
|
723
|
+
context.moveTo(start.x, start.y);
|
|
724
|
+
context.lineTo(end.x, end.y);
|
|
725
|
+
context.stroke();
|
|
726
|
+
const [left, right] = arrowHead(start, end, ARROW_HEAD_SIZE * scale);
|
|
727
|
+
context.beginPath();
|
|
728
|
+
context.moveTo(end.x, end.y);
|
|
729
|
+
context.lineTo(left.x, left.y);
|
|
730
|
+
context.lineTo(right.x, right.y);
|
|
731
|
+
context.closePath();
|
|
732
|
+
context.fill();
|
|
733
|
+
}
|
|
734
|
+
function drawPen(context, points, scale) {
|
|
735
|
+
const [first, ...rest] = points;
|
|
736
|
+
if (!first) return;
|
|
737
|
+
const head = scalePoint(first, scale);
|
|
738
|
+
context.beginPath();
|
|
739
|
+
context.moveTo(head.x, head.y);
|
|
740
|
+
for (const point of rest) {
|
|
741
|
+
const scaled = scalePoint(point, scale);
|
|
742
|
+
context.lineTo(scaled.x, scaled.y);
|
|
743
|
+
}
|
|
744
|
+
context.stroke();
|
|
745
|
+
}
|
|
746
|
+
function drawAnnotation(context, annotation, scale) {
|
|
747
|
+
const { color, tool } = annotation;
|
|
748
|
+
const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
|
|
749
|
+
context.save();
|
|
750
|
+
context.lineWidth = STROKE_WIDTH * scale;
|
|
751
|
+
context.lineCap = "round";
|
|
752
|
+
context.lineJoin = "round";
|
|
753
|
+
context.strokeStyle = color;
|
|
754
|
+
context.fillStyle = color;
|
|
755
|
+
switch (tool) {
|
|
756
|
+
case "pen":
|
|
757
|
+
drawPen(context, annotation.points ?? [], scale);
|
|
758
|
+
break;
|
|
759
|
+
case "arrow":
|
|
760
|
+
drawArrow(context, scalePoint(annotation.start, scale), scalePoint(annotation.end, scale), scale);
|
|
761
|
+
break;
|
|
762
|
+
case "rectangle":
|
|
763
|
+
context.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
764
|
+
break;
|
|
765
|
+
case "highlight":
|
|
766
|
+
context.globalAlpha = HIGHLIGHT_ALPHA;
|
|
767
|
+
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
768
|
+
break;
|
|
769
|
+
default: redact(context, rect);
|
|
770
|
+
}
|
|
771
|
+
context.restore();
|
|
772
|
+
}
|
|
773
|
+
function drawAnnotations(context, annotations, options) {
|
|
774
|
+
const scale = options?.scale ?? 1;
|
|
775
|
+
for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
|
|
776
|
+
}
|
|
777
|
+
function loadImage(url) {
|
|
778
|
+
return new Promise((resolve, reject) => {
|
|
779
|
+
const image = new Image();
|
|
780
|
+
image.onload = () => resolve(image);
|
|
781
|
+
image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
|
|
782
|
+
image.src = url;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
function toBlob(canvas) {
|
|
786
|
+
return new Promise((resolve) => {
|
|
787
|
+
canvas.toBlob(resolve, "image/png");
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Burns the annotations into a copy of the capture so reviewers and agents see
|
|
792
|
+
* the drawing without needing to replay the vector data.
|
|
793
|
+
*/
|
|
794
|
+
async function renderAnnotatedImage(source, annotations) {
|
|
795
|
+
if (annotations.length === 0) return null;
|
|
796
|
+
const canvas = document.createElement("canvas");
|
|
797
|
+
canvas.width = source.width;
|
|
798
|
+
canvas.height = source.height;
|
|
799
|
+
const context = canvas.getContext("2d");
|
|
800
|
+
if (!context) return null;
|
|
801
|
+
const image = await loadImage(source.objectUrl);
|
|
802
|
+
context.drawImage(image, 0, 0, source.width, source.height);
|
|
803
|
+
drawAnnotations(context, annotations);
|
|
804
|
+
const blob = await toBlob(canvas);
|
|
805
|
+
if (!blob) return null;
|
|
806
|
+
return {
|
|
807
|
+
blob,
|
|
808
|
+
height: source.height,
|
|
809
|
+
mimeType: "image/png",
|
|
810
|
+
objectUrl: URL.createObjectURL(blob),
|
|
811
|
+
width: source.width
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/feedback/ui/icons.tsx
|
|
816
|
+
function Svg({ children, className, size = 16 }) {
|
|
817
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
818
|
+
"aria-hidden": "true",
|
|
819
|
+
className,
|
|
820
|
+
fill: "none",
|
|
821
|
+
height: size,
|
|
822
|
+
stroke: "currentColor",
|
|
823
|
+
strokeLinecap: "round",
|
|
824
|
+
strokeLinejoin: "round",
|
|
825
|
+
strokeWidth: "1.8",
|
|
826
|
+
viewBox: "0 0 24 24",
|
|
827
|
+
width: size,
|
|
828
|
+
children
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function ChatIcon({ className }) {
|
|
832
|
+
return /* @__PURE__ */ jsx(Svg, {
|
|
833
|
+
className,
|
|
834
|
+
size: 15,
|
|
835
|
+
children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function CloseIcon({ className }) {
|
|
839
|
+
return /* @__PURE__ */ jsx(Svg, {
|
|
840
|
+
className,
|
|
841
|
+
children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
function BugIcon() {
|
|
845
|
+
return /* @__PURE__ */ jsx(Svg, {
|
|
846
|
+
size: 14,
|
|
847
|
+
children: /* @__PURE__ */ jsx("path", { d: "M8 6a4 4 0 0 1 8 0M6 10h12v4a6 6 0 0 1-12 0zM3 12h3M18 12h3M4.5 6.5 7 8M19.5 6.5 17 8M4.5 17.5 7 16M19.5 17.5 17 16" })
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
function IdeaIcon() {
|
|
851
|
+
return /* @__PURE__ */ jsx(Svg, {
|
|
852
|
+
size: 14,
|
|
853
|
+
children: /* @__PURE__ */ jsx("path", { d: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.5.4.8.9.9 1.5l.1.7h5.2l.1-.7c.1-.6.4-1.1.9-1.5A6 6 0 0 0 12 3" })
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
function QuestionIcon() {
|
|
857
|
+
return /* @__PURE__ */ jsxs(Svg, {
|
|
858
|
+
size: 14,
|
|
859
|
+
children: [/* @__PURE__ */ jsx("circle", {
|
|
860
|
+
cx: "12",
|
|
861
|
+
cy: "12",
|
|
862
|
+
r: "9"
|
|
863
|
+
}), /* @__PURE__ */ jsx("path", { d: "M9.6 9.4a2.5 2.5 0 1 1 3.3 2.4c-.6.2-.9.8-.9 1.4v.3M12 17h.01" })]
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function CameraIcon() {
|
|
867
|
+
return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2l1.2-2h8.2l1.2 2h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" }), /* @__PURE__ */ jsx("circle", {
|
|
868
|
+
cx: "12",
|
|
869
|
+
cy: "13",
|
|
870
|
+
r: "3.2"
|
|
871
|
+
})] });
|
|
872
|
+
}
|
|
873
|
+
function PencilIcon() {
|
|
874
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M16.5 4.5a2.1 2.1 0 0 1 3 3L8 19l-4 1 1-4z" }) });
|
|
875
|
+
}
|
|
876
|
+
function TargetIcon() {
|
|
877
|
+
return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
|
|
878
|
+
cx: "12",
|
|
879
|
+
cy: "12",
|
|
880
|
+
r: "5"
|
|
881
|
+
})] });
|
|
882
|
+
}
|
|
883
|
+
function TrashIcon() {
|
|
884
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
|
|
885
|
+
}
|
|
886
|
+
function ArrowIcon() {
|
|
887
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
|
|
888
|
+
}
|
|
889
|
+
function SquareIcon() {
|
|
890
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
|
|
891
|
+
height: "13",
|
|
892
|
+
rx: "1.5",
|
|
893
|
+
width: "16",
|
|
894
|
+
x: "4",
|
|
895
|
+
y: "5.5"
|
|
896
|
+
}) });
|
|
897
|
+
}
|
|
898
|
+
function HighlightIcon() {
|
|
899
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 15h16M6 5h12v7H6z" }) });
|
|
900
|
+
}
|
|
901
|
+
function BlurIcon() {
|
|
902
|
+
return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3c3.5 4 5.5 6.6 5.5 9.2A5.5 5.5 0 0 1 6.5 12.2C6.5 9.6 8.5 7 12 3" }), /* @__PURE__ */ jsx("path", { d: "M9 13h.01M12 15h.01M15 12h.01" })] });
|
|
903
|
+
}
|
|
904
|
+
function UndoIcon() {
|
|
905
|
+
return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
|
|
906
|
+
}
|
|
907
|
+
function CheckIcon({ size = 28 }) {
|
|
908
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
909
|
+
"aria-hidden": "true",
|
|
910
|
+
fill: "none",
|
|
911
|
+
height: size,
|
|
912
|
+
stroke: "currentColor",
|
|
913
|
+
strokeLinecap: "round",
|
|
914
|
+
strokeLinejoin: "round",
|
|
915
|
+
strokeWidth: "2.4",
|
|
916
|
+
viewBox: "0 0 24 24",
|
|
917
|
+
width: size,
|
|
918
|
+
children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/feedback/ui/annotator.tsx
|
|
923
|
+
const TOOLS = [
|
|
924
|
+
{
|
|
925
|
+
icon: PencilIcon,
|
|
926
|
+
id: "pen",
|
|
927
|
+
label: "Draw"
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
icon: ArrowIcon,
|
|
931
|
+
id: "arrow",
|
|
932
|
+
label: "Arrow"
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
icon: SquareIcon,
|
|
936
|
+
id: "rectangle",
|
|
937
|
+
label: "Box"
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
icon: HighlightIcon,
|
|
941
|
+
id: "highlight",
|
|
942
|
+
label: "Highlight"
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
icon: BlurIcon,
|
|
946
|
+
id: "blur",
|
|
947
|
+
label: "Hide"
|
|
948
|
+
}
|
|
949
|
+
];
|
|
950
|
+
function toImagePoint(canvas, clientX, clientY) {
|
|
951
|
+
const bounds = canvas.getBoundingClientRect();
|
|
952
|
+
return {
|
|
953
|
+
x: (clientX - bounds.left) / bounds.width * canvas.width,
|
|
954
|
+
y: (clientY - bounds.top) / bounds.height * canvas.height
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function Annotator({ annotations, capture, labels, onChange, onDone }) {
|
|
958
|
+
const canvasRef = useRef(null);
|
|
959
|
+
const nextId = useRef(0);
|
|
960
|
+
const [tool, setTool] = useState("pen");
|
|
961
|
+
const [color, setColor] = useState(ANNOTATION_COLORS[0]);
|
|
962
|
+
const [draft, setDraft] = useState(null);
|
|
963
|
+
const [base, setBase] = useState(null);
|
|
964
|
+
useEffect(() => {
|
|
965
|
+
let active = true;
|
|
966
|
+
const image = new Image();
|
|
967
|
+
image.onload = () => {
|
|
968
|
+
if (active) setBase(image);
|
|
969
|
+
};
|
|
970
|
+
image.src = capture.objectUrl;
|
|
971
|
+
return () => {
|
|
972
|
+
active = false;
|
|
973
|
+
setBase(null);
|
|
974
|
+
};
|
|
975
|
+
}, [capture.objectUrl]);
|
|
976
|
+
useEffect(() => {
|
|
977
|
+
const canvas = canvasRef.current;
|
|
978
|
+
const context = canvas?.getContext("2d");
|
|
979
|
+
if (!(canvas && context)) return;
|
|
980
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
981
|
+
if (base) context.drawImage(base, 0, 0, canvas.width, canvas.height);
|
|
982
|
+
drawAnnotations(context, draft ? [...annotations, draft] : annotations);
|
|
983
|
+
}, [
|
|
984
|
+
annotations,
|
|
985
|
+
base,
|
|
986
|
+
draft
|
|
987
|
+
]);
|
|
988
|
+
const onPointerDown = (event) => {
|
|
989
|
+
const canvas = canvasRef.current;
|
|
990
|
+
if (!canvas) return;
|
|
991
|
+
canvas.setPointerCapture(event.pointerId);
|
|
992
|
+
const point = toImagePoint(canvas, event.clientX, event.clientY);
|
|
993
|
+
nextId.current += 1;
|
|
994
|
+
setDraft({
|
|
995
|
+
color,
|
|
996
|
+
end: point,
|
|
997
|
+
id: `a${nextId.current}`,
|
|
998
|
+
points: tool === "pen" ? [point] : void 0,
|
|
999
|
+
start: point,
|
|
1000
|
+
tool
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
const onPointerMove = (event) => {
|
|
1004
|
+
const canvas = canvasRef.current;
|
|
1005
|
+
if (!(canvas && draft)) return;
|
|
1006
|
+
const point = toImagePoint(canvas, event.clientX, event.clientY);
|
|
1007
|
+
setDraft(draft.tool === "pen" ? {
|
|
1008
|
+
...draft,
|
|
1009
|
+
end: point,
|
|
1010
|
+
points: [...draft.points ?? [], point]
|
|
1011
|
+
} : {
|
|
1012
|
+
...draft,
|
|
1013
|
+
end: point
|
|
1014
|
+
});
|
|
1015
|
+
};
|
|
1016
|
+
const onPointerUp = () => {
|
|
1017
|
+
if (!draft) return;
|
|
1018
|
+
if (!isDegenerate(draft)) onChange([...annotations, draft]);
|
|
1019
|
+
setDraft(null);
|
|
1020
|
+
};
|
|
1021
|
+
useEffect(() => {
|
|
1022
|
+
const onKeyDown = (event) => {
|
|
1023
|
+
if (event.key === "Escape") {
|
|
1024
|
+
event.preventDefault();
|
|
1025
|
+
onDone();
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1029
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
1030
|
+
}, [onDone]);
|
|
1031
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1032
|
+
className: "overlay",
|
|
1033
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1034
|
+
className: "editor",
|
|
1035
|
+
children: /* @__PURE__ */ jsx("canvas", {
|
|
1036
|
+
height: capture.height,
|
|
1037
|
+
onPointerCancel: onPointerUp,
|
|
1038
|
+
onPointerDown,
|
|
1039
|
+
onPointerMove,
|
|
1040
|
+
onPointerUp,
|
|
1041
|
+
ref: canvasRef,
|
|
1042
|
+
width: capture.width
|
|
1043
|
+
})
|
|
1044
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1045
|
+
className: "toolbar",
|
|
1046
|
+
children: [
|
|
1047
|
+
/* @__PURE__ */ jsx("div", {
|
|
1048
|
+
className: "group",
|
|
1049
|
+
children: TOOLS.map(({ icon: Icon, id, label }) => /* @__PURE__ */ jsx("button", {
|
|
1050
|
+
"aria-label": label,
|
|
1051
|
+
"aria-pressed": tool === id,
|
|
1052
|
+
className: "tool",
|
|
1053
|
+
onClick: () => setTool(id),
|
|
1054
|
+
title: label,
|
|
1055
|
+
type: "button",
|
|
1056
|
+
children: /* @__PURE__ */ jsx(Icon, {})
|
|
1057
|
+
}, id))
|
|
1058
|
+
}),
|
|
1059
|
+
/* @__PURE__ */ jsx("div", {
|
|
1060
|
+
className: "group",
|
|
1061
|
+
children: ANNOTATION_COLORS.map((value) => /* @__PURE__ */ jsx("button", {
|
|
1062
|
+
"aria-label": `Color ${value}`,
|
|
1063
|
+
"aria-pressed": color === value,
|
|
1064
|
+
className: "swatch",
|
|
1065
|
+
onClick: () => setColor(value),
|
|
1066
|
+
style: { background: value },
|
|
1067
|
+
type: "button"
|
|
1068
|
+
}, value))
|
|
1069
|
+
}),
|
|
1070
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1071
|
+
className: "group",
|
|
1072
|
+
children: [
|
|
1073
|
+
/* @__PURE__ */ jsx("button", {
|
|
1074
|
+
"aria-label": labels.undo,
|
|
1075
|
+
className: "tool",
|
|
1076
|
+
disabled: annotations.length === 0,
|
|
1077
|
+
onClick: () => onChange(annotations.slice(0, -1)),
|
|
1078
|
+
title: labels.undo,
|
|
1079
|
+
type: "button",
|
|
1080
|
+
children: /* @__PURE__ */ jsx(UndoIcon, {})
|
|
1081
|
+
}),
|
|
1082
|
+
/* @__PURE__ */ jsx("button", {
|
|
1083
|
+
"aria-label": labels.clearAnnotations,
|
|
1084
|
+
className: "tool",
|
|
1085
|
+
disabled: annotations.length === 0,
|
|
1086
|
+
onClick: () => onChange([]),
|
|
1087
|
+
title: labels.clearAnnotations,
|
|
1088
|
+
type: "button",
|
|
1089
|
+
children: /* @__PURE__ */ jsx(TrashIcon, {})
|
|
1090
|
+
}),
|
|
1091
|
+
/* @__PURE__ */ jsx("button", {
|
|
1092
|
+
className: "done-btn",
|
|
1093
|
+
onClick: onDone,
|
|
1094
|
+
type: "button",
|
|
1095
|
+
children: labels.done
|
|
1096
|
+
})
|
|
1097
|
+
]
|
|
1098
|
+
})
|
|
1099
|
+
]
|
|
1100
|
+
})]
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
//#endregion
|
|
1104
|
+
//#region src/feedback/ui/launcher.tsx
|
|
1105
|
+
/**
|
|
1106
|
+
* Never changes size between states — the panel opening must not move the
|
|
1107
|
+
* button out from under the pointer.
|
|
1108
|
+
*/
|
|
1109
|
+
function Launcher({ isOpen, label, onClick }) {
|
|
1110
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
1111
|
+
"aria-expanded": isOpen,
|
|
1112
|
+
className: "launcher",
|
|
1113
|
+
"data-open": isOpen,
|
|
1114
|
+
onClick,
|
|
1115
|
+
type: "button",
|
|
1116
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
1117
|
+
className: "launcher-icon",
|
|
1118
|
+
children: [/* @__PURE__ */ jsx(ChatIcon, { className: "on-open" }), /* @__PURE__ */ jsx(CloseIcon, { className: "on-close" })]
|
|
1119
|
+
}), label]
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/feedback/ui/panel.tsx
|
|
1124
|
+
const CATEGORY_META = {
|
|
1125
|
+
bug: {
|
|
1126
|
+
icon: BugIcon,
|
|
1127
|
+
labelKey: "categoryBug"
|
|
1128
|
+
},
|
|
1129
|
+
idea: {
|
|
1130
|
+
icon: IdeaIcon,
|
|
1131
|
+
labelKey: "categoryIdea"
|
|
1132
|
+
},
|
|
1133
|
+
question: {
|
|
1134
|
+
icon: QuestionIcon,
|
|
1135
|
+
labelKey: "categoryQuestion"
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
function Attachment({ labels, state }) {
|
|
1139
|
+
if (state.isCapturing) return /* @__PURE__ */ jsxs("div", {
|
|
1140
|
+
className: "card",
|
|
1141
|
+
children: [/* @__PURE__ */ jsx("div", { className: "thumb" }), /* @__PURE__ */ jsxs("div", {
|
|
1142
|
+
className: "meta",
|
|
1143
|
+
children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsx("span", { children: "…" })]
|
|
1144
|
+
})]
|
|
1145
|
+
});
|
|
1146
|
+
if (!state.capture) return /* @__PURE__ */ jsxs("button", {
|
|
1147
|
+
className: "ghost-btn",
|
|
1148
|
+
onClick: () => state.takeCapture(),
|
|
1149
|
+
type: "button",
|
|
1150
|
+
children: [/* @__PURE__ */ jsx(CameraIcon, {}), labels.attachScreenshot]
|
|
1151
|
+
});
|
|
1152
|
+
const annotationCount = state.annotations.length;
|
|
1153
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1154
|
+
className: "card",
|
|
1155
|
+
children: [
|
|
1156
|
+
/* @__PURE__ */ jsx("div", {
|
|
1157
|
+
className: "thumb",
|
|
1158
|
+
children: /* @__PURE__ */ jsx("img", {
|
|
1159
|
+
alt: "",
|
|
1160
|
+
height: state.capture.height,
|
|
1161
|
+
src: state.capture.objectUrl,
|
|
1162
|
+
width: state.capture.width
|
|
1163
|
+
})
|
|
1164
|
+
}),
|
|
1165
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1166
|
+
className: "meta",
|
|
1167
|
+
children: [/* @__PURE__ */ jsx("strong", {
|
|
1168
|
+
className: "truncate",
|
|
1169
|
+
children: labels.retakeHint
|
|
1170
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
1171
|
+
className: "truncate",
|
|
1172
|
+
children: [
|
|
1173
|
+
state.capture.width,
|
|
1174
|
+
"×",
|
|
1175
|
+
state.capture.height,
|
|
1176
|
+
annotationCount > 0 ? ` · ${annotationCount} marks` : ""
|
|
1177
|
+
]
|
|
1178
|
+
})]
|
|
1179
|
+
}),
|
|
1180
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1181
|
+
className: "actions",
|
|
1182
|
+
children: [
|
|
1183
|
+
/* @__PURE__ */ jsx("button", {
|
|
1184
|
+
"aria-label": labels.annotateHint,
|
|
1185
|
+
className: "icon-btn",
|
|
1186
|
+
onClick: () => state.setStep("annotate"),
|
|
1187
|
+
title: labels.annotateHint,
|
|
1188
|
+
type: "button",
|
|
1189
|
+
children: /* @__PURE__ */ jsx(PencilIcon, {})
|
|
1190
|
+
}),
|
|
1191
|
+
/* @__PURE__ */ jsx("button", {
|
|
1192
|
+
"aria-label": labels.recapture,
|
|
1193
|
+
className: "icon-btn",
|
|
1194
|
+
onClick: () => state.takeCapture(),
|
|
1195
|
+
title: labels.recapture,
|
|
1196
|
+
type: "button",
|
|
1197
|
+
children: /* @__PURE__ */ jsx(CameraIcon, {})
|
|
1198
|
+
}),
|
|
1199
|
+
/* @__PURE__ */ jsx("button", {
|
|
1200
|
+
"aria-label": labels.removeScreenshot,
|
|
1201
|
+
className: "icon-btn",
|
|
1202
|
+
onClick: state.removeCapture,
|
|
1203
|
+
title: labels.removeScreenshot,
|
|
1204
|
+
type: "button",
|
|
1205
|
+
children: /* @__PURE__ */ jsx(TrashIcon, {})
|
|
1206
|
+
})
|
|
1207
|
+
]
|
|
1208
|
+
})
|
|
1209
|
+
]
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
/** Shows the reporter exactly what will be sent about the element they picked. */
|
|
1213
|
+
function Selection({ labels, state }) {
|
|
1214
|
+
const { elementCapture, selection } = state;
|
|
1215
|
+
if (!selection) return /* @__PURE__ */ jsxs("button", {
|
|
1216
|
+
className: "ghost-btn",
|
|
1217
|
+
onClick: () => state.setStep("picking"),
|
|
1218
|
+
type: "button",
|
|
1219
|
+
children: [/* @__PURE__ */ jsx(TargetIcon, {}), labels.pickElement]
|
|
1220
|
+
});
|
|
1221
|
+
const component = selection.componentStack[0];
|
|
1222
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1223
|
+
className: "card",
|
|
1224
|
+
children: [
|
|
1225
|
+
/* @__PURE__ */ jsx("div", {
|
|
1226
|
+
className: "thumb contain",
|
|
1227
|
+
children: elementCapture && /* @__PURE__ */ jsx("img", {
|
|
1228
|
+
alt: "",
|
|
1229
|
+
height: elementCapture.height,
|
|
1230
|
+
src: elementCapture.objectUrl,
|
|
1231
|
+
width: elementCapture.width
|
|
1232
|
+
})
|
|
1233
|
+
}),
|
|
1234
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1235
|
+
className: "meta",
|
|
1236
|
+
children: [/* @__PURE__ */ jsx("strong", {
|
|
1237
|
+
className: "truncate",
|
|
1238
|
+
children: component ? `<${component}>` : selection.label
|
|
1239
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1240
|
+
className: "truncate",
|
|
1241
|
+
children: selection.region ?? selection.sourceLocation ?? selection.selector
|
|
1242
|
+
})]
|
|
1243
|
+
}),
|
|
1244
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1245
|
+
className: "actions",
|
|
1246
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1247
|
+
"aria-label": labels.pickElement,
|
|
1248
|
+
className: "icon-btn",
|
|
1249
|
+
onClick: () => state.setStep("picking"),
|
|
1250
|
+
title: labels.pickElement,
|
|
1251
|
+
type: "button",
|
|
1252
|
+
children: /* @__PURE__ */ jsx(TargetIcon, {})
|
|
1253
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1254
|
+
"aria-label": labels.cancel,
|
|
1255
|
+
className: "icon-btn",
|
|
1256
|
+
onClick: state.clearSelection,
|
|
1257
|
+
title: labels.cancel,
|
|
1258
|
+
type: "button",
|
|
1259
|
+
children: /* @__PURE__ */ jsx(CloseIcon, {})
|
|
1260
|
+
})]
|
|
1261
|
+
})
|
|
1262
|
+
]
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
function FeedbackPanel({ categories, labels, state }) {
|
|
1266
|
+
const messageRef = useRef(null);
|
|
1267
|
+
useEffect(() => {
|
|
1268
|
+
messageRef.current?.focus();
|
|
1269
|
+
}, []);
|
|
1270
|
+
if (state.step === "success") return /* @__PURE__ */ jsx("section", {
|
|
1271
|
+
"aria-live": "polite",
|
|
1272
|
+
className: "panel",
|
|
1273
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1274
|
+
className: "done",
|
|
1275
|
+
children: [
|
|
1276
|
+
/* @__PURE__ */ jsx(CheckIcon, {}),
|
|
1277
|
+
/* @__PURE__ */ jsx("h2", { children: labels.successTitle }),
|
|
1278
|
+
/* @__PURE__ */ jsx("p", { children: labels.successMessage })
|
|
1279
|
+
]
|
|
1280
|
+
})
|
|
1281
|
+
});
|
|
1282
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1283
|
+
"aria-label": labels.title,
|
|
1284
|
+
className: "panel",
|
|
1285
|
+
children: [
|
|
1286
|
+
/* @__PURE__ */ jsxs("header", {
|
|
1287
|
+
className: "head",
|
|
1288
|
+
children: [
|
|
1289
|
+
/* @__PURE__ */ jsx("h2", { children: labels.title }),
|
|
1290
|
+
/* @__PURE__ */ jsx("span", { className: "spacer" }),
|
|
1291
|
+
/* @__PURE__ */ jsx("button", {
|
|
1292
|
+
"aria-label": labels.cancel,
|
|
1293
|
+
className: "icon-btn",
|
|
1294
|
+
onClick: state.close,
|
|
1295
|
+
type: "button",
|
|
1296
|
+
children: /* @__PURE__ */ jsx(CloseIcon, {})
|
|
1297
|
+
})
|
|
1298
|
+
]
|
|
1299
|
+
}),
|
|
1300
|
+
/* @__PURE__ */ jsxs("form", {
|
|
1301
|
+
className: "body",
|
|
1302
|
+
onSubmit: (event) => {
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
state.submit();
|
|
1305
|
+
},
|
|
1306
|
+
children: [
|
|
1307
|
+
categories.length > 1 && /* @__PURE__ */ jsx("fieldset", {
|
|
1308
|
+
className: "segmented",
|
|
1309
|
+
children: categories.map((value) => {
|
|
1310
|
+
const { icon: Icon, labelKey } = CATEGORY_META[value];
|
|
1311
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
1312
|
+
"aria-pressed": state.category === value,
|
|
1313
|
+
onClick: () => state.setCategory(value),
|
|
1314
|
+
type: "button",
|
|
1315
|
+
children: [/* @__PURE__ */ jsx(Icon, {}), labels[labelKey]]
|
|
1316
|
+
}, value);
|
|
1317
|
+
})
|
|
1318
|
+
}),
|
|
1319
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
1320
|
+
"aria-label": labels.descriptionPlaceholder,
|
|
1321
|
+
maxLength: 4e3,
|
|
1322
|
+
onChange: (event) => state.setMessage(event.target.value),
|
|
1323
|
+
placeholder: labels.descriptionPlaceholder,
|
|
1324
|
+
ref: messageRef,
|
|
1325
|
+
value: state.message
|
|
1326
|
+
}),
|
|
1327
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1328
|
+
className: "attachments",
|
|
1329
|
+
children: [/* @__PURE__ */ jsx(Attachment, {
|
|
1330
|
+
labels,
|
|
1331
|
+
state
|
|
1332
|
+
}), /* @__PURE__ */ jsx(Selection, {
|
|
1333
|
+
labels,
|
|
1334
|
+
state
|
|
1335
|
+
})]
|
|
1336
|
+
}),
|
|
1337
|
+
state.isAnonymous && /* @__PURE__ */ jsx("input", {
|
|
1338
|
+
"aria-label": labels.emailLabel,
|
|
1339
|
+
autoComplete: "email",
|
|
1340
|
+
onChange: (event) => state.setEmail(event.target.value),
|
|
1341
|
+
placeholder: labels.emailPlaceholder,
|
|
1342
|
+
type: "email",
|
|
1343
|
+
value: state.email
|
|
1344
|
+
}),
|
|
1345
|
+
/* @__PURE__ */ jsx("div", {
|
|
1346
|
+
"aria-hidden": "true",
|
|
1347
|
+
className: "hp",
|
|
1348
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
1349
|
+
autoComplete: "off",
|
|
1350
|
+
name: "company-website",
|
|
1351
|
+
onChange: (event) => state.setHoneypot(event.target.value),
|
|
1352
|
+
tabIndex: -1,
|
|
1353
|
+
value: state.honeypot
|
|
1354
|
+
})
|
|
1355
|
+
}),
|
|
1356
|
+
state.error && /* @__PURE__ */ jsx("p", {
|
|
1357
|
+
className: "error",
|
|
1358
|
+
children: state.error
|
|
1359
|
+
}),
|
|
1360
|
+
/* @__PURE__ */ jsx("button", {
|
|
1361
|
+
className: "submit",
|
|
1362
|
+
disabled: state.isSubmitting || !state.message.trim(),
|
|
1363
|
+
type: "submit",
|
|
1364
|
+
children: state.isSubmitting ? /* @__PURE__ */ jsx("span", { className: "spinner" }) : labels.submit
|
|
1365
|
+
})
|
|
1366
|
+
]
|
|
1367
|
+
}),
|
|
1368
|
+
/* @__PURE__ */ jsxs("footer", {
|
|
1369
|
+
className: "footer",
|
|
1370
|
+
"data-dismissible": state.canDismiss,
|
|
1371
|
+
children: [state.canDismiss && /* @__PURE__ */ jsx("button", {
|
|
1372
|
+
className: "dismiss-btn",
|
|
1373
|
+
onClick: state.dismiss,
|
|
1374
|
+
type: "button",
|
|
1375
|
+
children: labels.dismissForDays.replace("{days}", String(state.dismissForDays))
|
|
1376
|
+
}), /* @__PURE__ */ jsxs("span", { children: [
|
|
1377
|
+
"Powered by",
|
|
1378
|
+
" ",
|
|
1379
|
+
/* @__PURE__ */ jsx("a", {
|
|
1380
|
+
href: "https://reflet.app",
|
|
1381
|
+
rel: "noopener noreferrer",
|
|
1382
|
+
target: "_blank",
|
|
1383
|
+
children: "Reflet"
|
|
1384
|
+
})
|
|
1385
|
+
] })]
|
|
1386
|
+
})
|
|
1387
|
+
]
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
//#endregion
|
|
1391
|
+
//#region src/feedback/ui/picker.tsx
|
|
1392
|
+
const LABEL_HEIGHT = 24;
|
|
1393
|
+
const LABEL_GAP = 6;
|
|
1394
|
+
function isWidgetOwned(element) {
|
|
1395
|
+
return element.closest(`[${WIDGET_MARKER}]`) !== null;
|
|
1396
|
+
}
|
|
1397
|
+
function elementUnder(x, y) {
|
|
1398
|
+
const element = document.elementFromPoint(x, y);
|
|
1399
|
+
if (!element || isWidgetOwned(element)) return null;
|
|
1400
|
+
return element;
|
|
1401
|
+
}
|
|
1402
|
+
function describeTarget(element) {
|
|
1403
|
+
return {
|
|
1404
|
+
componentName: resolveComponentStack(getFiberFromNode(element))[0],
|
|
1405
|
+
element,
|
|
1406
|
+
label: describeElement(element),
|
|
1407
|
+
rect: element.getBoundingClientRect(),
|
|
1408
|
+
region: describeRegion(element)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Points at any element in the page the way a coding agent needs it: the React
|
|
1413
|
+
* component that owns it plus the page region it lives in.
|
|
1414
|
+
*/
|
|
1415
|
+
function ElementPicker({ hint, onCancel, onPick }) {
|
|
1416
|
+
const [target, setTarget] = useState(null);
|
|
1417
|
+
useEffect(() => {
|
|
1418
|
+
const swallow = (event) => {
|
|
1419
|
+
event.preventDefault();
|
|
1420
|
+
event.stopPropagation();
|
|
1421
|
+
};
|
|
1422
|
+
const onMove = (event) => {
|
|
1423
|
+
const element = elementUnder(event.clientX, event.clientY);
|
|
1424
|
+
if (!element) {
|
|
1425
|
+
setTarget(null);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
setTarget((current) => current?.element === element ? {
|
|
1429
|
+
...current,
|
|
1430
|
+
rect: element.getBoundingClientRect()
|
|
1431
|
+
} : describeTarget(element));
|
|
1432
|
+
};
|
|
1433
|
+
const onClick = (event) => {
|
|
1434
|
+
swallow(event);
|
|
1435
|
+
const element = elementUnder(event.clientX, event.clientY);
|
|
1436
|
+
if (element) onPick(element);
|
|
1437
|
+
};
|
|
1438
|
+
const onKeyDown = (event) => {
|
|
1439
|
+
if (event.key === "Escape") {
|
|
1440
|
+
swallow(event);
|
|
1441
|
+
onCancel();
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
document.addEventListener("pointermove", onMove, true);
|
|
1445
|
+
document.addEventListener("pointerdown", swallow, true);
|
|
1446
|
+
document.addEventListener("mousedown", swallow, true);
|
|
1447
|
+
document.addEventListener("mouseup", swallow, true);
|
|
1448
|
+
document.addEventListener("click", onClick, true);
|
|
1449
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
1450
|
+
const previousCursor = document.body.style.cursor;
|
|
1451
|
+
document.body.style.cursor = "crosshair";
|
|
1452
|
+
return () => {
|
|
1453
|
+
document.removeEventListener("pointermove", onMove, true);
|
|
1454
|
+
document.removeEventListener("pointerdown", swallow, true);
|
|
1455
|
+
document.removeEventListener("mousedown", swallow, true);
|
|
1456
|
+
document.removeEventListener("mouseup", swallow, true);
|
|
1457
|
+
document.removeEventListener("click", onClick, true);
|
|
1458
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
1459
|
+
document.body.style.cursor = previousCursor;
|
|
1460
|
+
};
|
|
1461
|
+
}, [onCancel, onPick]);
|
|
1462
|
+
const rect = target?.rect;
|
|
1463
|
+
const labelAbove = rect ? rect.top > 30 : true;
|
|
1464
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
1465
|
+
rect && /* @__PURE__ */ jsx("div", {
|
|
1466
|
+
className: "picker-box",
|
|
1467
|
+
style: {
|
|
1468
|
+
height: rect.height,
|
|
1469
|
+
left: rect.left,
|
|
1470
|
+
top: rect.top,
|
|
1471
|
+
width: rect.width
|
|
1472
|
+
}
|
|
1473
|
+
}),
|
|
1474
|
+
rect && target && /* @__PURE__ */ jsxs("div", {
|
|
1475
|
+
className: "picker-label",
|
|
1476
|
+
style: {
|
|
1477
|
+
left: Math.max(4, rect.left),
|
|
1478
|
+
top: labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP
|
|
1479
|
+
},
|
|
1480
|
+
children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
|
|
1481
|
+
}),
|
|
1482
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1483
|
+
className: "picker-hint",
|
|
1484
|
+
children: [
|
|
1485
|
+
hint,
|
|
1486
|
+
" ",
|
|
1487
|
+
/* @__PURE__ */ jsx("kbd", { children: "Esc" })
|
|
1488
|
+
]
|
|
1489
|
+
})
|
|
1490
|
+
] });
|
|
1491
|
+
}
|
|
1492
|
+
//#endregion
|
|
1493
|
+
//#region src/feedback/ui/styles.ts
|
|
1494
|
+
/**
|
|
1495
|
+
* Styles for the feedback widget. Injected into a shadow root, so nothing here
|
|
1496
|
+
* leaks into the host app and nothing from the host app leaks in.
|
|
1497
|
+
*/
|
|
1498
|
+
const WIDGET_STYLES = `
|
|
1499
|
+
:host {
|
|
1500
|
+
--rf-bg: #ffffff;
|
|
1501
|
+
--rf-bg-subtle: rgb(55 53 47 / 4%);
|
|
1502
|
+
--rf-bg-hover: rgb(55 53 47 / 7%);
|
|
1503
|
+
--rf-border: rgb(55 53 47 / 9%);
|
|
1504
|
+
--rf-text: #37352f;
|
|
1505
|
+
--rf-text-muted: rgb(55 53 47 / 55%);
|
|
1506
|
+
--rf-primary: #37352f;
|
|
1507
|
+
--rf-primary-text: #ffffff;
|
|
1508
|
+
--rf-accent: #2383e2;
|
|
1509
|
+
--rf-danger: #d44c47;
|
|
1510
|
+
--rf-success: #448361;
|
|
1511
|
+
--rf-radius: 12px;
|
|
1512
|
+
--rf-radius-sm: 8px;
|
|
1513
|
+
--rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
|
|
1514
|
+
--rf-shadow-low: 0 0 0 1px rgb(15 15 15 / 4%), 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
|
|
1515
|
+
--rf-shadow: 0 0 0 1px rgb(15 15 15 / 4%), 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
|
|
1516
|
+
--rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-accent) 20%, transparent);
|
|
1517
|
+
--rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
1518
|
+
color-scheme: light;
|
|
1519
|
+
all: initial;
|
|
1520
|
+
font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
:host([data-theme="dark"]) {
|
|
1524
|
+
--rf-bg: #202020;
|
|
1525
|
+
--rf-bg-subtle: rgb(255 255 255 / 5%);
|
|
1526
|
+
--rf-bg-hover: rgb(255 255 255 / 9%);
|
|
1527
|
+
--rf-border: rgb(255 255 255 / 9%);
|
|
1528
|
+
--rf-text: #ebeae8;
|
|
1529
|
+
--rf-text-muted: rgb(255 255 255 / 46%);
|
|
1530
|
+
--rf-primary: #f1efec;
|
|
1531
|
+
--rf-primary-text: #1c1c1b;
|
|
1532
|
+
--rf-accent: #529cca;
|
|
1533
|
+
--rf-danger: #eb5757;
|
|
1534
|
+
--rf-success: #4dab74;
|
|
1535
|
+
--rf-shadow-low: 0 0 0 1px rgb(255 255 255 / 6%), 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
|
|
1536
|
+
--rf-shadow: 0 0 0 1px rgb(255 255 255 / 7%), 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
|
|
1537
|
+
color-scheme: dark;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1541
|
+
|
|
1542
|
+
.root {
|
|
1543
|
+
position: fixed;
|
|
1544
|
+
z-index: 2147483000;
|
|
1545
|
+
display: flex;
|
|
1546
|
+
flex-direction: column;
|
|
1547
|
+
gap: 10px;
|
|
1548
|
+
font-family: inherit;
|
|
1549
|
+
font-size: 13.5px;
|
|
1550
|
+
line-height: 1.45;
|
|
1551
|
+
color: var(--rf-text);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
.root[data-position$="right"] { right: var(--rf-offset); align-items: flex-end; }
|
|
1555
|
+
.root[data-position$="left"] { left: var(--rf-offset); align-items: flex-start; }
|
|
1556
|
+
.root[data-position^="bottom"] { bottom: var(--rf-offset); }
|
|
1557
|
+
.root[data-position^="top"] { top: var(--rf-offset); flex-direction: column-reverse; }
|
|
1558
|
+
|
|
1559
|
+
button {
|
|
1560
|
+
font: inherit;
|
|
1561
|
+
color: inherit;
|
|
1562
|
+
margin: 0;
|
|
1563
|
+
cursor: pointer;
|
|
1564
|
+
border: 0;
|
|
1565
|
+
background: none;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
:focus-visible { outline: none; box-shadow: var(--rf-ring); }
|
|
1569
|
+
|
|
1570
|
+
.launcher {
|
|
1571
|
+
display: inline-flex;
|
|
1572
|
+
align-items: center;
|
|
1573
|
+
gap: 6px;
|
|
1574
|
+
height: 32px;
|
|
1575
|
+
padding: 0 11px;
|
|
1576
|
+
border-radius: var(--rf-radius-sm);
|
|
1577
|
+
background: var(--rf-primary);
|
|
1578
|
+
color: var(--rf-primary-text);
|
|
1579
|
+
font-size: 13px;
|
|
1580
|
+
font-weight: 500;
|
|
1581
|
+
letter-spacing: -0.005em;
|
|
1582
|
+
box-shadow: var(--rf-shadow-low);
|
|
1583
|
+
transition: transform 240ms var(--rf-ease), box-shadow 240ms var(--rf-ease), opacity 160ms var(--rf-ease);
|
|
1584
|
+
}
|
|
1585
|
+
.launcher:hover { transform: translateY(-1px); box-shadow: var(--rf-shadow); }
|
|
1586
|
+
.launcher:active { transform: translateY(0) scale(0.98); }
|
|
1587
|
+
|
|
1588
|
+
.launcher-icon { position: relative; width: 15px; height: 15px; flex: none; }
|
|
1589
|
+
.launcher-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 200ms var(--rf-ease), transform 320ms var(--rf-ease); }
|
|
1590
|
+
.launcher-icon .on-close { opacity: 0; transform: rotate(-90deg) scale(0.6); }
|
|
1591
|
+
.launcher[data-open="true"] .on-open { opacity: 0; transform: rotate(90deg) scale(0.6); }
|
|
1592
|
+
.launcher[data-open="true"] .on-close { opacity: 1; transform: none; }
|
|
1593
|
+
|
|
1594
|
+
.panel {
|
|
1595
|
+
width: min(352px, calc(100vw - 24px));
|
|
1596
|
+
max-height: min(600px, calc(100vh - 96px));
|
|
1597
|
+
display: flex;
|
|
1598
|
+
flex-direction: column;
|
|
1599
|
+
overflow: hidden;
|
|
1600
|
+
background: var(--rf-bg);
|
|
1601
|
+
border-radius: var(--rf-radius);
|
|
1602
|
+
box-shadow: var(--rf-shadow);
|
|
1603
|
+
animation: rf-in 220ms var(--rf-ease);
|
|
1604
|
+
}
|
|
1605
|
+
.root[data-position^="bottom"] .panel { transform-origin: bottom center; }
|
|
1606
|
+
.root[data-position^="top"] .panel { transform-origin: top center; animation-name: rf-in-top; }
|
|
1607
|
+
|
|
1608
|
+
@keyframes rf-in {
|
|
1609
|
+
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
|
1610
|
+
}
|
|
1611
|
+
@keyframes rf-in-top {
|
|
1612
|
+
from { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
|
1613
|
+
}
|
|
1614
|
+
@keyframes rf-fade { from { opacity: 0; } }
|
|
1615
|
+
|
|
1616
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1617
|
+
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
.head {
|
|
1621
|
+
display: flex;
|
|
1622
|
+
align-items: center;
|
|
1623
|
+
gap: 8px;
|
|
1624
|
+
padding: 12px 10px 4px 14px;
|
|
1625
|
+
}
|
|
1626
|
+
.head h2 { margin: 0; font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
|
|
1627
|
+
.head .spacer { flex: 1; }
|
|
1628
|
+
|
|
1629
|
+
.icon-btn {
|
|
1630
|
+
display: inline-flex;
|
|
1631
|
+
align-items: center;
|
|
1632
|
+
justify-content: center;
|
|
1633
|
+
width: 26px;
|
|
1634
|
+
height: 26px;
|
|
1635
|
+
flex: none;
|
|
1636
|
+
border-radius: 6px;
|
|
1637
|
+
color: var(--rf-text-muted);
|
|
1638
|
+
transition: background 120ms var(--rf-ease), color 120ms var(--rf-ease);
|
|
1639
|
+
}
|
|
1640
|
+
.icon-btn:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
|
|
1641
|
+
|
|
1642
|
+
.body { display: flex; flex-direction: column; gap: 8px; padding: 6px 14px 14px; overflow-y: auto; }
|
|
1643
|
+
|
|
1644
|
+
.segmented {
|
|
1645
|
+
border: 0;
|
|
1646
|
+
margin: 0;
|
|
1647
|
+
min-inline-size: 0;
|
|
1648
|
+
display: grid;
|
|
1649
|
+
grid-auto-flow: column;
|
|
1650
|
+
grid-auto-columns: 1fr;
|
|
1651
|
+
gap: 2px;
|
|
1652
|
+
padding: 2px;
|
|
1653
|
+
background: var(--rf-bg-subtle);
|
|
1654
|
+
border-radius: var(--rf-radius-sm);
|
|
1655
|
+
}
|
|
1656
|
+
.segmented button {
|
|
1657
|
+
display: flex;
|
|
1658
|
+
align-items: center;
|
|
1659
|
+
justify-content: center;
|
|
1660
|
+
gap: 5px;
|
|
1661
|
+
height: 26px;
|
|
1662
|
+
border-radius: 6px;
|
|
1663
|
+
font-size: 12.5px;
|
|
1664
|
+
font-weight: 500;
|
|
1665
|
+
color: var(--rf-text-muted);
|
|
1666
|
+
transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
|
|
1667
|
+
}
|
|
1668
|
+
.segmented button:hover { color: var(--rf-text); }
|
|
1669
|
+
.segmented button[aria-pressed="true"] {
|
|
1670
|
+
background: var(--rf-bg);
|
|
1671
|
+
color: var(--rf-text);
|
|
1672
|
+
box-shadow: var(--rf-shadow-low);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
textarea, input[type="email"] {
|
|
1676
|
+
width: 100%;
|
|
1677
|
+
padding: 8px 10px;
|
|
1678
|
+
font: inherit;
|
|
1679
|
+
color: var(--rf-text);
|
|
1680
|
+
background: var(--rf-bg);
|
|
1681
|
+
border: 1px solid var(--rf-border);
|
|
1682
|
+
border-radius: var(--rf-radius-sm);
|
|
1683
|
+
transition: border-color 160ms var(--rf-ease), box-shadow 160ms var(--rf-ease);
|
|
1684
|
+
}
|
|
1685
|
+
textarea { min-height: 84px; max-height: 220px; resize: none; }
|
|
1686
|
+
textarea::placeholder, input::placeholder { color: var(--rf-text-muted); }
|
|
1687
|
+
textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, var(--rf-accent) 55%, transparent); box-shadow: var(--rf-ring); }
|
|
1688
|
+
|
|
1689
|
+
.attachments { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
1690
|
+
|
|
1691
|
+
.card {
|
|
1692
|
+
flex: 1 0 100%;
|
|
1693
|
+
display: flex;
|
|
1694
|
+
align-items: center;
|
|
1695
|
+
gap: 10px;
|
|
1696
|
+
min-width: 0;
|
|
1697
|
+
padding: 6px;
|
|
1698
|
+
border-radius: 10px;
|
|
1699
|
+
background: var(--rf-bg-subtle);
|
|
1700
|
+
animation: rf-fade 200ms var(--rf-ease);
|
|
1701
|
+
}
|
|
1702
|
+
.thumb {
|
|
1703
|
+
position: relative;
|
|
1704
|
+
flex: 0 0 60px;
|
|
1705
|
+
height: 42px;
|
|
1706
|
+
overflow: hidden;
|
|
1707
|
+
border-radius: 6px;
|
|
1708
|
+
background: var(--rf-bg);
|
|
1709
|
+
box-shadow: var(--rf-shadow-inset);
|
|
1710
|
+
}
|
|
1711
|
+
.thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
|
|
1712
|
+
.thumb.contain img { object-fit: contain; object-position: center; }
|
|
1713
|
+
.card .meta { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
|
|
1714
|
+
.card .meta strong { font-size: 12.5px; font-weight: 550; }
|
|
1715
|
+
.card .meta span { font-size: 11.5px; color: var(--rf-text-muted); }
|
|
1716
|
+
.card .actions { display: flex; gap: 2px; margin-left: auto; align-items: center; }
|
|
1717
|
+
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1718
|
+
.card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
1719
|
+
|
|
1720
|
+
.ghost-btn {
|
|
1721
|
+
flex: 0 0 auto;
|
|
1722
|
+
display: inline-flex;
|
|
1723
|
+
align-items: center;
|
|
1724
|
+
gap: 6px;
|
|
1725
|
+
height: 30px;
|
|
1726
|
+
padding: 0 10px;
|
|
1727
|
+
font-size: 12.5px;
|
|
1728
|
+
font-weight: 500;
|
|
1729
|
+
color: var(--rf-text-muted);
|
|
1730
|
+
border-radius: var(--rf-radius-sm);
|
|
1731
|
+
box-shadow: var(--rf-shadow-inset);
|
|
1732
|
+
transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
|
|
1733
|
+
}
|
|
1734
|
+
.ghost-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
|
|
1735
|
+
|
|
1736
|
+
.submit {
|
|
1737
|
+
display: inline-flex;
|
|
1738
|
+
align-items: center;
|
|
1739
|
+
justify-content: center;
|
|
1740
|
+
gap: 8px;
|
|
1741
|
+
width: 100%;
|
|
1742
|
+
height: 32px;
|
|
1743
|
+
margin-top: 2px;
|
|
1744
|
+
font-size: 13px;
|
|
1745
|
+
font-weight: 550;
|
|
1746
|
+
color: var(--rf-primary-text);
|
|
1747
|
+
background: var(--rf-primary);
|
|
1748
|
+
border-radius: var(--rf-radius-sm);
|
|
1749
|
+
transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease);
|
|
1750
|
+
}
|
|
1751
|
+
.submit:hover:not(:disabled) { opacity: 0.88; }
|
|
1752
|
+
.submit:active:not(:disabled) { transform: scale(0.99); }
|
|
1753
|
+
.submit:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
1754
|
+
|
|
1755
|
+
.error { margin: 0; font-size: 12px; color: var(--rf-danger); }
|
|
1756
|
+
|
|
1757
|
+
.footer {
|
|
1758
|
+
min-height: 34px;
|
|
1759
|
+
padding: 0 14px;
|
|
1760
|
+
border-top: 1px solid var(--rf-border);
|
|
1761
|
+
display: flex;
|
|
1762
|
+
align-items: center;
|
|
1763
|
+
justify-content: center;
|
|
1764
|
+
gap: 12px;
|
|
1765
|
+
font-size: 11px;
|
|
1766
|
+
color: var(--rf-text-muted);
|
|
1767
|
+
}
|
|
1768
|
+
.footer[data-dismissible="true"] { justify-content: space-between; }
|
|
1769
|
+
.footer a { color: inherit; text-decoration: none; font-weight: 550; transition: color 120ms var(--rf-ease); }
|
|
1770
|
+
.footer a:hover { color: var(--rf-text); }
|
|
1771
|
+
.dismiss-btn {
|
|
1772
|
+
align-self: stretch;
|
|
1773
|
+
color: inherit;
|
|
1774
|
+
font-size: 11px;
|
|
1775
|
+
transition: color 120ms var(--rf-ease);
|
|
1776
|
+
}
|
|
1777
|
+
.dismiss-btn:hover { color: var(--rf-text); }
|
|
1778
|
+
|
|
1779
|
+
.done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 20px 34px; text-align: center; }
|
|
1780
|
+
.done svg { color: var(--rf-success); }
|
|
1781
|
+
.done h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
1782
|
+
.done p { margin: 0; font-size: 12.5px; color: var(--rf-text-muted); }
|
|
1783
|
+
|
|
1784
|
+
.spinner {
|
|
1785
|
+
width: 14px;
|
|
1786
|
+
height: 14px;
|
|
1787
|
+
border: 2px solid currentColor;
|
|
1788
|
+
border-right-color: transparent;
|
|
1789
|
+
border-radius: 50%;
|
|
1790
|
+
opacity: 0.6;
|
|
1791
|
+
animation: rf-spin 700ms linear infinite;
|
|
1792
|
+
}
|
|
1793
|
+
@keyframes rf-spin { to { transform: rotate(360deg); } }
|
|
1794
|
+
|
|
1795
|
+
.hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
1796
|
+
|
|
1797
|
+
.overlay {
|
|
1798
|
+
position: fixed;
|
|
1799
|
+
inset: 0;
|
|
1800
|
+
z-index: 2147483001;
|
|
1801
|
+
display: flex;
|
|
1802
|
+
flex-direction: column;
|
|
1803
|
+
background: rgb(15 15 15 / 76%);
|
|
1804
|
+
backdrop-filter: blur(3px);
|
|
1805
|
+
animation: rf-fade 180ms var(--rf-ease);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
.editor { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 24px 0; min-height: 0; }
|
|
1809
|
+
.editor canvas {
|
|
1810
|
+
max-width: 100%;
|
|
1811
|
+
max-height: 100%;
|
|
1812
|
+
border-radius: 10px;
|
|
1813
|
+
box-shadow: 0 20px 60px -16px rgb(0 0 0 / 70%);
|
|
1814
|
+
cursor: crosshair;
|
|
1815
|
+
touch-action: none;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
.toolbar {
|
|
1819
|
+
display: flex;
|
|
1820
|
+
align-items: center;
|
|
1821
|
+
justify-content: center;
|
|
1822
|
+
gap: 6px;
|
|
1823
|
+
flex-wrap: wrap;
|
|
1824
|
+
padding: 14px 16px calc(14px + env(safe-area-inset-bottom));
|
|
1825
|
+
}
|
|
1826
|
+
.toolbar .group {
|
|
1827
|
+
display: flex;
|
|
1828
|
+
align-items: center;
|
|
1829
|
+
gap: 2px;
|
|
1830
|
+
padding: 4px;
|
|
1831
|
+
background: var(--rf-bg);
|
|
1832
|
+
border-radius: 10px;
|
|
1833
|
+
box-shadow: var(--rf-shadow);
|
|
1834
|
+
}
|
|
1835
|
+
.tool {
|
|
1836
|
+
display: inline-flex;
|
|
1837
|
+
align-items: center;
|
|
1838
|
+
justify-content: center;
|
|
1839
|
+
width: 30px;
|
|
1840
|
+
height: 30px;
|
|
1841
|
+
border-radius: 7px;
|
|
1842
|
+
color: var(--rf-text-muted);
|
|
1843
|
+
transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
|
|
1844
|
+
}
|
|
1845
|
+
.tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
|
|
1846
|
+
.tool[aria-pressed="true"] { background: var(--rf-bg-hover); color: var(--rf-text); }
|
|
1847
|
+
.swatch {
|
|
1848
|
+
width: 20px;
|
|
1849
|
+
height: 20px;
|
|
1850
|
+
border-radius: 50%;
|
|
1851
|
+
border: 2px solid transparent;
|
|
1852
|
+
transition: transform 200ms var(--rf-ease), border-color 160ms var(--rf-ease);
|
|
1853
|
+
}
|
|
1854
|
+
.swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.15); }
|
|
1855
|
+
.toolbar .done-btn {
|
|
1856
|
+
height: 30px;
|
|
1857
|
+
padding: 0 13px;
|
|
1858
|
+
font-size: 13px;
|
|
1859
|
+
font-weight: 550;
|
|
1860
|
+
border-radius: 7px;
|
|
1861
|
+
color: var(--rf-primary-text);
|
|
1862
|
+
background: var(--rf-primary);
|
|
1863
|
+
transition: opacity 160ms var(--rf-ease);
|
|
1864
|
+
}
|
|
1865
|
+
.toolbar .done-btn:hover { opacity: 0.88; }
|
|
1866
|
+
|
|
1867
|
+
.picker-box {
|
|
1868
|
+
position: fixed;
|
|
1869
|
+
pointer-events: none;
|
|
1870
|
+
border-radius: 5px;
|
|
1871
|
+
background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
|
|
1872
|
+
box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
|
|
1873
|
+
transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease), width 130ms var(--rf-ease), height 130ms var(--rf-ease);
|
|
1874
|
+
}
|
|
1875
|
+
.picker-label {
|
|
1876
|
+
position: fixed;
|
|
1877
|
+
pointer-events: none;
|
|
1878
|
+
display: inline-flex;
|
|
1879
|
+
align-items: center;
|
|
1880
|
+
gap: 6px;
|
|
1881
|
+
max-width: min(420px, 90vw);
|
|
1882
|
+
height: 24px;
|
|
1883
|
+
padding: 0 8px;
|
|
1884
|
+
font-size: 11.5px;
|
|
1885
|
+
font-weight: 500;
|
|
1886
|
+
color: #ffffff;
|
|
1887
|
+
background: var(--rf-accent);
|
|
1888
|
+
border-radius: 6px;
|
|
1889
|
+
box-shadow: 0 4px 12px -3px rgb(15 15 15 / 35%);
|
|
1890
|
+
white-space: nowrap;
|
|
1891
|
+
overflow: hidden;
|
|
1892
|
+
transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease);
|
|
1893
|
+
}
|
|
1894
|
+
.picker-label strong { flex: none; font-weight: 600; }
|
|
1895
|
+
.picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
|
|
1896
|
+
.picker-hint {
|
|
1897
|
+
position: fixed;
|
|
1898
|
+
left: 50%;
|
|
1899
|
+
bottom: 24px;
|
|
1900
|
+
transform: translateX(-50%);
|
|
1901
|
+
display: flex;
|
|
1902
|
+
align-items: center;
|
|
1903
|
+
gap: 6px;
|
|
1904
|
+
padding: 7px 12px;
|
|
1905
|
+
font-size: 12.5px;
|
|
1906
|
+
color: var(--rf-text);
|
|
1907
|
+
background: var(--rf-bg);
|
|
1908
|
+
border-radius: 10px;
|
|
1909
|
+
box-shadow: var(--rf-shadow);
|
|
1910
|
+
animation: rf-fade 240ms var(--rf-ease);
|
|
1911
|
+
}
|
|
1912
|
+
.picker-hint kbd {
|
|
1913
|
+
padding: 1px 5px;
|
|
1914
|
+
font: inherit;
|
|
1915
|
+
font-size: 11px;
|
|
1916
|
+
color: var(--rf-text-muted);
|
|
1917
|
+
border-radius: 4px;
|
|
1918
|
+
background: var(--rf-bg-subtle);
|
|
1919
|
+
box-shadow: var(--rf-shadow-inset);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
@media (pointer: coarse) {
|
|
1923
|
+
.icon-btn, .tool, .swatch {
|
|
1924
|
+
width: 44px;
|
|
1925
|
+
height: 44px;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.launcher, .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
|
|
1929
|
+
min-height: 44px;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
.dismiss-btn, .footer a {
|
|
1933
|
+
display: inline-flex;
|
|
1934
|
+
min-height: 44px;
|
|
1935
|
+
align-items: center;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.footer a {
|
|
1939
|
+
min-width: 44px;
|
|
1940
|
+
justify-content: center;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
`;
|
|
1944
|
+
//#endregion
|
|
1945
|
+
//#region src/feedback/ui/shadow-portal.tsx
|
|
1946
|
+
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
1947
|
+
function useResolvedTheme(theme) {
|
|
1948
|
+
const [systemDark, setSystemDark] = useState(false);
|
|
1949
|
+
useEffect(() => {
|
|
1950
|
+
if (theme !== "auto" || typeof window.matchMedia !== "function") return;
|
|
1951
|
+
const query = window.matchMedia(DARK_QUERY);
|
|
1952
|
+
setSystemDark(query.matches);
|
|
1953
|
+
const onChange = (event) => setSystemDark(event.matches);
|
|
1954
|
+
query.addEventListener("change", onChange);
|
|
1955
|
+
return () => query.removeEventListener("change", onChange);
|
|
1956
|
+
}, [theme]);
|
|
1957
|
+
if (theme === "auto") return systemDark ? "dark" : "light";
|
|
1958
|
+
return theme;
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Hosts the widget in a shadow root: the app's CSS cannot reach the panel and
|
|
1962
|
+
* the panel's CSS cannot reach the app. Direct DOM work is unavoidable here —
|
|
1963
|
+
* React has no declarative way to create a shadow root.
|
|
1964
|
+
*/
|
|
1965
|
+
function ShadowPortal({ children, offset, primaryColor, theme }) {
|
|
1966
|
+
const [shadowRoot, setShadowRoot] = useState(null);
|
|
1967
|
+
const resolvedTheme = useResolvedTheme(theme);
|
|
1968
|
+
useEffect(() => {
|
|
1969
|
+
const host = document.createElement("div");
|
|
1970
|
+
host.setAttribute(WIDGET_MARKER, "");
|
|
1971
|
+
const root = host.attachShadow({ mode: "open" });
|
|
1972
|
+
const style = document.createElement("style");
|
|
1973
|
+
style.textContent = WIDGET_STYLES;
|
|
1974
|
+
root.append(style);
|
|
1975
|
+
document.body.append(host);
|
|
1976
|
+
setShadowRoot(root);
|
|
1977
|
+
return () => host.remove();
|
|
1978
|
+
}, []);
|
|
1979
|
+
useEffect(() => {
|
|
1980
|
+
const host = shadowRoot?.host;
|
|
1981
|
+
if (!(host instanceof HTMLElement)) return;
|
|
1982
|
+
host.setAttribute("data-theme", resolvedTheme);
|
|
1983
|
+
host.style.setProperty("--rf-offset", `${offset}px`);
|
|
1984
|
+
if (primaryColor) host.style.setProperty("--rf-primary", primaryColor);
|
|
1985
|
+
}, [
|
|
1986
|
+
shadowRoot,
|
|
1987
|
+
resolvedTheme,
|
|
1988
|
+
primaryColor,
|
|
1989
|
+
offset
|
|
1990
|
+
]);
|
|
1991
|
+
if (!shadowRoot) return null;
|
|
1992
|
+
return createPortal(children, shadowRoot);
|
|
1993
|
+
}
|
|
1994
|
+
//#endregion
|
|
1995
|
+
//#region src/feedback/core/console-recorder.ts
|
|
1996
|
+
const DEFAULT_LIMIT = 30;
|
|
1997
|
+
const MAX_MESSAGE_LENGTH = 1e3;
|
|
1998
|
+
const RECORDED_METHODS = ["error", "warn"];
|
|
1999
|
+
function serializeArgument(value) {
|
|
2000
|
+
if (typeof value === "string") return value;
|
|
2001
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
2002
|
+
if (value === null || value === void 0) return String(value);
|
|
2003
|
+
if (typeof value === "object") try {
|
|
2004
|
+
return JSON.stringify(value) ?? "[unserializable]";
|
|
2005
|
+
} catch {
|
|
2006
|
+
return "[unserializable]";
|
|
2007
|
+
}
|
|
2008
|
+
if (typeof value === "function") return "[function]";
|
|
2009
|
+
return String(value);
|
|
2010
|
+
}
|
|
2011
|
+
function formatMessage(args) {
|
|
2012
|
+
const message = args.map(serializeArgument).join(" ");
|
|
2013
|
+
return message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, 999)}…` : message;
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Buffers the console noise a user saw right before reporting a bug.
|
|
2017
|
+
* Wrappers delegate to the previous implementation, so nothing is swallowed.
|
|
2018
|
+
*/
|
|
2019
|
+
function startConsoleRecorder(options) {
|
|
2020
|
+
const limit = options?.limit ?? DEFAULT_LIMIT;
|
|
2021
|
+
const buffer = [];
|
|
2022
|
+
let active = true;
|
|
2023
|
+
const push = (level, message) => {
|
|
2024
|
+
if (!(active && message)) return;
|
|
2025
|
+
buffer.push({
|
|
2026
|
+
level,
|
|
2027
|
+
message,
|
|
2028
|
+
timestamp: Date.now()
|
|
2029
|
+
});
|
|
2030
|
+
if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
|
|
2031
|
+
};
|
|
2032
|
+
const originals = /* @__PURE__ */ new Map();
|
|
2033
|
+
const wrappers = /* @__PURE__ */ new Map();
|
|
2034
|
+
for (const method of RECORDED_METHODS) {
|
|
2035
|
+
const original = console[method];
|
|
2036
|
+
const wrapper = (...args) => {
|
|
2037
|
+
push(method, formatMessage(args));
|
|
2038
|
+
Reflect.apply(original, console, args);
|
|
2039
|
+
};
|
|
2040
|
+
originals.set(method, original);
|
|
2041
|
+
wrappers.set(method, wrapper);
|
|
2042
|
+
console[method] = wrapper;
|
|
2043
|
+
}
|
|
2044
|
+
const onError = (event) => {
|
|
2045
|
+
const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
|
|
2046
|
+
push("error", `${event.message}${where}`);
|
|
2047
|
+
};
|
|
2048
|
+
const onRejection = (event) => {
|
|
2049
|
+
push("error", `Unhandled rejection: ${serializeArgument(event.reason)}`);
|
|
2050
|
+
};
|
|
2051
|
+
window.addEventListener("error", onError);
|
|
2052
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
2053
|
+
return {
|
|
2054
|
+
events: () => buffer.slice(),
|
|
2055
|
+
stop: () => {
|
|
2056
|
+
if (!active) return;
|
|
2057
|
+
active = false;
|
|
2058
|
+
buffer.length = 0;
|
|
2059
|
+
for (const method of RECORDED_METHODS) {
|
|
2060
|
+
const original = originals.get(method);
|
|
2061
|
+
if (original && console[method] === wrappers.get(method)) console[method] = original;
|
|
2062
|
+
}
|
|
2063
|
+
window.removeEventListener("error", onError);
|
|
2064
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
//#endregion
|
|
2069
|
+
//#region src/feedback/core/submit.ts
|
|
2070
|
+
const MAX_TITLE_LENGTH = 100;
|
|
2071
|
+
const NEWLINE = /\r?\n/;
|
|
2072
|
+
/** First line of the message, cut on a word boundary so titles stay readable. */
|
|
2073
|
+
function deriveTitle(message) {
|
|
2074
|
+
const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
|
|
2075
|
+
if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
|
|
2076
|
+
const clipped = firstLine.slice(0, 99);
|
|
2077
|
+
const lastSpace = clipped.lastIndexOf(" ");
|
|
2078
|
+
return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
|
|
2079
|
+
}
|
|
2080
|
+
function buildDescription(params) {
|
|
2081
|
+
const { category, email, isAnonymous, message } = params;
|
|
2082
|
+
const label = category.charAt(0).toUpperCase() + category.slice(1);
|
|
2083
|
+
const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
|
|
2084
|
+
return `[${label}] ${message.trim()}${contact}`;
|
|
2085
|
+
}
|
|
2086
|
+
async function upload(transport, image) {
|
|
2087
|
+
const { uploadUrl } = await transport.getScreenshotUploadUrl();
|
|
2088
|
+
return await transport.uploadImage(uploadUrl, image);
|
|
2089
|
+
}
|
|
2090
|
+
async function attachScreenshot(transport, feedbackId, submission) {
|
|
2091
|
+
const { annotated, annotations, context, screenshot } = submission;
|
|
2092
|
+
if (!screenshot) return false;
|
|
2093
|
+
const storageId = await upload(transport, screenshot);
|
|
2094
|
+
const annotatedStorageId = annotated ? await upload(transport, annotated) : void 0;
|
|
2095
|
+
const wireAnnotations = toWireAnnotations(annotations);
|
|
2096
|
+
await transport.saveScreenshot({
|
|
2097
|
+
annotatedStorageId,
|
|
2098
|
+
annotations: wireAnnotations.length > 0 ? wireAnnotations : void 0,
|
|
2099
|
+
captureSource: "widget",
|
|
2100
|
+
feedbackId,
|
|
2101
|
+
filename: "screenshot.png",
|
|
2102
|
+
height: screenshot.height,
|
|
2103
|
+
mimeType: screenshot.mimeType,
|
|
2104
|
+
pageUrl: context.url,
|
|
2105
|
+
size: screenshot.blob.size,
|
|
2106
|
+
storageId,
|
|
2107
|
+
width: screenshot.width
|
|
2108
|
+
});
|
|
2109
|
+
return true;
|
|
2110
|
+
}
|
|
2111
|
+
async function attachElement(transport, feedbackId, submission) {
|
|
2112
|
+
const { context, element } = submission;
|
|
2113
|
+
if (!element) return;
|
|
2114
|
+
await transport.saveScreenshot({
|
|
2115
|
+
captureSource: "element",
|
|
2116
|
+
feedbackId,
|
|
2117
|
+
filename: "element.png",
|
|
2118
|
+
height: element.height,
|
|
2119
|
+
mimeType: element.mimeType,
|
|
2120
|
+
pageUrl: context.url,
|
|
2121
|
+
size: element.blob.size,
|
|
2122
|
+
storageId: await upload(transport, element),
|
|
2123
|
+
width: element.width
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Creates the feedback first, then attaches the capture. A failed upload is
|
|
2128
|
+
* reported back but never discards what the user wrote.
|
|
2129
|
+
*/
|
|
2130
|
+
async function submitWidgetFeedback(transport, submission) {
|
|
2131
|
+
const title = deriveTitle(submission.message);
|
|
2132
|
+
if (!title) throw new Error("Please describe your feedback before sending it.");
|
|
2133
|
+
const { feedbackId } = await transport.create({
|
|
2134
|
+
context: submission.context,
|
|
2135
|
+
description: buildDescription(submission),
|
|
2136
|
+
title
|
|
2137
|
+
});
|
|
2138
|
+
const screenshotSaved = await attachScreenshot(transport, feedbackId, submission).catch(() => false);
|
|
2139
|
+
await attachElement(transport, feedbackId, submission).catch(() => void 0);
|
|
2140
|
+
return {
|
|
2141
|
+
feedbackId,
|
|
2142
|
+
screenshotSaved
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
function isStorageResponse(value) {
|
|
2146
|
+
return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
|
|
2147
|
+
}
|
|
2148
|
+
function createFeedbackTransport(client) {
|
|
2149
|
+
return {
|
|
2150
|
+
create: (params) => client.create(params),
|
|
2151
|
+
getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
|
|
2152
|
+
saveScreenshot: (params) => client.saveScreenshot(params),
|
|
2153
|
+
uploadImage: async (uploadUrl, image) => {
|
|
2154
|
+
const response = await fetch(uploadUrl, {
|
|
2155
|
+
body: image.blob,
|
|
2156
|
+
headers: { "Content-Type": image.mimeType },
|
|
2157
|
+
method: "POST"
|
|
2158
|
+
});
|
|
2159
|
+
if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
|
|
2160
|
+
const payload = await response.json();
|
|
2161
|
+
if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
|
|
2162
|
+
return payload.storageId;
|
|
2163
|
+
}
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
//#endregion
|
|
2167
|
+
//#region src/feedback/ui/use-widget-state.ts
|
|
2168
|
+
const SUCCESS_CLOSE_DELAY = 2400;
|
|
2169
|
+
const ELEMENT_CAPTURE_MAX_WIDTH = 1200;
|
|
2170
|
+
const IS_APPLE = /Mac|iPhone|iPad/;
|
|
2171
|
+
const MILLISECONDS_PER_DAY = 864e5;
|
|
2172
|
+
const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
|
|
2173
|
+
function readDismissedUntil(key) {
|
|
2174
|
+
if (typeof window === "undefined") return 0;
|
|
2175
|
+
try {
|
|
2176
|
+
const value = Number.parseInt(window.localStorage.getItem(key) ?? "", 10);
|
|
2177
|
+
if (Number.isFinite(value) && value > Date.now()) return value;
|
|
2178
|
+
window.localStorage.removeItem(key);
|
|
2179
|
+
} catch {
|
|
2180
|
+
return 0;
|
|
2181
|
+
}
|
|
2182
|
+
return 0;
|
|
2183
|
+
}
|
|
2184
|
+
function writeDismissedUntil(key, until) {
|
|
2185
|
+
try {
|
|
2186
|
+
window.localStorage.setItem(key, String(until));
|
|
2187
|
+
} catch {}
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Matches shortcuts written as `alt+f` or `mod+shift+k`, where `mod` is the
|
|
2191
|
+
* platform's command key.
|
|
2192
|
+
*/
|
|
2193
|
+
function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
|
|
2194
|
+
const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
|
|
2195
|
+
const key = parts.at(-1);
|
|
2196
|
+
if (!key || event.key.toLowerCase() !== key) return false;
|
|
2197
|
+
const isApple = IS_APPLE.test(platform);
|
|
2198
|
+
const wantsMod = parts.includes("mod");
|
|
2199
|
+
const expected = {
|
|
2200
|
+
alt: parts.includes("alt") || parts.includes("option"),
|
|
2201
|
+
ctrl: parts.includes("ctrl") || wantsMod && !isApple,
|
|
2202
|
+
meta: parts.includes("meta") || parts.includes("cmd") || wantsMod && isApple,
|
|
2203
|
+
shift: parts.includes("shift")
|
|
2204
|
+
};
|
|
2205
|
+
return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
|
|
2206
|
+
}
|
|
2207
|
+
function highlightFor(selection, capture) {
|
|
2208
|
+
const scale = capture.width / window.innerWidth;
|
|
2209
|
+
const { rect } = selection;
|
|
2210
|
+
return {
|
|
2211
|
+
color: "#4f46e5",
|
|
2212
|
+
end: {
|
|
2213
|
+
x: (rect.x + rect.width) * scale,
|
|
2214
|
+
y: (rect.y + rect.height) * scale
|
|
2215
|
+
},
|
|
2216
|
+
id: `selection-${rect.x}-${rect.y}`,
|
|
2217
|
+
start: {
|
|
2218
|
+
x: rect.x * scale,
|
|
2219
|
+
y: rect.y * scale
|
|
2220
|
+
},
|
|
2221
|
+
tool: "rectangle"
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* A capture whose object URL is revoked the moment it is replaced or dropped —
|
|
2226
|
+
* holding one without this leaks the blob for the lifetime of the page.
|
|
2227
|
+
*/
|
|
2228
|
+
function useOwnedCapture() {
|
|
2229
|
+
const [image, setImage] = useState(null);
|
|
2230
|
+
const ref = useRef(null);
|
|
2231
|
+
useEffect(() => {
|
|
2232
|
+
ref.current = image;
|
|
2233
|
+
}, [image]);
|
|
2234
|
+
useEffect(() => () => releaseCapture(ref.current), []);
|
|
2235
|
+
return [image, useCallback((next) => {
|
|
2236
|
+
releaseCapture(ref.current);
|
|
2237
|
+
setImage(next);
|
|
2238
|
+
}, [])];
|
|
2239
|
+
}
|
|
2240
|
+
function useWidgetState(props) {
|
|
2241
|
+
const context = useContext(RefletContext);
|
|
2242
|
+
const publicKey = props.publicKey ?? context?.publicKey;
|
|
2243
|
+
const baseUrl = props.baseUrl ?? context?.baseUrl;
|
|
2244
|
+
const user = props.user ?? context?.user;
|
|
2245
|
+
const userToken = props.userToken ?? context?.userToken;
|
|
2246
|
+
const isAnonymous = !(user || userToken);
|
|
2247
|
+
const dismissForDays = props.dismissForDays !== void 0 && Number.isFinite(props.dismissForDays) && props.dismissForDays > 0 ? props.dismissForDays : null;
|
|
2248
|
+
const dismissalKey = `${DISMISSAL_STORAGE_PREFIX}:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`;
|
|
2249
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
2250
|
+
const [step, setStep] = useState("compose");
|
|
2251
|
+
const [category, setCategory] = useState(props.defaultCategory ?? "bug");
|
|
2252
|
+
const [message, setMessage] = useState("");
|
|
2253
|
+
const [email, setEmail] = useState("");
|
|
2254
|
+
const [honeypot, setHoneypot] = useState("");
|
|
2255
|
+
const [capture, replaceCapture] = useOwnedCapture();
|
|
2256
|
+
const [elementCapture, replaceElementCapture] = useOwnedCapture();
|
|
2257
|
+
const [annotations, setAnnotations] = useState([]);
|
|
2258
|
+
const [selection, setSelection] = useState(null);
|
|
2259
|
+
const [isCapturing, setIsCapturing] = useState(false);
|
|
2260
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
2261
|
+
const [error, setError] = useState(null);
|
|
2262
|
+
const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
|
|
2263
|
+
const captureRef = useRef(null);
|
|
2264
|
+
const pickRef = useRef(0);
|
|
2265
|
+
const consoleRef = useRef(null);
|
|
2266
|
+
useEffect(() => {
|
|
2267
|
+
captureRef.current = capture;
|
|
2268
|
+
}, [capture]);
|
|
2269
|
+
useEffect(() => {
|
|
2270
|
+
setDismissedUntil(readDismissedUntil(dismissalKey));
|
|
2271
|
+
}, [dismissalKey]);
|
|
2272
|
+
useEffect(() => {
|
|
2273
|
+
if (props.captureConsole === false) return;
|
|
2274
|
+
const recorder = startConsoleRecorder();
|
|
2275
|
+
consoleRef.current = recorder;
|
|
2276
|
+
return () => {
|
|
2277
|
+
consoleRef.current = null;
|
|
2278
|
+
recorder.stop();
|
|
2279
|
+
};
|
|
2280
|
+
}, [props.captureConsole]);
|
|
2281
|
+
const takeCapture = useCallback((element) => {
|
|
2282
|
+
setIsCapturing(true);
|
|
2283
|
+
captureViewport({ element }).catch(() => null).then((image) => {
|
|
2284
|
+
setIsCapturing(false);
|
|
2285
|
+
replaceCapture(image);
|
|
2286
|
+
});
|
|
2287
|
+
}, [replaceCapture]);
|
|
2288
|
+
const reset = useCallback(() => {
|
|
2289
|
+
pickRef.current++;
|
|
2290
|
+
replaceCapture(null);
|
|
2291
|
+
replaceElementCapture(null);
|
|
2292
|
+
setAnnotations([]);
|
|
2293
|
+
setSelection(null);
|
|
2294
|
+
setMessage("");
|
|
2295
|
+
setEmail("");
|
|
2296
|
+
setHoneypot("");
|
|
2297
|
+
setError(null);
|
|
2298
|
+
setStep("compose");
|
|
2299
|
+
setCategory(props.defaultCategory ?? "bug");
|
|
2300
|
+
}, [
|
|
2301
|
+
props.defaultCategory,
|
|
2302
|
+
replaceCapture,
|
|
2303
|
+
replaceElementCapture
|
|
2304
|
+
]);
|
|
2305
|
+
const close = useCallback(() => {
|
|
2306
|
+
setIsOpen(false);
|
|
2307
|
+
reset();
|
|
2308
|
+
props.onClose?.();
|
|
2309
|
+
}, [reset, props.onClose]);
|
|
2310
|
+
const dismiss = useCallback(() => {
|
|
2311
|
+
if (dismissForDays === null) return;
|
|
2312
|
+
const until = Date.now() + dismissForDays * MILLISECONDS_PER_DAY;
|
|
2313
|
+
writeDismissedUntil(dismissalKey, until);
|
|
2314
|
+
setIsOpen(false);
|
|
2315
|
+
reset();
|
|
2316
|
+
setDismissedUntil(until);
|
|
2317
|
+
props.onDismiss?.({ until });
|
|
2318
|
+
}, [
|
|
2319
|
+
dismissForDays,
|
|
2320
|
+
dismissalKey,
|
|
2321
|
+
props.onDismiss,
|
|
2322
|
+
reset
|
|
2323
|
+
]);
|
|
2324
|
+
const open = useCallback(() => {
|
|
2325
|
+
setIsOpen(true);
|
|
2326
|
+
setStep("compose");
|
|
2327
|
+
props.onOpen?.();
|
|
2328
|
+
if (props.captureOnOpen !== false) takeCapture();
|
|
2329
|
+
}, [
|
|
2330
|
+
props.captureOnOpen,
|
|
2331
|
+
props.onOpen,
|
|
2332
|
+
takeCapture
|
|
2333
|
+
]);
|
|
2334
|
+
const selectElement = useCallback((element) => {
|
|
2335
|
+
const picked = buildElementSelection(element);
|
|
2336
|
+
setSelection(picked);
|
|
2337
|
+
setStep("compose");
|
|
2338
|
+
const current = captureRef.current;
|
|
2339
|
+
if (current) setAnnotations((previous) => [...previous.filter((item) => !item.id.startsWith("selection-")), highlightFor(picked, current)]);
|
|
2340
|
+
const pick = ++pickRef.current;
|
|
2341
|
+
captureViewport({
|
|
2342
|
+
element,
|
|
2343
|
+
maxWidth: ELEMENT_CAPTURE_MAX_WIDTH
|
|
2344
|
+
}).catch(() => null).then((image) => {
|
|
2345
|
+
if (pickRef.current === pick) replaceElementCapture(image);
|
|
2346
|
+
else releaseCapture(image);
|
|
2347
|
+
});
|
|
2348
|
+
}, [replaceElementCapture]);
|
|
2349
|
+
const clearSelection = useCallback(() => {
|
|
2350
|
+
pickRef.current++;
|
|
2351
|
+
replaceElementCapture(null);
|
|
2352
|
+
setSelection(null);
|
|
2353
|
+
setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
|
|
2354
|
+
}, [replaceElementCapture]);
|
|
2355
|
+
const removeCapture = useCallback(() => {
|
|
2356
|
+
replaceCapture(null);
|
|
2357
|
+
setAnnotations([]);
|
|
2358
|
+
}, [replaceCapture]);
|
|
2359
|
+
const client = useMemo(() => publicKey ? new Reflet({
|
|
2360
|
+
baseUrl,
|
|
2361
|
+
publicKey,
|
|
2362
|
+
user,
|
|
2363
|
+
userToken
|
|
2364
|
+
}) : null, [
|
|
2365
|
+
baseUrl,
|
|
2366
|
+
publicKey,
|
|
2367
|
+
user,
|
|
2368
|
+
userToken
|
|
2369
|
+
]);
|
|
2370
|
+
const submit = useCallback(async () => {
|
|
2371
|
+
if (honeypot) {
|
|
2372
|
+
setStep("success");
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (!client) {
|
|
2376
|
+
setError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
setIsSubmitting(true);
|
|
2380
|
+
setError(null);
|
|
2381
|
+
let annotated = null;
|
|
2382
|
+
try {
|
|
2383
|
+
annotated = capture ? await renderAnnotatedImage(capture, annotations) : null;
|
|
2384
|
+
const result = await submitWidgetFeedback(createFeedbackTransport(client), {
|
|
2385
|
+
annotated,
|
|
2386
|
+
annotations,
|
|
2387
|
+
category,
|
|
2388
|
+
context: {
|
|
2389
|
+
...collectPageContext({ sdkVersion: SDK_VERSION }),
|
|
2390
|
+
consoleEvents: consoleRef.current?.events(),
|
|
2391
|
+
metadata: props.metadata,
|
|
2392
|
+
selection: selection ?? void 0
|
|
2393
|
+
},
|
|
2394
|
+
element: elementCapture,
|
|
2395
|
+
email,
|
|
2396
|
+
isAnonymous,
|
|
2397
|
+
message,
|
|
2398
|
+
screenshot: capture
|
|
2399
|
+
});
|
|
2400
|
+
setStep("success");
|
|
2401
|
+
props.onSubmit?.({ feedbackId: result.feedbackId });
|
|
2402
|
+
setTimeout(close, SUCCESS_CLOSE_DELAY);
|
|
2403
|
+
} catch (submitError) {
|
|
2404
|
+
setError(submitError instanceof Error ? submitError.message : "Something went wrong. Please try again.");
|
|
2405
|
+
} finally {
|
|
2406
|
+
releaseCapture(annotated);
|
|
2407
|
+
setIsSubmitting(false);
|
|
2408
|
+
}
|
|
2409
|
+
}, [
|
|
2410
|
+
annotations,
|
|
2411
|
+
capture,
|
|
2412
|
+
category,
|
|
2413
|
+
client,
|
|
2414
|
+
close,
|
|
2415
|
+
elementCapture,
|
|
2416
|
+
email,
|
|
2417
|
+
honeypot,
|
|
2418
|
+
isAnonymous,
|
|
2419
|
+
message,
|
|
2420
|
+
props.metadata,
|
|
2421
|
+
props.onSubmit,
|
|
2422
|
+
selection
|
|
2423
|
+
]);
|
|
2424
|
+
return {
|
|
2425
|
+
annotations,
|
|
2426
|
+
canDismiss: dismissForDays !== null,
|
|
2427
|
+
capture,
|
|
2428
|
+
category,
|
|
2429
|
+
clearSelection,
|
|
2430
|
+
close,
|
|
2431
|
+
dismiss,
|
|
2432
|
+
dismissForDays,
|
|
2433
|
+
elementCapture,
|
|
2434
|
+
email,
|
|
2435
|
+
error,
|
|
2436
|
+
honeypot,
|
|
2437
|
+
isAnonymous,
|
|
2438
|
+
isCapturing,
|
|
2439
|
+
isDismissed: dismissedUntil > Date.now(),
|
|
2440
|
+
isOpen,
|
|
2441
|
+
isSubmitting,
|
|
2442
|
+
message,
|
|
2443
|
+
open,
|
|
2444
|
+
removeCapture,
|
|
2445
|
+
selectElement,
|
|
2446
|
+
selection,
|
|
2447
|
+
setAnnotations,
|
|
2448
|
+
setCategory,
|
|
2449
|
+
setEmail,
|
|
2450
|
+
setHoneypot,
|
|
2451
|
+
setMessage,
|
|
2452
|
+
setStep,
|
|
2453
|
+
step,
|
|
2454
|
+
submit,
|
|
2455
|
+
takeCapture
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
//#endregion
|
|
2459
|
+
//#region src/feedback/widget.tsx
|
|
2460
|
+
const DEFAULT_CATEGORIES = [
|
|
2461
|
+
"bug",
|
|
2462
|
+
"idea",
|
|
2463
|
+
"question"
|
|
2464
|
+
];
|
|
2465
|
+
const DEFAULT_OFFSET = 20;
|
|
2466
|
+
/**
|
|
2467
|
+
* Floating feedback button with a screenshot, annotation and element picker
|
|
2468
|
+
* flow. Mount it once, anywhere below `RefletProvider` or with its own
|
|
2469
|
+
* `publicKey`.
|
|
2470
|
+
*/
|
|
2471
|
+
function RefletFeedback(props) {
|
|
2472
|
+
const { categories = DEFAULT_CATEGORIES, enabled = true, hotkey = null, offset = DEFAULT_OFFSET, position = "bottom-right", theme = "auto" } = props;
|
|
2473
|
+
const labels = {
|
|
2474
|
+
...DEFAULT_WIDGET_LABELS,
|
|
2475
|
+
...props.labels
|
|
2476
|
+
};
|
|
2477
|
+
const state = useWidgetState(props);
|
|
2478
|
+
const { close, isOpen, open } = state;
|
|
2479
|
+
useEffect(() => {
|
|
2480
|
+
if (!(hotkey && enabled)) return;
|
|
2481
|
+
const onKeyDown = (event) => {
|
|
2482
|
+
if (matchesHotkey(event, hotkey)) {
|
|
2483
|
+
event.preventDefault();
|
|
2484
|
+
if (isOpen) close();
|
|
2485
|
+
else open();
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
document.addEventListener("keydown", onKeyDown);
|
|
2489
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
2490
|
+
}, [
|
|
2491
|
+
close,
|
|
2492
|
+
enabled,
|
|
2493
|
+
hotkey,
|
|
2494
|
+
isOpen,
|
|
2495
|
+
open
|
|
2496
|
+
]);
|
|
2497
|
+
useEffect(() => {
|
|
2498
|
+
if (!isOpen || state.step !== "compose") return;
|
|
2499
|
+
const onKeyDown = (event) => {
|
|
2500
|
+
if (event.key === "Escape") close();
|
|
2501
|
+
};
|
|
2502
|
+
document.addEventListener("keydown", onKeyDown);
|
|
2503
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
2504
|
+
}, [
|
|
2505
|
+
close,
|
|
2506
|
+
isOpen,
|
|
2507
|
+
state.step
|
|
2508
|
+
]);
|
|
2509
|
+
if (!enabled || state.isDismissed) return null;
|
|
2510
|
+
const showPanel = isOpen && state.step !== "picking" && state.step !== "annotate";
|
|
2511
|
+
return /* @__PURE__ */ jsxs(ShadowPortal, {
|
|
2512
|
+
offset,
|
|
2513
|
+
primaryColor: props.primaryColor,
|
|
2514
|
+
theme,
|
|
2515
|
+
children: [
|
|
2516
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2517
|
+
className: "root",
|
|
2518
|
+
"data-position": position,
|
|
2519
|
+
children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
|
|
2520
|
+
categories,
|
|
2521
|
+
labels,
|
|
2522
|
+
state
|
|
2523
|
+
}), /* @__PURE__ */ jsx(Launcher, {
|
|
2524
|
+
isOpen,
|
|
2525
|
+
label: labels.trigger,
|
|
2526
|
+
onClick: isOpen ? close : open
|
|
2527
|
+
})]
|
|
2528
|
+
}),
|
|
2529
|
+
isOpen && state.step === "annotate" && state.capture && /* @__PURE__ */ jsx(Annotator, {
|
|
2530
|
+
annotations: state.annotations,
|
|
2531
|
+
capture: state.capture,
|
|
2532
|
+
labels,
|
|
2533
|
+
onChange: state.setAnnotations,
|
|
2534
|
+
onDone: () => state.setStep("compose")
|
|
2535
|
+
}),
|
|
2536
|
+
isOpen && state.step === "picking" && /* @__PURE__ */ jsx(ElementPicker, {
|
|
2537
|
+
hint: labels.pickElementHint,
|
|
2538
|
+
onCancel: () => state.setStep("compose"),
|
|
2539
|
+
onPick: state.selectElement
|
|
2540
|
+
})
|
|
2541
|
+
]
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
//#endregion
|
|
2545
|
+
export { RefletFeedback, buildElementSelection, captureViewport, collectPageContext, releaseCapture };
|
|
2546
|
+
|
|
2547
|
+
//# sourceMappingURL=feedback.js.map
|