reflet-sdk 0.4.3 → 0.5.1

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/dist/feedback.js CHANGED
@@ -1,932 +1,12 @@
1
1
  "use client";
2
- import { t as Reflet } from "./client-BYuSKJw5.js";
3
- import { n as RefletContext, t as REFLET_Z_INDEX } from "./z-index-_Q9PWbfY.js";
4
- import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { n as Reflet } from "./client-BWEBWk1k.js";
3
+ import { A as TrashIcon, C as MoreIcon, E as SquareIcon, F as SDK_VERSION, I as collectPageContext, K as releaseCapture, L as MAX_SELECTION_COMMENT_LENGTH, M as isDegenerate, N as normalizeRect, O as TargetIcon, P as DEFAULT_WIDGET_LABELS, R as buildElementSelection, S as HighlightIcon, T as SpotlightIcon, U as WIDGET_MARKER, W as captureViewport, _ as ChatIcon, b as CloseIcon, d as SelectionOutline, f as drawAnnotations, g as CameraIcon, h as BlurIcon, j as UndoIcon, k as TextIcon, l as submitWidgetFeedback, m as ArrowIcon, o as attachPreparedScreenshots, p as listenToKeydown, r as useFloatingPosition, s as createFeedbackTransport, t as ElementPicker, v as CheckIcon, w as PencilIcon, x as GripIcon, y as ChevronDownIcon } from "./picker-DFfC1uIY.js";
4
+ import { n as RefletContext, t as REFLET_Z_INDEX } from "./z-index-CKxBgZTG.js";
5
+ import { Suspense, lazy, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
7
  import { Slider } from "@base-ui/react/slider";
7
8
  import { ScrollArea } from "@base-ui/react/scroll-area";
8
9
  import { createPortal } from "react-dom";
9
- //#region src/feedback/core/capture.ts
10
- /** Marks every node the widget owns, so captures never include the panel. */
11
- const WIDGET_MARKER = "data-reflet-widget";
12
- const MAX_PIXEL_RATIO = 2;
13
- const DEFAULT_MAX_WIDTH = 2400;
14
- const MAX_INLINE_BYTES = 3e6;
15
- const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
16
- const EXTERNAL_URL = /^(https?:)?\/\//i;
17
- const CSS_URL = /url\((['"]?)([^)'"]+)\1\)/g;
18
- function isExternalImageUrl(url) {
19
- if (!url || url.startsWith("data:") || url.startsWith("blob:")) return false;
20
- if (!EXTERNAL_URL.test(url)) return false;
21
- try {
22
- return new URL(url, window.location.href).origin !== window.location.origin;
23
- } catch {
24
- return false;
25
- }
26
- }
27
- function toDataUrl(url) {
28
- return new Promise((resolve) => {
29
- fetch(url, { mode: "cors" }).then(async (response) => {
30
- if (!response.ok) {
31
- resolve(null);
32
- return;
33
- }
34
- const blob = await response.blob();
35
- if (blob.size === 0 || blob.size > MAX_INLINE_BYTES) {
36
- resolve(null);
37
- return;
38
- }
39
- const reader = new FileReader();
40
- reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
41
- reader.onerror = () => resolve(null);
42
- reader.readAsDataURL(blob);
43
- }).catch(() => resolve(null));
44
- });
45
- }
46
- const PNG_NORMALIZE_TIMEOUT = 750;
47
- const CAPTURE_TIMEOUT = 1e4;
48
- const ANIMATION_SETTLE_TIMEOUT = 1e3;
49
- /**
50
- * Tiles and sections routinely animate in or reflow (grid drags, entrance
51
- * transitions); a capture taken mid-flight freezes them displaced. Waits for
52
- * animations touching the page to finish, capped so an infinite spinner can
53
- * never stall the report.
54
- */
55
- async function waitForPageAnimations() {
56
- if (typeof document.getAnimations !== "function") return;
57
- const start = performance.now();
58
- while (performance.now() - start < ANIMATION_SETTLE_TIMEOUT) {
59
- const running = document.getAnimations().filter((animation) => animation.playState === "running");
60
- if (running.length === 0) return;
61
- await Promise.race([Promise.allSettled(running.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, 150))]);
62
- }
63
- }
64
- /**
65
- * snapdom awaits `decode()` on images created with `decoding="sync"` — a
66
- * promise Chrome 151 sometimes never settles (observed for cross-origin
67
- * webp, cached or inlined). Re-encoding through a canvas yields a PNG data
68
- * url, which decodes reliably; the load itself goes through `onload`, never
69
- * `decode()`.
70
- */
71
- function normalizeToPng(dataUrl) {
72
- return new Promise((resolve) => {
73
- const image = new Image();
74
- const timeout = setTimeout(() => resolve(dataUrl), PNG_NORMALIZE_TIMEOUT);
75
- image.onload = () => {
76
- clearTimeout(timeout);
77
- try {
78
- if (image.naturalWidth === 0 || image.naturalHeight === 0) {
79
- resolve(null);
80
- return;
81
- }
82
- const canvas = document.createElement("canvas");
83
- canvas.width = image.naturalWidth;
84
- canvas.height = image.naturalHeight;
85
- const context = canvas.getContext("2d");
86
- if (!context) {
87
- resolve(dataUrl);
88
- return;
89
- }
90
- context.drawImage(image, 0, 0);
91
- resolve(canvas.toDataURL("image/png"));
92
- } catch {
93
- resolve(null);
94
- }
95
- };
96
- image.onerror = () => {
97
- clearTimeout(timeout);
98
- resolve(null);
99
- };
100
- image.src = dataUrl;
101
- });
102
- }
103
- async function toInlineUrl(url) {
104
- const dataUrl = await toDataUrl(url);
105
- if (!dataUrl) return null;
106
- return normalizeToPng(dataUrl);
107
- }
108
- function remember(swaps, element, name) {
109
- swaps.push({
110
- element,
111
- name,
112
- value: element.getAttribute(name)
113
- });
114
- }
115
- async function inlineImgSource(img, swaps) {
116
- const src = img.getAttribute("src");
117
- if (!(src && isExternalImageUrl(src))) return;
118
- const dataUrl = await toInlineUrl(src);
119
- if (dataUrl) {
120
- if (img.hasAttribute("srcset")) {
121
- remember(swaps, img, "srcset");
122
- img.removeAttribute("srcset");
123
- }
124
- if (img.hasAttribute("sizes")) {
125
- remember(swaps, img, "sizes");
126
- img.removeAttribute("sizes");
127
- }
128
- }
129
- remember(swaps, img, "src");
130
- img.setAttribute("src", dataUrl ?? TRANSPARENT_PIXEL);
131
- }
132
- async function inlineBackgroundImages(element, swaps) {
133
- const style = element.getAttribute("style");
134
- if (!style) return;
135
- const urls = [...style.matchAll(CSS_URL)].map((match) => match[2] ?? "");
136
- if (!urls.some(isExternalImageUrl)) return;
137
- let next = style;
138
- for (const url of urls) {
139
- if (!isExternalImageUrl(url)) continue;
140
- const dataUrl = await toInlineUrl(url);
141
- next = next.split(url).join(dataUrl ?? TRANSPARENT_PIXEL);
142
- }
143
- if (next !== style) {
144
- remember(swaps, element, "style");
145
- element.setAttribute("style", next);
146
- }
147
- }
148
- /**
149
- * Swaps every cross-origin image under `root` for a data URL so the capture
150
- * cannot hit snapdom's decode-failure hang. Returns the undo list — always
151
- * run it, even after a failed capture.
152
- */
153
- async function inlineExternalImages(root) {
154
- const swaps = [];
155
- for (const img of [...root.querySelectorAll("img")]) await inlineImgSource(img, swaps);
156
- for (const el of [...root.querySelectorAll("[style*='url(']")]) await inlineBackgroundImages(el, swaps);
157
- return swaps;
158
- }
159
- function restoreInlinedImages(swaps) {
160
- for (const swap of swaps) if (swap.value === null) swap.element.removeAttribute(swap.name);
161
- else swap.element.setAttribute(swap.name, swap.value);
162
- }
163
- function buildSnapdomOptions(options) {
164
- const ratio = options.devicePixelRatio ?? 1;
165
- return {
166
- cache: "disabled",
167
- clip: options.element ? void 0 : "viewport",
168
- dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
169
- embedFonts: true,
170
- exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
171
- fast: true,
172
- filter: options.filter,
173
- reconcile: true
174
- };
175
- }
176
- function fitWithin(size, maxWidth) {
177
- if (size.width <= maxWidth) return {
178
- height: size.height,
179
- scale: 1,
180
- width: size.width
181
- };
182
- const scale = maxWidth / size.width;
183
- return {
184
- height: Math.max(1, Math.round(size.height * scale)),
185
- scale,
186
- width: maxWidth
187
- };
188
- }
189
- function toBlob(canvas) {
190
- return new Promise((resolve) => {
191
- canvas.toBlob(resolve, "image/png");
192
- });
193
- }
194
- function downscale(canvas, target) {
195
- const resized = document.createElement("canvas");
196
- resized.width = target.width;
197
- resized.height = target.height;
198
- const context = resized.getContext("2d");
199
- if (!context) return canvas;
200
- context.imageSmoothingQuality = "high";
201
- context.drawImage(canvas, 0, 0, target.width, target.height);
202
- return resized;
203
- }
204
- /** Wraps a rendered canvas as an owned capture, downscaled to `maxWidth`. */
205
- async function capturedFromCanvas(source, maxWidth) {
206
- const fitted = fitWithin({
207
- height: source.height,
208
- width: source.width
209
- }, maxWidth);
210
- const output = fitted.scale === 1 ? source : downscale(source, fitted);
211
- const blob = await toBlob(output);
212
- if (!blob) return null;
213
- return {
214
- blob,
215
- height: output.height,
216
- mimeType: "image/png",
217
- objectUrl: URL.createObjectURL(blob),
218
- width: output.width
219
- };
220
- }
221
- /**
222
- * Renders what the user is looking at right now.
223
- *
224
- * Runs off the DOM rather than `getDisplayMedia` on purpose: a screen-share
225
- * permission prompt on every report would cost more reports than it captures.
226
- * Returns null instead of throwing — a screenshot is never worth losing the
227
- * written feedback.
228
- */
229
- async function captureViewport(options = {}) {
230
- try {
231
- const { snapdom } = await import("@zumer/snapdom");
232
- const target = options.element ?? document.documentElement;
233
- await waitForPageAnimations();
234
- const swaps = await inlineExternalImages(target);
235
- const shoot = (withoutImages = false) => {
236
- const attempt = snapdom.toCanvas(target, buildSnapdomOptions({
237
- devicePixelRatio: window.devicePixelRatio,
238
- element: options.element,
239
- excludeSelectors: options.excludeSelectors,
240
- filter: withoutImages ? (element) => element.tagName !== "IMG" : void 0
241
- }));
242
- attempt.catch(() => void 0);
243
- return Promise.race([attempt, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("capture timed out")), options.timeout ?? CAPTURE_TIMEOUT))]);
244
- };
245
- let canvas;
246
- try {
247
- canvas = await shoot();
248
- } catch {
249
- canvas = await shoot(true);
250
- } finally {
251
- restoreInlinedImages(swaps);
252
- }
253
- return await capturedFromCanvas(canvas, options.maxWidth ?? DEFAULT_MAX_WIDTH);
254
- } catch {
255
- return null;
256
- }
257
- }
258
- function releaseCapture(image) {
259
- if (image) URL.revokeObjectURL(image.objectUrl);
260
- }
261
- //#endregion
262
- //#region src/feedback/core/react-source.ts
263
- const FIBER_KEY_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
264
- const MEMO_TYPE = Symbol.for("react.memo");
265
- const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
266
- const MAX_STACK_DEPTH = 6;
267
- const MAX_FIBER_WALK = 60;
268
- const MAX_OWNER_WALK = 8;
269
- const MIN_NAME_LENGTH = 4;
270
- const CAPITALIZED = /^[A-Z]/;
271
- const WORD_INSIDE = /[a-z]{2}/;
272
- const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
273
- const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
274
- const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
275
- const NON_SOURCE_FRAME = /node_modules|\/_next\/|\/chunks?\/|\/\.vite\/|\/dist\/|\/build\/|hot-update/;
276
- const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
277
- const SOURCE_ROOT_MARKERS = [
278
- "src",
279
- "app",
280
- "pages",
281
- "components",
282
- "features",
283
- "lib"
284
- ];
285
- function isRecord(value) {
286
- return typeof value === "object" && value !== null;
287
- }
288
- /** React attaches the fiber under a randomly suffixed key on the host node. */
289
- function getFiberFromNode(node) {
290
- for (const key of Object.getOwnPropertyNames(node)) for (const prefix of FIBER_KEY_PREFIXES) if (key.startsWith(prefix)) return Reflect.get(node, key) ?? null;
291
- return null;
292
- }
293
- function readName(value) {
294
- if (typeof value !== "function" && !isRecord(value)) return null;
295
- const displayName = Reflect.get(value, "displayName");
296
- if (typeof displayName === "string" && displayName) return displayName;
297
- const name = Reflect.get(value, "name");
298
- return typeof name === "string" && name ? name : null;
299
- }
300
- /**
301
- * Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
302
- * owning component actively misleads an agent, so only real words get through.
303
- */
304
- function isMeaningfulName(name) {
305
- if (name.includes("/") || name.includes("_")) return true;
306
- return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
307
- }
308
- function componentName(type, depth = 0) {
309
- if (typeof type === "string" || type === null || depth > 3) return null;
310
- if (isRecord(type)) {
311
- const marker = Reflect.get(type, "$$typeof");
312
- if (marker === MEMO_TYPE) return componentName(Reflect.get(type, "type"), depth + 1);
313
- if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
314
- }
315
- const name = readName(type);
316
- return name && isMeaningfulName(name) ? name : null;
317
- }
318
- /** Component names owning the fiber, innermost first. */
319
- function resolveComponentStack(fiber) {
320
- const stack = [];
321
- let current = fiber;
322
- let steps = 0;
323
- while (isRecord(current) && steps < MAX_FIBER_WALK) {
324
- const name = componentName(Reflect.get(current, "type")) ?? componentName(Reflect.get(current, "elementType"));
325
- if (name && stack.at(-1) !== name) {
326
- stack.push(name);
327
- if (stack.length >= MAX_STACK_DEPTH) break;
328
- }
329
- current = Reflect.get(current, "return");
330
- steps++;
331
- }
332
- return stack;
333
- }
334
- function shortenSourcePath(raw) {
335
- const path = raw.replace(WEBPACK_PREFIX, "").replace(ORIGIN_PREFIX, "").split("?")[0];
336
- if (!path) return raw;
337
- for (const marker of SOURCE_ROOT_MARKERS) {
338
- if (path.startsWith(`${marker}/`)) return path;
339
- const index = path.lastIndexOf(`/${marker}/`);
340
- if (index !== -1) return path.slice(index + 1);
341
- }
342
- return path.split("/").filter(Boolean).slice(-3).join("/");
343
- }
344
- function formatLocation(fileName, line, column) {
345
- const base = `${shortenSourcePath(fileName)}:${line}`;
346
- return column === void 0 ? base : `${base}:${column}`;
347
- }
348
- function fromDebugSource(source) {
349
- if (!isRecord(source)) return;
350
- const fileName = Reflect.get(source, "fileName");
351
- const lineNumber = Reflect.get(source, "lineNumber");
352
- if (typeof fileName !== "string" || typeof lineNumber !== "number") return;
353
- const columnNumber = Reflect.get(source, "columnNumber");
354
- return formatLocation(fileName, lineNumber, typeof columnNumber === "number" ? columnNumber : void 0);
355
- }
356
- function fromDebugStack(stack) {
357
- const raw = isRecord(stack) ? Reflect.get(stack, "stack") : stack;
358
- if (typeof raw !== "string") return;
359
- for (const line of raw.split("\n")) {
360
- const match = STACK_LOCATION.exec(line);
361
- const file = match?.[1];
362
- if (!(match && file) || NON_SOURCE_FRAME.test(file)) continue;
363
- const path = file.replace(ORIGIN_PREFIX, "").split("?")[0] ?? "";
364
- if (!SOURCE_EXTENSION.test(path)) continue;
365
- return formatLocation(path, Number(match[2]), Number(match[3]));
366
- }
367
- }
368
- /**
369
- * `src/components/login-form.tsx:46:19` when the React build exposes it.
370
- * Production bundles strip debug info — callers must treat this as optional.
371
- */
372
- function resolveSourceLocation(fiber) {
373
- let current = fiber;
374
- let steps = 0;
375
- while (isRecord(current) && steps < MAX_OWNER_WALK) {
376
- const located = fromDebugSource(Reflect.get(current, "_debugSource")) ?? fromDebugStack(Reflect.get(current, "_debugStack"));
377
- if (located) return located;
378
- current = Reflect.get(current, "_debugOwner");
379
- steps++;
380
- }
381
- }
382
- function inspectReactElement(element) {
383
- const fiber = getFiberFromNode(element);
384
- return {
385
- componentStack: resolveComponentStack(fiber),
386
- sourceLocation: resolveSourceLocation(fiber)
387
- };
388
- }
389
- //#endregion
390
- //#region src/feedback/core/redact.ts
391
- /**
392
- * The picked element's markup leaves the reporter's browser and ends up in an
393
- * agent prompt, so it is scrubbed here: typed-in values, credentials and
394
- * anything marked `data-reflet-redact` never make the trip.
395
- */
396
- const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
397
- const LONG_TOKEN = /\b[\w-]{28,}\b/g;
398
- const OPAQUE_URI = /^(?:data|blob|javascript):/i;
399
- const WHITESPACE_RUN = /\s+/g;
400
- const URL_QUERY = /[?#]/;
401
- const REDACTED = "[redacted]";
402
- const ELLIPSIS = "…";
403
- const MAX_ATTRIBUTE_LENGTH = 120;
404
- const MAX_TEXT_LENGTH$1 = 200;
405
- const MAX_CHILD_DEPTH = 3;
406
- const MAX_CLONED_NODES = 400;
407
- const DROPPED_TAGS = /* @__PURE__ */ new Set([
408
- "script",
409
- "style",
410
- "noscript",
411
- "template"
412
- ]);
413
- const VALUE_HOLDERS = /* @__PURE__ */ new Set([
414
- "input",
415
- "textarea",
416
- "select",
417
- "option"
418
- ]);
419
- const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
420
- "src",
421
- "srcset",
422
- "href",
423
- "poster",
424
- "action",
425
- "formaction"
426
- ]);
427
- function truncate$1(value, max) {
428
- return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
429
- }
430
- /** Emails and token-shaped strings, wherever they appear in reported text. */
431
- function maskSecrets(value) {
432
- return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
433
- }
434
- const SENSITIVE_PARAM = /token|secret|pass(?:word|wd)?|pwd|auth|jwt|credential|session|sid|key|email|ssn|otp|code|signature|nonce/i;
435
- const LEADING_QUESTION_MARK = /^\?/;
436
- function decoded(value) {
437
- try {
438
- return decodeURIComponent(value);
439
- } catch {
440
- return value;
441
- }
442
- }
443
- /**
444
- * Query strings carry signed-in state (`?token=…`, `?userEmail=…`) straight
445
- * into stored reports and agent prompts. Values of anything credential- or
446
- * identity-shaped are masked; every other byte of the address stays intact.
447
- */
448
- function redactUrl(href) {
449
- let url;
450
- try {
451
- url = new URL(href);
452
- } catch {
453
- return maskSecrets(href);
454
- }
455
- const pairs = url.search.replace(LEADING_QUESTION_MARK, "").split("&").filter(Boolean);
456
- if (!pairs.some((pair) => SENSITIVE_PARAM.test(decoded(pair.split("=")[0] ?? "")))) return href;
457
- const query = pairs.map((pair) => {
458
- const equals = pair.indexOf("=");
459
- if (equals === -1 || !SENSITIVE_PARAM.test(decoded(pair.slice(0, equals)))) return pair;
460
- return `${pair.slice(0, equals)}=${REDACTED}`;
461
- }).join("&");
462
- return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
463
- }
464
- function sanitizeUrl(value) {
465
- if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
466
- return value.split(URL_QUERY)[0] ?? value;
467
- }
468
- function sanitizeAttributes(element) {
469
- const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
470
- for (const { name, value } of Array.from(element.attributes)) {
471
- if (holdsTypedValue && name === "value") {
472
- element.removeAttribute(name);
473
- continue;
474
- }
475
- const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
476
- element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
477
- }
478
- }
479
- function collapse(element) {
480
- element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
481
- }
482
- function sanitizeTree(element, depth) {
483
- sanitizeAttributes(element);
484
- if (element.hasAttribute("data-reflet-redact")) {
485
- element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
486
- return;
487
- }
488
- if (element.localName === "svg") {
489
- element.replaceChildren();
490
- return;
491
- }
492
- for (const child of Array.from(element.childNodes)) {
493
- if (child.nodeType === Node.COMMENT_NODE) {
494
- child.remove();
495
- continue;
496
- }
497
- if (child.nodeType === Node.TEXT_NODE) {
498
- child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
499
- continue;
500
- }
501
- if (!(child instanceof Element)) continue;
502
- if (DROPPED_TAGS.has(child.localName)) {
503
- child.remove();
504
- continue;
505
- }
506
- if (depth >= MAX_CHILD_DEPTH) {
507
- sanitizeAttributes(child);
508
- collapse(child);
509
- continue;
510
- }
511
- sanitizeTree(child, depth + 1);
512
- }
513
- }
514
- /** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
515
- function sanitizeMarkup(element, maxLength) {
516
- const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
517
- const clone = element.cloneNode(isSmallEnough);
518
- if (!(clone instanceof Element)) return "";
519
- sanitizeTree(clone, 0);
520
- if (!isSmallEnough) collapse(clone);
521
- return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
522
- }
523
- //#endregion
524
- //#region src/feedback/core/element-selector.ts
525
- const SAFE_ID = /^[A-Za-z][\w-]*$/;
526
- const WHITESPACE = /\s+/g;
527
- const MAX_LABEL_LENGTH = 80;
528
- const MAX_HTML_LENGTH = 600;
529
- const MAX_TEXT_LENGTH = 300;
530
- const MAX_REGION_LENGTH = 160;
531
- const MAX_HANDLE_VALUE_LENGTH = 80;
532
- const MAX_DEPTH = 12;
533
- const MIN_PATH_SEGMENTS = 2;
534
- const MAX_SELECTOR_LENGTH = 600;
535
- const MAX_SOURCE_LOCATION_LENGTH = 400;
536
- const MAX_SELECTION_COMMENT_LENGTH = 2e3;
537
- const TEST_ATTRIBUTES = [
538
- "data-testid",
539
- "data-test-id",
540
- "data-test",
541
- "data-cy",
542
- "data-qa"
543
- ];
544
- const NAMING_ATTRIBUTES = ["name", "aria-label"];
545
- const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
546
- const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
547
- /** Elements that start a new visual block — their text gets separated by a space. */
548
- const BLOCK_TAGS = /* @__PURE__ */ new Set([
549
- "address",
550
- "article",
551
- "aside",
552
- "blockquote",
553
- "dd",
554
- "details",
555
- "div",
556
- "dl",
557
- "dt",
558
- "fieldset",
559
- "figcaption",
560
- "figure",
561
- "footer",
562
- "form",
563
- "h1",
564
- "h2",
565
- "h3",
566
- "h4",
567
- "h5",
568
- "h6",
569
- "header",
570
- "hr",
571
- "li",
572
- "main",
573
- "nav",
574
- "ol",
575
- "p",
576
- "pre",
577
- "section",
578
- "summary",
579
- "table",
580
- "td",
581
- "th",
582
- "tr",
583
- "ul"
584
- ]);
585
- function visibleText(element) {
586
- const clone = element.cloneNode(true);
587
- if (!(clone instanceof Element)) return "";
588
- for (const node of clone.querySelectorAll("*")) if (BLOCK_TAGS.has(node.localName)) node.before(clone.ownerDocument.createTextNode(" "));
589
- return collapseText(maskSecrets(clone.textContent ?? ""));
590
- }
591
- function isUnique(selector) {
592
- try {
593
- return document.querySelectorAll(selector).length === 1;
594
- } catch {
595
- return false;
596
- }
597
- }
598
- function escapeAttributeValue(value) {
599
- return value.replace(/["\\]/g, "\\$&");
600
- }
601
- function attributeHandle(element, attribute, qualifier) {
602
- const value = element.getAttribute(attribute);
603
- if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
604
- const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
605
- return isUnique(selector) ? selector : null;
606
- }
607
- /** Something an engineer would have written on purpose, unlike a positional path. */
608
- function uniqueHandle(element) {
609
- const id = element.getAttribute("id");
610
- if (id && SAFE_ID.test(id) && isUnique(`#${id}`)) return `#${id}`;
611
- for (const attribute of TEST_ATTRIBUTES) {
612
- const handle = attributeHandle(element, attribute, "");
613
- if (handle) return handle;
614
- }
615
- for (const attribute of NAMING_ATTRIBUTES) {
616
- const handle = attributeHandle(element, attribute, element.localName);
617
- if (handle) return handle;
618
- }
619
- return null;
620
- }
621
- function positionalSegment(element) {
622
- const tag = element.localName;
623
- const parent = element.parentElement;
624
- if (!parent) return tag;
625
- const sameTag = Array.from(parent.children).filter((child) => child.localName === tag);
626
- if (sameTag.length <= 1) return tag;
627
- return `${tag}:nth-of-type(${sameTag.indexOf(element) + 1})`;
628
- }
629
- /**
630
- * Shortest selector that still resolves back to `element` from the document.
631
- * Grows the path one ancestor at a time and stops as soon as it is unique, so
632
- * a stable handle anywhere up the tree replaces the whole positional chain.
633
- * A bare tag is never enough: positional paths keep a parent for context.
634
- */
635
- function buildSelector(element) {
636
- if (element === document.documentElement) return "html";
637
- if (element === document.body) return "body";
638
- const handle = uniqueHandle(element);
639
- if (handle) return handle;
640
- const segments = [];
641
- let current = element;
642
- for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
643
- const ancestorHandle = current === element ? null : uniqueHandle(current);
644
- segments.unshift(ancestorHandle ?? positionalSegment(current));
645
- const candidate = segments.join(" > ");
646
- const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
647
- if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
648
- current = current.parentElement;
649
- }
650
- return segments.join(" > ");
651
- }
652
- function truncate(value, max) {
653
- return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
654
- }
655
- function collapseText(value) {
656
- return value.replace(WHITESPACE, " ").trim();
657
- }
658
- function accessibleLabel(element) {
659
- const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
660
- if (explicit?.trim()) return explicit.trim();
661
- return collapseText(element.textContent ?? "");
662
- }
663
- /** Short human label such as `button "Sign in"` or `div.card`. */
664
- function describeElement(element) {
665
- const tag = element.localName;
666
- const label = maskSecrets(accessibleLabel(element));
667
- if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
668
- const firstClass = element.classList.item(0);
669
- return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
670
- }
671
- function describeLandmark(element) {
672
- const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
673
- if (!landmark) return null;
674
- const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
675
- return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
676
- }
677
- function headingAbove(element) {
678
- const scope = element.closest(LANDMARK_SELECTOR) ?? document.body;
679
- const probe = document.createRange();
680
- probe.selectNodeContents(element);
681
- probe.collapse(true);
682
- let closest = null;
683
- for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
684
- return collapseText(closest?.textContent ?? "") || null;
685
- }
686
- /**
687
- * Where the element sits from a reader's point of view — `dialog "Members" ›
688
- * Danger zone` says more to an agent than any DOM path can.
689
- */
690
- function describeRegion(element) {
691
- const landmark = describeLandmark(element);
692
- const heading = headingAbove(element);
693
- const parts = [landmark];
694
- if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
695
- const region = parts.filter((part) => part !== null).join(" › ");
696
- return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
697
- }
698
- function getElementRect(element) {
699
- const rect = element.getBoundingClientRect();
700
- return {
701
- height: Math.round(rect.height),
702
- width: Math.round(rect.width),
703
- x: Math.round(rect.x),
704
- y: Math.round(rect.y)
705
- };
706
- }
707
- /** Everything an agent needs to find the picked element back in the codebase. */
708
- function buildElementSelection(element) {
709
- const { componentStack, sourceLocation } = inspectReactElement(element);
710
- const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
711
- return {
712
- componentStack,
713
- html: sanitizeMarkup(element, MAX_HTML_LENGTH),
714
- label: describeElement(element),
715
- rect: getElementRect(element),
716
- region: describeRegion(element),
717
- selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
718
- sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
719
- text: text || void 0
720
- };
721
- }
722
- //#endregion
723
- //#region src/feedback/core/page-context.ts
724
- const BROWSER_PATTERNS = [
725
- ["Edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
726
- ["Opera", /OPR\/([\d.]+)/],
727
- ["Samsung Internet", /SamsungBrowser\/([\d.]+)/],
728
- ["Firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
729
- ["Chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
730
- ["Safari", /Version\/([\d.]+).*Safari/]
731
- ];
732
- const OS_PATTERNS = [
733
- ["iOS", /iPhone|iPad|iPod/],
734
- ["Android", /Android/],
735
- ["Windows", /Windows NT/],
736
- ["macOS", /Mac OS X|Macintosh/],
737
- ["ChromeOS", /CrOS/],
738
- ["Linux", /Linux|X11/]
739
- ];
740
- const MOBILE_HINT = /Mobile|iPhone|iPod/;
741
- const TABLET_HINT = /iPad|Tablet/;
742
- const TRAILING_ZERO = /\.0$/;
743
- const ANDROID = /Android/;
744
- function shortenVersion(version) {
745
- return version.split(".").slice(0, 2).join(".").replace(TRAILING_ZERO, "");
746
- }
747
- function detectDevice(userAgent) {
748
- if (TABLET_HINT.test(userAgent)) return "tablet";
749
- if (MOBILE_HINT.test(userAgent)) return "mobile";
750
- return ANDROID.test(userAgent) ? "tablet" : "desktop";
751
- }
752
- function parseUserAgent(userAgent) {
753
- const info = { device: detectDevice(userAgent) };
754
- for (const [name, pattern] of BROWSER_PATTERNS) {
755
- const version = pattern.exec(userAgent)?.[1];
756
- if (version) {
757
- info.browser = `${name} ${shortenVersion(version)}`;
758
- break;
759
- }
760
- }
761
- for (const [name, pattern] of OS_PATTERNS) if (pattern.test(userAgent)) {
762
- info.os = name;
763
- break;
764
- }
765
- return info;
766
- }
767
- const MAX_PAGE_TITLE_LENGTH = 300;
768
- const MAX_URL_LENGTH = 2e3;
769
- const MAX_USER_AGENT_LENGTH = 600;
770
- function clip(value, max) {
771
- return value.length <= max ? value : value.slice(0, max);
772
- }
773
- function optionalText(value, max) {
774
- return value?.trim() ? clip(value, max) : void 0;
775
- }
776
- function resolveTimezone() {
777
- try {
778
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
779
- } catch {}
780
- }
781
- /** Everything about the page that a bug report needs and the DOM already knows. */
782
- function collectPageContext(options) {
783
- const userAgent = navigator.userAgent;
784
- const agent = parseUserAgent(userAgent);
785
- return {
786
- browser: agent.browser,
787
- device: agent.device,
788
- language: navigator.language,
789
- os: agent.os,
790
- pageTitle: optionalText(document.title, MAX_PAGE_TITLE_LENGTH),
791
- referrer: optionalText(document.referrer, MAX_URL_LENGTH),
792
- screen: {
793
- height: window.screen.height,
794
- width: window.screen.width
795
- },
796
- scroll: {
797
- x: window.scrollX,
798
- y: window.scrollY
799
- },
800
- sdkVersion: options?.sdkVersion,
801
- timezone: resolveTimezone(),
802
- url: redactUrl(window.location.href),
803
- userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
804
- viewport: {
805
- devicePixelRatio: window.devicePixelRatio,
806
- height: window.innerHeight,
807
- width: window.innerWidth
808
- }
809
- };
810
- }
811
- //#endregion
812
- //#region src/feedback/types.ts
813
- const SDK_VERSION = "0.4.3";
814
- const DEFAULT_WIDGET_LABELS = {
815
- annotateHint: "Draw on the screenshot to point at the problem.",
816
- attachElement: "Attach this element",
817
- attachmentUploadFailed: "Your feedback was saved, but some screenshots are still pending. Retry to attach them.",
818
- attachScreenshot: "Attach a screenshot",
819
- back: "Back",
820
- cancel: "Cancel",
821
- captureFailed: "Screenshot unavailable. Try again or send without it.",
822
- capturing: "Taking screenshot…",
823
- clearAnnotations: "Clear drawing",
824
- descriptionPlaceholder: "What would you like to share?",
825
- dismissForDays: "Hide for {days} days",
826
- done: "Done",
827
- elementNote: "Comment on the picked element",
828
- elementNotePlaceholder: "What is wrong with this?",
829
- emailInvalid: "Enter a valid email address.",
830
- emailLabel: "Email",
831
- emailPlaceholder: "you@company.com",
832
- emailPromptBody: "We only use it to follow up on this report.",
833
- emailPromptTitle: "Add your email?",
834
- errorGeneric: "Something went wrong. Please try again.",
835
- errorTitleRequired: "Tell us a bit about it first.",
836
- minimize: "Minimize feedback",
837
- moreOptions: "More options",
838
- moveFeedback: "Move feedback",
839
- pickAnother: "Pick another",
840
- pickElement: "Point at an element",
841
- pickElementHint: "Pick the element you are talking about",
842
- recapture: "Retake",
843
- removeScreenshot: "Remove",
844
- resume: "Resume feedback",
845
- retakeHint: "Screenshot of this page",
846
- retryAttachments: "Retry attachments",
847
- screenshot: "Screenshot",
848
- sendWithoutEmail: "Send without email",
849
- submit: "Send feedback",
850
- successMessage: "We read every report. Thanks for taking the time.",
851
- successTitle: "Feedback sent",
852
- title: "Send feedback",
853
- titlePlaceholder: "Short summary",
854
- trigger: "Feedback",
855
- undo: "Undo"
856
- };
857
- //#endregion
858
- //#region src/feedback/core/annotations.ts
859
- const BOX_TOOLS = [
860
- "text",
861
- "rectangle",
862
- "spotlight",
863
- "highlight",
864
- "blur"
865
- ];
866
- const MIN_GESTURE_LENGTH = 8;
867
- const DEFAULT_PATH_TOLERANCE = 2.5;
868
- function normalizeRect(start, end) {
869
- return {
870
- height: Math.abs(end.y - start.y),
871
- width: Math.abs(end.x - start.x),
872
- x: Math.min(start.x, end.x),
873
- y: Math.min(start.y, end.y)
874
- };
875
- }
876
- function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
877
- if (points.length < 3) return points;
878
- const [first, ...rest] = points;
879
- if (!first) return points;
880
- const kept = [first];
881
- let anchor = first;
882
- for (const point of rest.slice(0, -1)) if (Math.hypot(point.x - anchor.x, point.y - anchor.y) >= tolerance) {
883
- kept.push(point);
884
- anchor = point;
885
- }
886
- const last = points.at(-1);
887
- if (last) kept.push(last);
888
- return kept;
889
- }
890
- function isDegenerate(annotation) {
891
- if (annotation.tool === "text") return !annotation.text?.trim();
892
- if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
893
- const { start, end } = annotation;
894
- return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
895
- }
896
- function toWire(annotation) {
897
- const { color, start, end, tool } = annotation;
898
- if (tool === "pen") return {
899
- color,
900
- points: simplifyPath(annotation.points ?? []),
901
- type: tool,
902
- x: start.x,
903
- y: start.y
904
- };
905
- if (BOX_TOOLS.includes(tool)) {
906
- const rect = normalizeRect(start, end);
907
- return {
908
- color,
909
- height: rect.height,
910
- ...tool === "text" ? { text: annotation.text } : {},
911
- type: tool,
912
- width: rect.width,
913
- x: rect.x,
914
- y: rect.y
915
- };
916
- }
917
- return {
918
- color,
919
- endX: end.x,
920
- endY: end.y,
921
- type: tool,
922
- x: start.x,
923
- y: start.y
924
- };
925
- }
926
- function toWireAnnotations(annotations) {
927
- return annotations.filter((item) => !isDegenerate(item)).map(toWire);
928
- }
929
- //#endregion
930
10
  //#region src/feedback/ui/annotation/color-value.ts
931
11
  const DEFAULT_ANNOTATION_COLOR = {
932
12
  baseColor: "#ef4444",
@@ -943,118 +23,6 @@ function annotationColorHex({ baseColor, strengthPercent }) {
943
23
  }).join("")}`;
944
24
  }
945
25
  //#endregion
946
- //#region src/feedback/ui/icons.tsx
947
- function Svg({ children, className, size = 16 }) {
948
- return /* @__PURE__ */ jsx("svg", {
949
- "aria-hidden": "true",
950
- className,
951
- fill: "none",
952
- height: size,
953
- stroke: "currentColor",
954
- strokeLinecap: "round",
955
- strokeLinejoin: "round",
956
- strokeWidth: "1.8",
957
- viewBox: "0 0 24 24",
958
- width: size,
959
- children
960
- });
961
- }
962
- function ChatIcon({ className }) {
963
- return /* @__PURE__ */ jsx(Svg, {
964
- className,
965
- size: 15,
966
- 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" })
967
- });
968
- }
969
- function CloseIcon({ className }) {
970
- return /* @__PURE__ */ jsx(Svg, {
971
- className,
972
- children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
973
- });
974
- }
975
- function CameraIcon() {
976
- 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", {
977
- cx: "12",
978
- cy: "13",
979
- r: "3.2"
980
- })] });
981
- }
982
- function PencilIcon() {
983
- 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" }) });
984
- }
985
- function TargetIcon() {
986
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
987
- cx: "12",
988
- cy: "12",
989
- r: "5"
990
- })] });
991
- }
992
- function TrashIcon() {
993
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
994
- }
995
- function ArrowIcon() {
996
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
997
- }
998
- function UndoIcon() {
999
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
1000
- }
1001
- function CheckIcon({ size = 28 }) {
1002
- return /* @__PURE__ */ jsx("svg", {
1003
- "aria-hidden": "true",
1004
- fill: "none",
1005
- height: size,
1006
- stroke: "currentColor",
1007
- strokeLinecap: "round",
1008
- strokeLinejoin: "round",
1009
- strokeWidth: "2.4",
1010
- viewBox: "0 0 24 24",
1011
- width: size,
1012
- children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
1013
- });
1014
- }
1015
- function GripIcon() {
1016
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" }) });
1017
- }
1018
- function MoreIcon() {
1019
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h.01M12 12h.01M19 12h.01" }) });
1020
- }
1021
- function ChevronDownIcon() {
1022
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "m7 10 5 5 5-5" }) });
1023
- }
1024
- function SquareIcon() {
1025
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
1026
- height: "14",
1027
- rx: "2",
1028
- width: "14",
1029
- x: "5",
1030
- y: "5"
1031
- }) });
1032
- }
1033
- function TextIcon() {
1034
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 6V4h14v2M12 4v16M8 20h8" }) });
1035
- }
1036
- function SpotlightIcon() {
1037
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M8 4H4v4M16 4h4v4M4 16v4h4M20 16v4h-4" }), /* @__PURE__ */ jsx("rect", {
1038
- height: "8",
1039
- rx: "1",
1040
- width: "8",
1041
- x: "8",
1042
- y: "8"
1043
- })] });
1044
- }
1045
- function HighlightIcon() {
1046
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 19h16M8 15l8-11 4 3-8 11-4-3ZM7 15l-2 3" }) });
1047
- }
1048
- function BlurIcon() {
1049
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
1050
- height: "14",
1051
- rx: "2",
1052
- width: "14",
1053
- x: "5",
1054
- y: "5"
1055
- }), /* @__PURE__ */ jsx("path", { d: "M8 8h8v8H8z" })] });
1056
- }
1057
- //#endregion
1058
26
  //#region src/feedback/ui/annotation/text/text-editor.tsx
1059
27
  function TextAnnotationEditor({ draft, onCommit, onCancel }) {
1060
28
  const [text, setText] = useState(draft.annotation.text ?? "");
@@ -1169,54 +137,6 @@ function useTextAnnotation(options) {
1169
137
  };
1170
138
  }
1171
139
  //#endregion
1172
- //#region src/feedback/ui/widget-keys.ts
1173
- const KEY_EVENTS = [
1174
- "keydown",
1175
- "keyup",
1176
- "keypress"
1177
- ];
1178
- function isWidgetNode(node) {
1179
- if (node instanceof ShadowRoot) return node.host.hasAttribute(WIDGET_MARKER);
1180
- return node instanceof Element && node.hasAttribute("data-reflet-widget");
1181
- }
1182
- function startsInWidget(event) {
1183
- return event.composedPath().some(isWidgetNode);
1184
- }
1185
- function relayInsideWidget(event) {
1186
- const target = event.composedPath()[0];
1187
- if (!(event instanceof KeyboardEvent && target && startsInWidget(event))) return;
1188
- event.stopImmediatePropagation();
1189
- const relay = new KeyboardEvent(event.type, {
1190
- altKey: event.altKey,
1191
- bubbles: true,
1192
- cancelable: true,
1193
- charCode: event.charCode,
1194
- code: event.code,
1195
- ctrlKey: event.ctrlKey,
1196
- isComposing: event.isComposing,
1197
- key: event.key,
1198
- keyCode: event.keyCode,
1199
- location: event.location,
1200
- metaKey: event.metaKey,
1201
- repeat: event.repeat,
1202
- shiftKey: event.shiftKey
1203
- });
1204
- target.dispatchEvent(relay);
1205
- if (relay.defaultPrevented) event.preventDefault();
1206
- }
1207
- if (typeof window !== "undefined") for (const type of KEY_EVENTS) window.addEventListener(type, relayInsideWidget, true);
1208
- function listenToKeydown(anchor, onKeyDown, capture = false) {
1209
- const widgetRoot = anchor?.getRootNode();
1210
- const targets = widgetRoot instanceof ShadowRoot ? [document, widgetRoot] : [document];
1211
- const listener = (event) => {
1212
- if (event instanceof KeyboardEvent) onKeyDown(event);
1213
- };
1214
- for (const target of targets) target.addEventListener("keydown", listener, capture);
1215
- return () => {
1216
- for (const target of targets) target.removeEventListener("keydown", listener, capture);
1217
- };
1218
- }
1219
- //#endregion
1220
140
  //#region src/feedback/ui/floating/use-details-popover.ts
1221
141
  function useDetailsPopover() {
1222
142
  const [open, setOpen] = useState(false);
@@ -1492,179 +412,6 @@ function AnnotationToolbar({ editor, labels, drawing }) {
1492
412
  });
1493
413
  }
1494
414
  //#endregion
1495
- //#region src/feedback/core/arrow-geometry.ts
1496
- function arrowOutline(start, end) {
1497
- const dx = end.x - start.x;
1498
- const dy = end.y - start.y;
1499
- const length = Math.hypot(dx, dy);
1500
- if (length === 0) return {
1501
- points: [],
1502
- strokeWidth: 0
1503
- };
1504
- const shaftWidth = Math.min(20, Math.max(6, length * .025), length * .2);
1505
- const headLength = Math.min(length * .45, shaftWidth * 3);
1506
- const headHalfWidth = headLength * .55;
1507
- const shaftLength = length - headLength;
1508
- const pointOnArrow = (along, across) => ({
1509
- x: start.x + (dx * along - dy * across) / length,
1510
- y: start.y + (dy * along + dx * across) / length
1511
- });
1512
- return {
1513
- points: [
1514
- pointOnArrow(0, 0),
1515
- pointOnArrow(shaftLength, -shaftWidth / 2),
1516
- pointOnArrow(shaftLength, -headHalfWidth),
1517
- {
1518
- x: end.x,
1519
- y: end.y
1520
- },
1521
- pointOnArrow(shaftLength, headHalfWidth),
1522
- pointOnArrow(shaftLength, shaftWidth / 2)
1523
- ],
1524
- strokeWidth: Math.min(4, length * .02)
1525
- };
1526
- }
1527
- //#endregion
1528
- //#region src/feedback/core/annotation-renderer.ts
1529
- const STROKE_WIDTH = 3;
1530
- const HIGHLIGHT_ALPHA = .3;
1531
- const REDACTION_FILL = "#111827";
1532
- const PIXEL_BLOCK = 10;
1533
- function scalePoint(point, scale) {
1534
- return {
1535
- x: point.x * scale,
1536
- y: point.y * scale
1537
- };
1538
- }
1539
- function scaleRect(rect, scale) {
1540
- return {
1541
- height: rect.height * scale,
1542
- width: rect.width * scale,
1543
- x: rect.x * scale,
1544
- y: rect.y * scale
1545
- };
1546
- }
1547
- function isCanvasElement(value) {
1548
- return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
1549
- }
1550
- function redact(context, rect) {
1551
- const source = context.canvas;
1552
- const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
1553
- const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
1554
- if (isCanvasElement(source)) {
1555
- const offscreen = document.createElement("canvas");
1556
- offscreen.width = width;
1557
- offscreen.height = height;
1558
- const offscreenContext = offscreen.getContext("2d");
1559
- if (offscreenContext) {
1560
- offscreenContext.imageSmoothingEnabled = true;
1561
- offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
1562
- context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
1563
- return;
1564
- }
1565
- }
1566
- context.fillStyle = REDACTION_FILL;
1567
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
1568
- }
1569
- function drawArrow(context, annotation, scale) {
1570
- const { points, strokeWidth } = arrowOutline(annotation.start, annotation.end);
1571
- const [first, ...rest] = points.map((point) => scalePoint(point, scale));
1572
- if (!first) return;
1573
- context.beginPath();
1574
- context.moveTo(first.x, first.y);
1575
- for (const point of rest) context.lineTo(point.x, point.y);
1576
- context.closePath();
1577
- context.lineWidth = strokeWidth * scale;
1578
- context.fill();
1579
- context.stroke();
1580
- }
1581
- function drawPen(context, points, scale) {
1582
- const [first, ...rest] = points;
1583
- if (!first) return;
1584
- const head = scalePoint(first, scale);
1585
- context.beginPath();
1586
- context.moveTo(head.x, head.y);
1587
- for (const point of rest) {
1588
- const scaled = scalePoint(point, scale);
1589
- context.lineTo(scaled.x, scaled.y);
1590
- }
1591
- context.stroke();
1592
- }
1593
- function drawSpotlight(context, rect) {
1594
- const { width, height } = context.canvas;
1595
- context.save();
1596
- context.fillStyle = REDACTION_FILL;
1597
- context.globalAlpha = .55;
1598
- context.fillRect(0, 0, width, rect.y);
1599
- context.fillRect(0, rect.y + rect.height, width, height - rect.y - rect.height);
1600
- context.fillRect(0, rect.y, rect.x, rect.height);
1601
- context.fillRect(rect.x + rect.width, rect.y, width - rect.x - rect.width, rect.height);
1602
- context.restore();
1603
- context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1604
- }
1605
- function drawAnnotation(context, annotation, scale) {
1606
- const { color, tool } = annotation;
1607
- const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
1608
- context.save();
1609
- context.lineWidth = STROKE_WIDTH * scale;
1610
- context.lineCap = "round";
1611
- context.lineJoin = "round";
1612
- context.strokeStyle = color;
1613
- context.fillStyle = color;
1614
- switch (tool) {
1615
- case "text": {
1616
- const lines = (annotation.text ?? "").split("\n");
1617
- const lineHeight = rect.height / lines.length;
1618
- context.font = `${lineHeight / 1.4}px ui-sans-serif, system-ui, sans-serif`;
1619
- context.textBaseline = "top";
1620
- for (const [index, line] of lines.entries()) context.fillText(line, rect.x, rect.y + index * lineHeight);
1621
- break;
1622
- }
1623
- case "pen":
1624
- drawPen(context, annotation.points ?? [], scale);
1625
- break;
1626
- case "arrow":
1627
- drawArrow(context, annotation, scale);
1628
- break;
1629
- case "spotlight":
1630
- drawSpotlight(context, rect);
1631
- break;
1632
- case "rectangle":
1633
- context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1634
- break;
1635
- case "highlight":
1636
- context.globalAlpha = HIGHLIGHT_ALPHA;
1637
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
1638
- break;
1639
- default: redact(context, rect);
1640
- }
1641
- context.restore();
1642
- }
1643
- function drawAnnotations(context, annotations, options) {
1644
- const scale = options?.scale ?? 1;
1645
- for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
1646
- }
1647
- function loadImage(url) {
1648
- return new Promise((resolve, reject) => {
1649
- const image = new Image();
1650
- image.onload = () => resolve(image);
1651
- image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
1652
- image.src = url;
1653
- });
1654
- }
1655
- async function renderAnnotatedImage(source, annotations) {
1656
- if (annotations.length === 0) return null;
1657
- const canvas = document.createElement("canvas");
1658
- canvas.width = source.width;
1659
- canvas.height = source.height;
1660
- const context = canvas.getContext("2d");
1661
- if (!context) return null;
1662
- const image = await loadImage(source.objectUrl);
1663
- context.drawImage(image, 0, 0, source.width, source.height);
1664
- drawAnnotations(context, annotations);
1665
- return await capturedFromCanvas(canvas, source.width);
1666
- }
1667
- //#endregion
1668
415
  //#region src/feedback/ui/annotation/use-annotation-canvas.ts
1669
416
  function useAnnotationCanvas(capture, annotations) {
1670
417
  const canvasRef = useRef(null);
@@ -1918,6 +665,42 @@ function Annotator({ capture, labels, editor, note }) {
1918
665
  });
1919
666
  }
1920
667
  //#endregion
668
+ //#region src/feedback/ui/devtools-owner.ts
669
+ const claims = /* @__PURE__ */ new Map();
670
+ const listeners = /* @__PURE__ */ new Set();
671
+ function notify() {
672
+ for (const listener of listeners) listener();
673
+ }
674
+ function subscribe(listener) {
675
+ listeners.add(listener);
676
+ return () => listeners.delete(listener);
677
+ }
678
+ function currentOwner() {
679
+ for (const [token, claim] of claims) if (claim.hasWidget) return token;
680
+ return claims.keys().next().value;
681
+ }
682
+ /**
683
+ * One devtools bar per page, however many RefletFeedback instances mount:
684
+ * the one with a visible widget wins, so its bar can step aside for its panel.
685
+ */
686
+ function useOwnsDevtools(wantsDevtools, hasWidget) {
687
+ const [token] = useState(() => Symbol("reflet-devtools"));
688
+ useEffect(() => {
689
+ if (!wantsDevtools) return;
690
+ claims.set(token, { hasWidget });
691
+ notify();
692
+ return () => {
693
+ claims.delete(token);
694
+ notify();
695
+ };
696
+ }, [
697
+ hasWidget,
698
+ token,
699
+ wantsDevtools
700
+ ]);
701
+ return useSyncExternalStore(subscribe, () => currentOwner() === token, () => false);
702
+ }
703
+ //#endregion
1921
704
  //#region src/feedback/ui/launcher.tsx
1922
705
  function Launcher({ isOpen, label, onClick, buttonRef }) {
1923
706
  return /* @__PURE__ */ jsxs("button", {
@@ -1960,45 +743,8 @@ function CaptureSlot({ labels, state }) {
1960
743
  }
1961
744
  //#endregion
1962
745
  //#region src/feedback/core/element-capture.ts
1963
- const MIN_CONTEXT_PADDING = 24;
1964
- const MAX_CONTEXT_WIDTH = 2e3;
1965
- const CLOSE_UP_MAX_WIDTH = 1200;
1966
746
  const SELECTION_COLOR = "#4f46e5";
1967
747
  const SELECTION_ANNOTATION_PREFIX = "selection-";
1968
- const OUTLINE_COLOR = "#4f46e5";
1969
- const OUTLINE_GLOW = "#ffffff";
1970
- const OUTLINE_WIDTH = 3;
1971
- const GLOW_WIDTH = 6;
1972
- const DIM_OVERLAY = "rgba(15, 23, 42, 0.45)";
1973
- /**
1974
- * Tightest ancestor that keeps some breathing room around the pick — the zoom
1975
- * stays zoomed instead of growing to a giant section. Only when nothing up
1976
- * the tree clears the minimum does the widest candidate below the cap win.
1977
- */
1978
- function contextRootFor(element) {
1979
- const target = element.getBoundingClientRect();
1980
- const coversAtLeast = (box, margin) => box.left <= target.left - margin && box.right >= target.right + margin && box.top <= target.top - margin && box.bottom >= target.bottom + margin;
1981
- let tightest = null;
1982
- let current = element.parentElement;
1983
- while (current && current !== document.documentElement) {
1984
- const box = current.getBoundingClientRect();
1985
- if (coversAtLeast(box, MIN_CONTEXT_PADDING)) return current;
1986
- tightest ??= box.width < MAX_CONTEXT_WIDTH ? current : null;
1987
- current = current.parentElement;
1988
- }
1989
- return tightest ?? element;
1990
- }
1991
- /** Where the picked element sits inside the context shot, in image pixels. */
1992
- function closeUpGeometry(params) {
1993
- const scale = params.rootRect.width > 0 ? params.capture.width / params.rootRect.width : 1;
1994
- return {
1995
- height: Math.round(params.elementRect.height * scale),
1996
- scale,
1997
- width: Math.round(params.elementRect.width * scale),
1998
- x: Math.round((params.elementRect.left - params.rootRect.left) * scale),
1999
- y: Math.round((params.elementRect.top - params.rootRect.top) * scale)
2000
- };
2001
- }
2002
748
  function isSelectionAnnotation({ id }) {
2003
749
  return id.startsWith(SELECTION_ANNOTATION_PREFIX);
2004
750
  }
@@ -2019,70 +765,6 @@ function highlightFor(selection, capture) {
2019
765
  tool: "rectangle"
2020
766
  };
2021
767
  }
2022
- function drawZoneOverlay(context, zone) {
2023
- context.save();
2024
- context.fillStyle = DIM_OVERLAY;
2025
- context.beginPath();
2026
- context.rect(0, 0, context.canvas.width, context.canvas.height);
2027
- context.rect(zone.x, zone.y, zone.width, zone.height);
2028
- context.fill("evenodd");
2029
- for (const [color, width] of [[OUTLINE_GLOW, GLOW_WIDTH], [OUTLINE_COLOR, OUTLINE_WIDTH]]) {
2030
- context.strokeStyle = color;
2031
- context.lineWidth = width * zone.scale;
2032
- context.strokeRect(zone.x, zone.y, zone.width, zone.height);
2033
- }
2034
- context.restore();
2035
- }
2036
- function drawCloseUp(capture, zone) {
2037
- return new Promise((resolve) => {
2038
- const canvas = document.createElement("canvas");
2039
- canvas.width = capture.width;
2040
- canvas.height = capture.height;
2041
- const context = canvas.getContext("2d");
2042
- const image = new Image();
2043
- image.onload = () => {
2044
- if (!context) {
2045
- resolve(null);
2046
- return;
2047
- }
2048
- context.drawImage(image, 0, 0, capture.width, capture.height);
2049
- drawZoneOverlay(context, zone);
2050
- resolve(capturedFromCanvas(canvas, CLOSE_UP_MAX_WIDTH));
2051
- };
2052
- image.onerror = () => resolve(null);
2053
- image.src = capture.objectUrl;
2054
- });
2055
- }
2056
- /**
2057
- * Zoomed view of the picked zone: the surrounding area captured for context,
2058
- * everything outside the selection dimmed and the zone framed with a glowing
2059
- * outline — unambiguous to eyes and vision models alike.
2060
- * Falls back to the plain context shot when the overlay cannot be drawn.
2061
- */
2062
- async function captureElementCloseUp(element) {
2063
- try {
2064
- const root = contextRootFor(element);
2065
- const rootRect = root.getBoundingClientRect();
2066
- const elementRect = element.getBoundingClientRect();
2067
- const capture = await captureViewport({
2068
- element: root,
2069
- maxWidth: CLOSE_UP_MAX_WIDTH
2070
- });
2071
- if (!capture || root === element) return capture;
2072
- const outlined = await drawCloseUp(capture, closeUpGeometry({
2073
- capture,
2074
- elementRect,
2075
- rootRect
2076
- }));
2077
- if (outlined) {
2078
- releaseCapture(capture);
2079
- return outlined;
2080
- }
2081
- return capture;
2082
- } catch {
2083
- return null;
2084
- }
2085
- }
2086
768
  //#endregion
2087
769
  //#region src/feedback/ui/annotation/screenshot-preview.tsx
2088
770
  function ScreenshotPreview({ annotations, capture }) {
@@ -2458,52 +1140,6 @@ function FeedbackPanel({ floatingControls, labels, state }) {
2458
1140
  });
2459
1141
  }
2460
1142
  //#endregion
2461
- //#region src/feedback/ui/selection-outline.tsx
2462
- /**
2463
- * Keeps the picked element ringed while the reporter writes about it, so the
2464
- * composer parked next to it is visibly about that element.
2465
- */
2466
- function SelectionOutline({ node }) {
2467
- const boxRef = useRef(null);
2468
- useEffect(() => {
2469
- const box = boxRef.current;
2470
- if (!(box && node)) return;
2471
- let frame = 0;
2472
- const draw = () => {
2473
- frame = 0;
2474
- const rect = node.getBoundingClientRect();
2475
- const hidden = rect.width === 0 || rect.height === 0 || rect.bottom < 0 || rect.top > window.innerHeight;
2476
- box.style.opacity = hidden ? "0" : "1";
2477
- box.style.width = `${rect.width}px`;
2478
- box.style.height = `${rect.height}px`;
2479
- box.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
2480
- };
2481
- const schedule = () => {
2482
- frame ||= requestAnimationFrame(draw);
2483
- };
2484
- draw();
2485
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(draw);
2486
- observer?.observe(node);
2487
- window.addEventListener("scroll", schedule, {
2488
- capture: true,
2489
- passive: true
2490
- });
2491
- window.addEventListener("resize", schedule);
2492
- return () => {
2493
- cancelAnimationFrame(frame);
2494
- observer?.disconnect();
2495
- window.removeEventListener("scroll", schedule, true);
2496
- window.removeEventListener("resize", schedule);
2497
- };
2498
- }, [node]);
2499
- if (!node) return null;
2500
- return /* @__PURE__ */ jsx("div", {
2501
- "aria-hidden": "true",
2502
- className: "selection-outline",
2503
- ref: boxRef
2504
- });
2505
- }
2506
- //#endregion
2507
1143
  //#region src/feedback/core/console-recorder.ts
2508
1144
  const DEFAULT_LIMIT = 30;
2509
1145
  const MAX_MESSAGE_LENGTH = 1e3;
@@ -2578,114 +1214,6 @@ function startConsoleRecorder(options) {
2578
1214
  };
2579
1215
  }
2580
1216
  //#endregion
2581
- //#region src/feedback/core/submission/upload-screenshots.ts
2582
- async function uploadImage(transport, image) {
2583
- const { uploadUrl } = await transport.getScreenshotUploadUrl();
2584
- return transport.uploadImage(uploadUrl, image);
2585
- }
2586
- async function prepareScreenshot(transport, screenshot, index) {
2587
- const { closeUp, image, annotations, context } = screenshot;
2588
- const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
2589
- if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
2590
- try {
2591
- const storageId = await uploadImage(transport, image);
2592
- const prepared = [{
2593
- annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
2594
- annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
2595
- captureSource: "widget",
2596
- filename: `screenshot-${index + 1}.png`,
2597
- height: image.height,
2598
- mimeType: image.mimeType,
2599
- pageUrl: context.url,
2600
- size: image.blob.size,
2601
- storageId,
2602
- width: image.width
2603
- }];
2604
- if (closeUp) prepared.push({
2605
- captureSource: "element",
2606
- filename: `element-${index + 1}.png`,
2607
- height: closeUp.height,
2608
- mimeType: closeUp.mimeType,
2609
- pageUrl: context.url,
2610
- size: closeUp.blob.size,
2611
- storageId: await uploadImage(transport, closeUp),
2612
- width: closeUp.width
2613
- });
2614
- return prepared;
2615
- } finally {
2616
- releaseCapture(annotated);
2617
- }
2618
- }
2619
- async function uploadScreenshots(transport, submission) {
2620
- return (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).flatMap((result) => {
2621
- if (result.status === "rejected") throw result.reason;
2622
- return result.value;
2623
- });
2624
- }
2625
- //#endregion
2626
- //#region src/feedback/core/submit.ts
2627
- const MAX_TITLE_LENGTH = 100;
2628
- const NEWLINE = /\r?\n/;
2629
- function deriveTitle(message) {
2630
- const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
2631
- if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
2632
- const clipped = firstLine.slice(0, 99);
2633
- const lastSpace = clipped.lastIndexOf(" ");
2634
- return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
2635
- }
2636
- function buildDescription(params) {
2637
- const { email, isAnonymous, message } = params;
2638
- const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
2639
- return `${message.trim()}${contact}`;
2640
- }
2641
- async function attachPreparedScreenshots(transport, result) {
2642
- const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
2643
- ...screenshot,
2644
- feedbackId: result.feedbackId
2645
- })));
2646
- const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
2647
- return {
2648
- feedbackId: result.feedbackId,
2649
- pendingScreenshots
2650
- };
2651
- }
2652
- async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
2653
- const { feedbackId } = await transport.create({
2654
- context: submission.context,
2655
- description: buildDescription(submission),
2656
- title: deriveTitle(submission.message)
2657
- });
2658
- return attachPreparedScreenshots(transport, {
2659
- feedbackId,
2660
- pendingScreenshots
2661
- });
2662
- }
2663
- async function submitWidgetFeedback(transport, submission) {
2664
- if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
2665
- return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
2666
- }
2667
- function isStorageResponse(value) {
2668
- return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
2669
- }
2670
- function createFeedbackTransport(client) {
2671
- return {
2672
- create: (params) => client.create(params),
2673
- getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
2674
- saveScreenshot: (params) => client.saveScreenshot(params),
2675
- uploadImage: async (uploadUrl, image) => {
2676
- const response = await fetch(uploadUrl, {
2677
- body: image.blob,
2678
- headers: { "Content-Type": image.mimeType },
2679
- method: "POST"
2680
- });
2681
- if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
2682
- const payload = await response.json();
2683
- if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
2684
- return payload.storageId;
2685
- }
2686
- };
2687
- }
2688
- //#endregion
2689
1217
  //#region src/feedback/ui/state/use-feedback-submission.ts
2690
1218
  function useFeedbackSubmission(options) {
2691
1219
  const [activeRequest, setActiveRequest] = useState(null);
@@ -2743,7 +1271,6 @@ function useFeedbackSubmission(options) {
2743
1271
  //#region src/feedback/ui/state/use-screenshot-drafts.ts
2744
1272
  function releaseDraft(draft) {
2745
1273
  releaseCapture(draft?.image);
2746
- releaseCapture(draft?.closeUp);
2747
1274
  }
2748
1275
  function useScreenshotDrafts(captureFailed, onError) {
2749
1276
  const [screenshots, setScreenshots] = useState([]);
@@ -2845,9 +1372,12 @@ function useScreenshotDrafts(captureFailed, onError) {
2845
1372
  }
2846
1373
  } : item));
2847
1374
  }, []);
1375
+ const activeScreenshot = screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null;
1376
+ const firstCapture = pendingCapture ?? screenshots[0];
2848
1377
  return {
2849
- activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
1378
+ activeScreenshot,
2850
1379
  cancelPendingCapture,
1380
+ canRefreshAutomatically: screenshots.length <= 1 && firstCapture?.source === "automatic" && (activeScreenshot?.annotations.length ?? 0) === 0 && !screenshots.some((draft) => draft.selection),
2851
1381
  pendingCapture,
2852
1382
  refreshAutomatic,
2853
1383
  removeCapture,
@@ -3016,9 +1546,7 @@ function useWidgetState(props) {
3016
1546
  partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
3017
1547
  });
3018
1548
  const { resetSubmission } = submission;
3019
- const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
3020
- const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
3021
- useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !gallery.screenshots.some((draft) => draft.selection) && props.captureOnOpen !== false, refreshAutomatic);
1549
+ useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && gallery.canRefreshAutomatically && props.captureOnOpen !== false, refreshAutomatic);
3022
1550
  const reset = useCallback(() => {
3023
1551
  if (successTimer.current) clearTimeout(successTimer.current);
3024
1552
  setAnnotationTrigger(null);
@@ -3071,21 +1599,18 @@ function useWidgetState(props) {
3071
1599
  setStep("compose");
3072
1600
  const pick = ++pickRef.current;
3073
1601
  const context = collectPageContext({ sdkVersion: SDK_VERSION });
3074
- const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
1602
+ const fresh = await captureViewport().catch(() => null);
3075
1603
  if (pickRef.current !== pick) {
3076
1604
  releaseCapture(fresh);
3077
- releaseCapture(closeUp);
3078
1605
  return;
3079
1606
  }
3080
1607
  setIsElementCapturing(false);
3081
1608
  if (!fresh) {
3082
- releaseCapture(closeUp);
3083
1609
  setError(captureFailed);
3084
1610
  return;
3085
1611
  }
3086
1612
  storeScreenshot({
3087
1613
  annotations: [highlightFor(picked, fresh)],
3088
- closeUp,
3089
1614
  context,
3090
1615
  id: crypto.randomUUID(),
3091
1616
  image: fresh,
@@ -3109,7 +1634,7 @@ function useWidgetState(props) {
3109
1634
  setStep("compose");
3110
1635
  return submission.submit({
3111
1636
  context: {
3112
- ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.4.3" }),
1637
+ ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.5.1" }),
3113
1638
  consoleEvents: consoleRef.current?.events(),
3114
1639
  metadata: props.metadata,
3115
1640
  selections
@@ -3173,155 +1698,6 @@ function useWidgetState(props) {
3173
1698
  };
3174
1699
  }
3175
1700
  //#endregion
3176
- //#region src/feedback/ui/visible-viewport.ts
3177
- /**
3178
- * The part of the page the reporter can actually see, in fixed-position
3179
- * coordinates. On mobile the keyboard shrinks this without touching
3180
- * `window.innerHeight`, so anything that must stay reachable measures here.
3181
- */
3182
- function visibleViewport() {
3183
- const viewport = window.visualViewport;
3184
- const left = viewport?.offsetLeft ?? 0;
3185
- const top = viewport?.offsetTop ?? 0;
3186
- const width = viewport?.width ?? window.innerWidth;
3187
- const height = viewport?.height ?? window.innerHeight;
3188
- return {
3189
- bottom: window.innerHeight - top - height,
3190
- height,
3191
- left,
3192
- right: window.innerWidth - left - width,
3193
- top,
3194
- width
3195
- };
3196
- }
3197
- function onViewportChange(update) {
3198
- window.addEventListener("resize", update);
3199
- window.visualViewport?.addEventListener("resize", update);
3200
- window.visualViewport?.addEventListener("scroll", update);
3201
- return () => {
3202
- window.removeEventListener("resize", update);
3203
- window.visualViewport?.removeEventListener("resize", update);
3204
- window.visualViewport?.removeEventListener("scroll", update);
3205
- };
3206
- }
3207
- //#endregion
3208
- //#region src/feedback/ui/floating/use-floating-position.ts
3209
- const EDGE_GAP$1 = 12;
3210
- const KEYBOARD_STEP = 24;
3211
- const KEY_DIRECTIONS = {
3212
- ArrowDown: {
3213
- x: 0,
3214
- y: KEYBOARD_STEP
3215
- },
3216
- ArrowLeft: {
3217
- x: -24,
3218
- y: 0
3219
- },
3220
- ArrowRight: {
3221
- x: KEYBOARD_STEP,
3222
- y: 0
3223
- },
3224
- ArrowUp: {
3225
- x: 0,
3226
- y: -24
3227
- }
3228
- };
3229
- function clampPosition(point, bounds, viewport = visibleViewport()) {
3230
- return {
3231
- x: Math.max(viewport.left + EDGE_GAP$1, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP$1)),
3232
- y: Math.max(viewport.top + EDGE_GAP$1, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP$1))
3233
- };
3234
- }
3235
- function useFloatingPosition(corner = "bottom-right", isPanelOpen = false) {
3236
- const rootRef = useRef(null);
3237
- const gesture = useRef(null);
3238
- const [position, setPosition] = useState(null);
3239
- const [positionedPanelOpen, setPositionedPanelOpen] = useState(isPanelOpen);
3240
- const [viewport, setViewport] = useState(visibleViewport);
3241
- const horizontalEdge = corner.endsWith("right") ? "right" : "left";
3242
- const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
3243
- if (positionedPanelOpen !== isPanelOpen) {
3244
- setPositionedPanelOpen(isPanelOpen);
3245
- setPosition(null);
3246
- }
3247
- const moveTo = useCallback((point, bounds) => {
3248
- const fitted = clampPosition(point, bounds);
3249
- const visible = visibleViewport();
3250
- const anchor = {
3251
- x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
3252
- y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
3253
- };
3254
- setPosition((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
3255
- }, [horizontalEdge, verticalEdge]);
3256
- const keepInViewport = useCallback(() => {
3257
- const bounds = rootRef.current?.getBoundingClientRect();
3258
- if (!bounds) return;
3259
- const fitted = clampPosition(bounds, bounds, viewport);
3260
- if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) moveTo(fitted, bounds);
3261
- }, [moveTo, viewport]);
3262
- useLayoutEffect(() => {
3263
- keepInViewport();
3264
- }, [keepInViewport]);
3265
- useEffect(() => {
3266
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(keepInViewport);
3267
- if (rootRef.current) observer?.observe(rootRef.current);
3268
- const stopListening = onViewportChange(() => setViewport(visibleViewport()));
3269
- return () => {
3270
- observer?.disconnect();
3271
- stopListening();
3272
- };
3273
- }, [keepInViewport]);
3274
- const onPointerDown = (event) => {
3275
- const bounds = rootRef.current?.getBoundingClientRect();
3276
- if (!bounds || event.button !== 0) return;
3277
- event.currentTarget.setPointerCapture(event.pointerId);
3278
- gesture.current = {
3279
- x: event.clientX - bounds.x,
3280
- y: event.clientY - bounds.y
3281
- };
3282
- };
3283
- const onPointerMove = (event) => {
3284
- const bounds = rootRef.current?.getBoundingClientRect();
3285
- const grab = gesture.current;
3286
- if (!(bounds && grab && event.currentTarget.hasPointerCapture(event.pointerId))) return;
3287
- moveTo({
3288
- x: event.clientX - grab.x,
3289
- y: event.clientY - grab.y
3290
- }, bounds);
3291
- };
3292
- const onKeyDown = (event) => {
3293
- const direction = KEY_DIRECTIONS[event.key];
3294
- const bounds = rootRef.current?.getBoundingClientRect();
3295
- if (!(bounds && direction)) return;
3296
- event.preventDefault();
3297
- moveTo({
3298
- x: bounds.x + direction.x,
3299
- y: bounds.y + direction.y
3300
- }, bounds);
3301
- };
3302
- const viewportStyles = `.root { --rf-viewport-width: ${viewport.width}px; --rf-viewport-top: ${viewport.top}px; --rf-viewport-bottom: ${viewport.bottom}px; --rf-viewport-left: ${viewport.left}px; --rf-viewport-right: ${viewport.right}px; }`;
3303
- const anchorStyles = position ? `.root[data-moved="true"] { left: auto; right: auto; top: auto; bottom: auto; ${horizontalEdge}: ${position.x + viewport[horizontalEdge]}px; ${verticalEdge}: ${position.y + viewport[verticalEdge]}px; }` : "";
3304
- return {
3305
- handleProps: {
3306
- onKeyDown,
3307
- onLostPointerCapture: () => {
3308
- gesture.current = null;
3309
- },
3310
- onPointerCancel: () => {
3311
- gesture.current = null;
3312
- },
3313
- onPointerDown,
3314
- onPointerMove,
3315
- onPointerUp: () => {
3316
- gesture.current = null;
3317
- }
3318
- },
3319
- position,
3320
- rootRef,
3321
- styles: viewportStyles + anchorStyles
3322
- };
3323
- }
3324
- //#endregion
3325
1701
  //#region src/feedback/ui/floating/floating-widget.tsx
3326
1702
  function FloatingWidget({ hotkey, labels, position, state }) {
3327
1703
  const [minimized, setMinimized] = useState(false);
@@ -3410,309 +1786,6 @@ function FloatingWidget({ hotkey, labels, position, state }) {
3410
1786
  });
3411
1787
  }
3412
1788
  //#endregion
3413
- //#region src/feedback/ui/floating/element-walk.ts
3414
- const ARROW_SIBLING = {
3415
- ArrowLeft: "previousElementSibling",
3416
- ArrowRight: "nextElementSibling"
3417
- };
3418
- const AIM_KEYS = [
3419
- "Tab",
3420
- "ArrowUp",
3421
- "ArrowDown",
3422
- "ArrowLeft",
3423
- "ArrowRight"
3424
- ];
3425
- function isWidgetOwned(element) {
3426
- return element.closest(`[${WIDGET_MARKER}]`) !== null;
3427
- }
3428
- function isPickable(element) {
3429
- if (!element || element === document.documentElement || isWidgetOwned(element)) return false;
3430
- const rect = element.getBoundingClientRect();
3431
- return rect.width > 0 && rect.height > 0;
3432
- }
3433
- function pickableElements() {
3434
- return Array.from(document.body.querySelectorAll("*")).filter(isPickable);
3435
- }
3436
- function documentStep(from, backwards) {
3437
- const all = pickableElements();
3438
- const index = all.indexOf(from);
3439
- if (all.length === 0) return null;
3440
- if (index === -1) return all[0] ?? null;
3441
- return all[(index + (backwards ? -1 : 1) + all.length) % all.length] ?? null;
3442
- }
3443
- function climb(from) {
3444
- let candidate = from.parentElement;
3445
- while (candidate && !isPickable(candidate)) candidate = candidate.parentElement;
3446
- return candidate;
3447
- }
3448
- function descend(from) {
3449
- let candidate = from.firstElementChild;
3450
- while (candidate && !isPickable(candidate)) candidate = candidate.nextElementSibling;
3451
- return candidate;
3452
- }
3453
- function sibling(from, direction) {
3454
- let candidate = from[direction];
3455
- while (candidate && !isPickable(candidate)) candidate = candidate[direction];
3456
- return candidate;
3457
- }
3458
- function stepAim(from, key, backwards) {
3459
- if (!(from && isPickable(from))) return pickableElements()[0] ?? null;
3460
- if (key === "Tab") return documentStep(from, backwards);
3461
- if (key === "ArrowUp") return climb(from);
3462
- if (key === "ArrowDown") return descend(from);
3463
- const direction = ARROW_SIBLING[key];
3464
- return direction ? sibling(from, direction) : null;
3465
- }
3466
- //#endregion
3467
- //#region src/feedback/ui/picker.tsx
3468
- const LABEL_HEIGHT = 24;
3469
- const LABEL_GAP = 6;
3470
- const NOTE_WIDTH = 300;
3471
- const NOTE_GAP = 10;
3472
- const EDGE_GAP = 12;
3473
- function elementUnder(x, y) {
3474
- const element = document.elementFromPoint(x, y);
3475
- if (!element || isWidgetOwned(element)) return null;
3476
- return element;
3477
- }
3478
- function describeTarget(element) {
3479
- return {
3480
- componentName: resolveComponentStack(getFiberFromNode(element))[0],
3481
- element,
3482
- label: describeElement(element),
3483
- rect: element.getBoundingClientRect(),
3484
- region: describeRegion(element)
3485
- };
3486
- }
3487
- /**
3488
- * Pins the note card to the element: below it, flipped above when the visible
3489
- * viewport — what is left of the screen once a mobile keyboard is up — has no
3490
- * room there. Measures against the part of the element actually on screen, so
3491
- * picking a full-height section still leaves the card in view.
3492
- */
3493
- function noteStyle(rect, view) {
3494
- const left = Math.min(Math.max(view.left + EDGE_GAP, rect.left), Math.max(view.left + EDGE_GAP, view.left + view.width - NOTE_WIDTH - EDGE_GAP));
3495
- const visibleTop = Math.max(rect.top, view.top + EDGE_GAP);
3496
- const visibleBottom = Math.min(rect.bottom, view.top + view.height - EDGE_GAP);
3497
- if (view.top + view.height - visibleBottom > visibleTop - view.top) return {
3498
- left,
3499
- top: visibleBottom + NOTE_GAP
3500
- };
3501
- return {
3502
- bottom: window.innerHeight - visibleTop + NOTE_GAP,
3503
- left
3504
- };
3505
- }
3506
- /**
3507
- * Points at any element in the page the way a coding agent needs it: the React
3508
- * component that owns it plus the page region it lives in. Picking never
3509
- * commits on its own — it pins a note card to the element, so the reporter
3510
- * writes about that element right there and confirms. Aiming stays live until
3511
- * then, which is what makes the flow usable without hover, on touch.
3512
- */
3513
- function ElementPicker({ labels, onCancel, onPick }) {
3514
- const [target, setTarget] = useState(null);
3515
- const [pinned, setPinned] = useState(null);
3516
- const [note, setNote] = useState("");
3517
- const pickerRef = useRef(null);
3518
- const noteRef = useRef(null);
3519
- const pinnedRef = useRef(false);
3520
- const [view, setView] = useState(visibleViewport);
3521
- const aimRef = useRef(null);
3522
- useEffect(() => onViewportChange(() => setView(visibleViewport())), []);
3523
- useEffect(() => {
3524
- pinnedRef.current = pinned !== null;
3525
- if (pinned) noteRef.current?.focus({ preventScroll: true });
3526
- }, [pinned]);
3527
- useEffect(() => {
3528
- aimRef.current = target?.element ?? null;
3529
- }, [target]);
3530
- useEffect(() => {
3531
- const swallow = (event) => {
3532
- if (startsInWidget(event)) return;
3533
- event.preventDefault();
3534
- event.stopPropagation();
3535
- };
3536
- const aimAt = (x, y) => {
3537
- const element = elementUnder(x, y);
3538
- if (!element) return;
3539
- setTarget((current) => current?.element === element ? {
3540
- ...current,
3541
- rect: element.getBoundingClientRect()
3542
- } : describeTarget(element));
3543
- };
3544
- const onMove = (event) => {
3545
- if (pinnedRef.current || startsInWidget(event)) return;
3546
- aimAt(event.clientX, event.clientY);
3547
- };
3548
- const onPointerDown = (event) => {
3549
- if (startsInWidget(event)) return;
3550
- swallow(event);
3551
- if (!pinnedRef.current) aimAt(event.clientX, event.clientY);
3552
- };
3553
- const onPointerUp = (event) => {
3554
- if (startsInWidget(event)) return;
3555
- swallow(event);
3556
- if (pinnedRef.current) return;
3557
- const element = elementUnder(event.clientX, event.clientY);
3558
- if (element) setPinned(describeTarget(element));
3559
- };
3560
- const moveAim = (key, backwards) => {
3561
- const next = stepAim(aimRef.current, key, backwards);
3562
- if (!next) return;
3563
- next.scrollIntoView({
3564
- block: "nearest",
3565
- inline: "nearest"
3566
- });
3567
- aimRef.current = next;
3568
- setTarget(describeTarget(next));
3569
- };
3570
- const onEscape = () => {
3571
- if (pinnedRef.current) {
3572
- setPinned(null);
3573
- setNote("");
3574
- return;
3575
- }
3576
- onCancel();
3577
- };
3578
- const onKeyDown = (event) => {
3579
- if (event.key === "Escape") {
3580
- swallow(event);
3581
- onEscape();
3582
- return;
3583
- }
3584
- if (pinnedRef.current || startsInWidget(event)) return;
3585
- if (event.key === "Enter") {
3586
- swallow(event);
3587
- if (aimRef.current) setPinned(describeTarget(aimRef.current));
3588
- return;
3589
- }
3590
- if (!AIM_KEYS.includes(event.key)) return;
3591
- swallow(event);
3592
- moveAim(event.key, event.shiftKey);
3593
- };
3594
- const onScroll = () => {
3595
- setTarget((current) => current ? {
3596
- ...current,
3597
- rect: current.element.getBoundingClientRect()
3598
- } : current);
3599
- setPinned((current) => current ? {
3600
- ...current,
3601
- rect: current.element.getBoundingClientRect()
3602
- } : current);
3603
- };
3604
- document.addEventListener("pointermove", onMove, true);
3605
- document.addEventListener("pointerdown", onPointerDown, true);
3606
- document.addEventListener("pointerup", onPointerUp, true);
3607
- document.addEventListener("mousedown", swallow, true);
3608
- document.addEventListener("mouseup", swallow, true);
3609
- document.addEventListener("click", swallow, true);
3610
- const stopListeningToKeys = listenToKeydown(pickerRef.current, onKeyDown, true);
3611
- window.addEventListener("scroll", onScroll, {
3612
- capture: true,
3613
- passive: true
3614
- });
3615
- const previousCursor = document.body.style.cursor;
3616
- document.body.style.cursor = "crosshair";
3617
- return () => {
3618
- document.removeEventListener("pointermove", onMove, true);
3619
- document.removeEventListener("pointerdown", onPointerDown, true);
3620
- document.removeEventListener("pointerup", onPointerUp, true);
3621
- document.removeEventListener("mousedown", swallow, true);
3622
- document.removeEventListener("mouseup", swallow, true);
3623
- document.removeEventListener("click", swallow, true);
3624
- stopListeningToKeys();
3625
- window.removeEventListener("scroll", onScroll, true);
3626
- document.body.style.cursor = previousCursor;
3627
- };
3628
- }, [onCancel]);
3629
- const rect = (pinned ?? target)?.rect;
3630
- const labelAbove = rect ? rect.top > 30 : true;
3631
- return /* @__PURE__ */ jsxs("div", {
3632
- ref: pickerRef,
3633
- children: [
3634
- rect && /* @__PURE__ */ jsx("div", {
3635
- className: "picker-box",
3636
- "data-pinned": pinned !== null,
3637
- style: {
3638
- height: rect.height,
3639
- translate: `${rect.left}px ${rect.top}px`,
3640
- width: rect.width
3641
- }
3642
- }),
3643
- rect && target && !pinned && /* @__PURE__ */ jsxs("div", {
3644
- className: "picker-label",
3645
- style: { translate: `${Math.max(4, rect.left)}px ${labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP}px` },
3646
- children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
3647
- }),
3648
- pinned ? /* @__PURE__ */ jsxs("form", {
3649
- className: "picker-note glass",
3650
- onSubmit: (event) => {
3651
- event.preventDefault();
3652
- onPick(pinned.element, note.trim());
3653
- },
3654
- style: noteStyle(pinned.rect, view),
3655
- children: [
3656
- /* @__PURE__ */ jsxs("p", {
3657
- className: "picker-note-target",
3658
- children: [/* @__PURE__ */ jsx("strong", { children: pinned.componentName ? `<${pinned.componentName}>` : pinned.label }), pinned.region && /* @__PURE__ */ jsx("span", { children: pinned.region })]
3659
- }),
3660
- /* @__PURE__ */ jsx("textarea", {
3661
- "aria-label": labels.elementNote,
3662
- maxLength: MAX_SELECTION_COMMENT_LENGTH,
3663
- onChange: (event) => setNote(event.target.value),
3664
- onKeyDown: (event) => {
3665
- if (event.key === "Enter" && !event.shiftKey) {
3666
- event.preventDefault();
3667
- onPick(pinned.element, note.trim());
3668
- }
3669
- },
3670
- placeholder: labels.elementNotePlaceholder,
3671
- ref: noteRef,
3672
- rows: 2,
3673
- value: note
3674
- }),
3675
- /* @__PURE__ */ jsxs("div", {
3676
- className: "picker-note-actions",
3677
- children: [/* @__PURE__ */ jsx("button", {
3678
- className: "picker-cancel",
3679
- onClick: () => {
3680
- setPinned(null);
3681
- setNote("");
3682
- },
3683
- type: "button",
3684
- children: labels.pickAnother
3685
- }), /* @__PURE__ */ jsx("button", {
3686
- "aria-label": labels.attachElement,
3687
- className: "submit",
3688
- title: labels.attachElement,
3689
- type: "submit",
3690
- children: /* @__PURE__ */ jsx(ArrowIcon, {})
3691
- })]
3692
- })
3693
- ]
3694
- }) : /* @__PURE__ */ jsxs("div", {
3695
- className: "picker-hint",
3696
- children: [/* @__PURE__ */ jsxs("span", {
3697
- className: "picker-instruction",
3698
- children: [
3699
- labels.pickElementHint,
3700
- " ",
3701
- /* @__PURE__ */ jsx("kbd", { children: "Tab" }),
3702
- /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
3703
- /* @__PURE__ */ jsx("kbd", { children: "Esc" })
3704
- ]
3705
- }), /* @__PURE__ */ jsx("button", {
3706
- className: "picker-cancel",
3707
- onClick: onCancel,
3708
- type: "button",
3709
- children: labels.cancel
3710
- })]
3711
- })
3712
- ]
3713
- });
3714
- }
3715
- //#endregion
3716
1789
  //#region src/feedback/ui/annotation/styles.ts
3717
1790
  const ANNOTATOR_STYLES = `
3718
1791
  .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.annotatorOverlay}; display: flex; flex-direction: column; background: transparent; color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
@@ -4233,20 +2306,23 @@ function ShadowPortal({ children, offset, primaryColor, theme }) {
4233
2306
  }
4234
2307
  //#endregion
4235
2308
  //#region src/feedback/widget.tsx
2309
+ const DevtoolsLayer = process.env.NODE_ENV === "development" ? lazy(() => import("./devtools-layer-EyK7n9w3.js").then((module) => ({ default: module.DevtoolsLayer }))) : null;
4236
2310
  function RefletFeedback(props) {
4237
- const { enabled = true, hotkey = null, offset = 20, position = "bottom-right", theme = "auto" } = props;
2311
+ const { devtools = true, enabled = true, hotkey = null, offset = 20, position = "bottom-right", theme = "auto" } = props;
4238
2312
  const labels = {
4239
2313
  ...DEFAULT_WIDGET_LABELS,
4240
2314
  ...props.labels
4241
2315
  };
4242
2316
  const state = useWidgetState(props);
4243
2317
  const { isOpen } = state;
4244
- if (!enabled || state.isDismissed) return null;
2318
+ const showsWidget = enabled && !state.isDismissed;
2319
+ const showsDevtools = useOwnsDevtools(devtools && DevtoolsLayer !== null, showsWidget) && DevtoolsLayer !== null;
2320
+ if (!(showsWidget || showsDevtools)) return null;
4245
2321
  return /* @__PURE__ */ jsxs(ShadowPortal, {
4246
2322
  offset,
4247
2323
  primaryColor: props.primaryColor,
4248
2324
  theme,
4249
- children: [
2325
+ children: [showsWidget && /* @__PURE__ */ jsxs(Fragment, { children: [
4250
2326
  /* @__PURE__ */ jsx("div", {
4251
2327
  "aria-hidden": "true",
4252
2328
  className: "capture-halo",
@@ -4283,7 +2359,13 @@ function RefletFeedback(props) {
4283
2359
  onCancel: () => state.setStep("compose"),
4284
2360
  onPick: state.selectElement
4285
2361
  })
4286
- ]
2362
+ ] }), showsDevtools && /* @__PURE__ */ jsx(Suspense, {
2363
+ fallback: null,
2364
+ children: /* @__PURE__ */ jsx(DevtoolsLayer, {
2365
+ isWidgetOpen: showsWidget && isOpen,
2366
+ position
2367
+ })
2368
+ })]
4287
2369
  });
4288
2370
  }
4289
2371
  //#endregion