reflet-sdk 0.4.3 → 0.5.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.
@@ -0,0 +1,1918 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/feedback/core/capture.ts
4
+ /** Marks every node the widget owns, so captures never include the panel. */
5
+ const WIDGET_MARKER = "data-reflet-widget";
6
+ const MAX_PIXEL_RATIO = 2;
7
+ const DEFAULT_MAX_WIDTH = 2400;
8
+ const MAX_INLINE_BYTES = 3e6;
9
+ const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
10
+ const EXTERNAL_URL = /^(https?:)?\/\//i;
11
+ const CSS_URL = /url\((['"]?)([^)'"]+)\1\)/g;
12
+ function isExternalImageUrl(url) {
13
+ if (!url || url.startsWith("data:") || url.startsWith("blob:")) return false;
14
+ if (!EXTERNAL_URL.test(url)) return false;
15
+ try {
16
+ return new URL(url, window.location.href).origin !== window.location.origin;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ function toDataUrl(url) {
22
+ return new Promise((resolve) => {
23
+ fetch(url, { mode: "cors" }).then(async (response) => {
24
+ if (!response.ok) {
25
+ resolve(null);
26
+ return;
27
+ }
28
+ const blob = await response.blob();
29
+ if (blob.size === 0 || blob.size > MAX_INLINE_BYTES) {
30
+ resolve(null);
31
+ return;
32
+ }
33
+ const reader = new FileReader();
34
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
35
+ reader.onerror = () => resolve(null);
36
+ reader.readAsDataURL(blob);
37
+ }).catch(() => resolve(null));
38
+ });
39
+ }
40
+ const PNG_NORMALIZE_TIMEOUT = 750;
41
+ const CAPTURE_TIMEOUT = 1e4;
42
+ const ANIMATION_SETTLE_TIMEOUT = 1e3;
43
+ /** The widget is excluded from every capture, so its own motion (a pulsing pin) is not worth waiting for. */
44
+ function isWidgetAnimation(animation) {
45
+ const root = (animation.effect instanceof KeyframeEffect ? animation.effect.target : null)?.getRootNode();
46
+ return root instanceof ShadowRoot && root.host.hasAttribute("data-reflet-widget");
47
+ }
48
+ /**
49
+ * Tiles and sections routinely animate in or reflow (grid drags, entrance
50
+ * transitions); a capture taken mid-flight freezes them displaced. Waits for
51
+ * animations touching the page to finish, capped so an infinite spinner can
52
+ * never stall the report.
53
+ */
54
+ async function waitForPageAnimations() {
55
+ if (typeof document.getAnimations !== "function") return;
56
+ const start = performance.now();
57
+ while (performance.now() - start < ANIMATION_SETTLE_TIMEOUT) {
58
+ const running = document.getAnimations().filter((animation) => animation.playState === "running" && !isWidgetAnimation(animation));
59
+ if (running.length === 0) return;
60
+ await Promise.race([Promise.allSettled(running.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, 150))]);
61
+ }
62
+ }
63
+ /**
64
+ * snapdom awaits `decode()` on images created with `decoding="sync"` — a
65
+ * promise Chrome 151 sometimes never settles (observed for cross-origin
66
+ * webp, cached or inlined). Re-encoding through a canvas yields a PNG data
67
+ * url, which decodes reliably; the load itself goes through `onload`, never
68
+ * `decode()`.
69
+ */
70
+ function normalizeToPng(dataUrl) {
71
+ return new Promise((resolve) => {
72
+ const image = new Image();
73
+ const timeout = setTimeout(() => resolve(dataUrl), PNG_NORMALIZE_TIMEOUT);
74
+ image.onload = () => {
75
+ clearTimeout(timeout);
76
+ try {
77
+ if (image.naturalWidth === 0 || image.naturalHeight === 0) {
78
+ resolve(null);
79
+ return;
80
+ }
81
+ const canvas = document.createElement("canvas");
82
+ canvas.width = image.naturalWidth;
83
+ canvas.height = image.naturalHeight;
84
+ const context = canvas.getContext("2d");
85
+ if (!context) {
86
+ resolve(dataUrl);
87
+ return;
88
+ }
89
+ context.drawImage(image, 0, 0);
90
+ resolve(canvas.toDataURL("image/png"));
91
+ } catch {
92
+ resolve(null);
93
+ }
94
+ };
95
+ image.onerror = () => {
96
+ clearTimeout(timeout);
97
+ resolve(null);
98
+ };
99
+ image.src = dataUrl;
100
+ });
101
+ }
102
+ async function toInlineUrl(url) {
103
+ const dataUrl = await toDataUrl(url);
104
+ if (!dataUrl) return null;
105
+ return normalizeToPng(dataUrl);
106
+ }
107
+ function remember(swaps, element, name) {
108
+ swaps.push({
109
+ element,
110
+ name,
111
+ value: element.getAttribute(name)
112
+ });
113
+ }
114
+ async function inlineImgSource(img, swaps) {
115
+ const src = img.getAttribute("src");
116
+ if (!(src && isExternalImageUrl(src))) return;
117
+ const dataUrl = await toInlineUrl(src);
118
+ if (dataUrl) {
119
+ if (img.hasAttribute("srcset")) {
120
+ remember(swaps, img, "srcset");
121
+ img.removeAttribute("srcset");
122
+ }
123
+ if (img.hasAttribute("sizes")) {
124
+ remember(swaps, img, "sizes");
125
+ img.removeAttribute("sizes");
126
+ }
127
+ }
128
+ remember(swaps, img, "src");
129
+ img.setAttribute("src", dataUrl ?? TRANSPARENT_PIXEL);
130
+ }
131
+ async function inlineBackgroundImages(element, swaps) {
132
+ const style = element.getAttribute("style");
133
+ if (!style) return;
134
+ const urls = [...style.matchAll(CSS_URL)].map((match) => match[2] ?? "");
135
+ if (!urls.some(isExternalImageUrl)) return;
136
+ let next = style;
137
+ for (const url of urls) {
138
+ if (!isExternalImageUrl(url)) continue;
139
+ const dataUrl = await toInlineUrl(url);
140
+ next = next.split(url).join(dataUrl ?? TRANSPARENT_PIXEL);
141
+ }
142
+ if (next !== style) {
143
+ remember(swaps, element, "style");
144
+ element.setAttribute("style", next);
145
+ }
146
+ }
147
+ /**
148
+ * Swaps every cross-origin image under `root` for a data URL so the capture
149
+ * cannot hit snapdom's decode-failure hang. Returns the undo list — always
150
+ * run it, even after a failed capture.
151
+ */
152
+ async function inlineExternalImages(root) {
153
+ const swaps = [];
154
+ for (const img of [...root.querySelectorAll("img")]) await inlineImgSource(img, swaps);
155
+ for (const el of [...root.querySelectorAll("[style*='url(']")]) await inlineBackgroundImages(el, swaps);
156
+ return swaps;
157
+ }
158
+ function restoreInlinedImages(swaps) {
159
+ for (const swap of swaps) if (swap.value === null) swap.element.removeAttribute(swap.name);
160
+ else swap.element.setAttribute(swap.name, swap.value);
161
+ }
162
+ function buildSnapdomOptions(options) {
163
+ const ratio = options.devicePixelRatio ?? 1;
164
+ return {
165
+ cache: "disabled",
166
+ clip: options.element ? void 0 : "viewport",
167
+ dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
168
+ embedFonts: true,
169
+ exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
170
+ fast: true,
171
+ filter: options.filter,
172
+ reconcile: true
173
+ };
174
+ }
175
+ function fitWithin(size, maxWidth) {
176
+ if (size.width <= maxWidth) return {
177
+ height: size.height,
178
+ scale: 1,
179
+ width: size.width
180
+ };
181
+ const scale = maxWidth / size.width;
182
+ return {
183
+ height: Math.max(1, Math.round(size.height * scale)),
184
+ scale,
185
+ width: maxWidth
186
+ };
187
+ }
188
+ function toBlob(canvas) {
189
+ return new Promise((resolve) => {
190
+ canvas.toBlob(resolve, "image/png");
191
+ });
192
+ }
193
+ function downscale(canvas, target) {
194
+ const resized = document.createElement("canvas");
195
+ resized.width = target.width;
196
+ resized.height = target.height;
197
+ const context = resized.getContext("2d");
198
+ if (!context) return canvas;
199
+ context.imageSmoothingQuality = "high";
200
+ context.drawImage(canvas, 0, 0, target.width, target.height);
201
+ return resized;
202
+ }
203
+ /** Wraps a rendered canvas as an owned capture, downscaled to `maxWidth`. */
204
+ async function capturedFromCanvas(source, maxWidth) {
205
+ const fitted = fitWithin({
206
+ height: source.height,
207
+ width: source.width
208
+ }, maxWidth);
209
+ const output = fitted.scale === 1 ? source : downscale(source, fitted);
210
+ const blob = await toBlob(output);
211
+ if (!blob) return null;
212
+ return {
213
+ blob,
214
+ height: output.height,
215
+ mimeType: "image/png",
216
+ objectUrl: URL.createObjectURL(blob),
217
+ width: output.width
218
+ };
219
+ }
220
+ /**
221
+ * Renders what the user is looking at right now.
222
+ *
223
+ * Runs off the DOM rather than `getDisplayMedia` on purpose: a screen-share
224
+ * permission prompt on every report would cost more reports than it captures.
225
+ * Returns null instead of throwing — a screenshot is never worth losing the
226
+ * written feedback.
227
+ */
228
+ async function captureViewport(options = {}) {
229
+ try {
230
+ const { snapdom } = await import("@zumer/snapdom");
231
+ const target = options.element ?? document.documentElement;
232
+ await waitForPageAnimations();
233
+ const swaps = await inlineExternalImages(target);
234
+ const shoot = (withoutImages = false) => {
235
+ const attempt = snapdom.toCanvas(target, buildSnapdomOptions({
236
+ devicePixelRatio: window.devicePixelRatio,
237
+ element: options.element,
238
+ excludeSelectors: options.excludeSelectors,
239
+ filter: withoutImages ? (element) => element.tagName !== "IMG" : void 0
240
+ }));
241
+ attempt.catch(() => void 0);
242
+ return Promise.race([attempt, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("capture timed out")), options.timeout ?? CAPTURE_TIMEOUT))]);
243
+ };
244
+ let canvas;
245
+ try {
246
+ canvas = await shoot();
247
+ } catch {
248
+ canvas = await shoot(true);
249
+ } finally {
250
+ restoreInlinedImages(swaps);
251
+ }
252
+ return await capturedFromCanvas(canvas, options.maxWidth ?? DEFAULT_MAX_WIDTH);
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+ function releaseCapture(image) {
258
+ if (image) URL.revokeObjectURL(image.objectUrl);
259
+ }
260
+ //#endregion
261
+ //#region src/feedback/core/react-source.ts
262
+ const FIBER_KEY_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
263
+ const MEMO_TYPE = Symbol.for("react.memo");
264
+ const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
265
+ const MAX_STACK_DEPTH = 6;
266
+ const MAX_FIBER_WALK = 60;
267
+ const MAX_OWNER_WALK = 8;
268
+ const MIN_NAME_LENGTH = 4;
269
+ const CAPITALIZED = /^[A-Z]/;
270
+ const WORD_INSIDE = /[a-z]{2}/;
271
+ const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
272
+ const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
273
+ const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
274
+ const NON_SOURCE_FRAME = /node_modules|\/_next\/|\/chunks?\/|\/\.vite\/|\/dist\/|\/build\/|hot-update/;
275
+ const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
276
+ const SOURCE_ROOT_MARKERS = [
277
+ "src",
278
+ "app",
279
+ "pages",
280
+ "components",
281
+ "features",
282
+ "lib"
283
+ ];
284
+ function isRecord(value) {
285
+ return typeof value === "object" && value !== null;
286
+ }
287
+ /** React attaches the fiber under a randomly suffixed key on the host node. */
288
+ function getFiberFromNode(node) {
289
+ for (const key of Object.getOwnPropertyNames(node)) for (const prefix of FIBER_KEY_PREFIXES) if (key.startsWith(prefix)) return Reflect.get(node, key) ?? null;
290
+ return null;
291
+ }
292
+ function readName(value) {
293
+ if (typeof value !== "function" && !isRecord(value)) return null;
294
+ const displayName = Reflect.get(value, "displayName");
295
+ if (typeof displayName === "string" && displayName) return displayName;
296
+ const name = Reflect.get(value, "name");
297
+ return typeof name === "string" && name ? name : null;
298
+ }
299
+ /**
300
+ * Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
301
+ * owning component actively misleads an agent, so only real words get through.
302
+ */
303
+ function isMeaningfulName(name) {
304
+ if (name.includes("/") || name.includes("_")) return true;
305
+ return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
306
+ }
307
+ function componentName(type, depth = 0) {
308
+ if (typeof type === "string" || type === null || depth > 3) return null;
309
+ if (isRecord(type)) {
310
+ const marker = Reflect.get(type, "$$typeof");
311
+ if (marker === MEMO_TYPE) return componentName(Reflect.get(type, "type"), depth + 1);
312
+ if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
313
+ }
314
+ const name = readName(type);
315
+ return name && isMeaningfulName(name) ? name : null;
316
+ }
317
+ /** Component names owning the fiber, innermost first. */
318
+ function resolveComponentStack(fiber) {
319
+ const stack = [];
320
+ let current = fiber;
321
+ let steps = 0;
322
+ while (isRecord(current) && steps < MAX_FIBER_WALK) {
323
+ const name = componentName(Reflect.get(current, "type")) ?? componentName(Reflect.get(current, "elementType"));
324
+ if (name && stack.at(-1) !== name) {
325
+ stack.push(name);
326
+ if (stack.length >= MAX_STACK_DEPTH) break;
327
+ }
328
+ current = Reflect.get(current, "return");
329
+ steps++;
330
+ }
331
+ return stack;
332
+ }
333
+ function shortenSourcePath(raw) {
334
+ const path = raw.replace(WEBPACK_PREFIX, "").replace(ORIGIN_PREFIX, "").split("?")[0];
335
+ if (!path) return raw;
336
+ for (const marker of SOURCE_ROOT_MARKERS) {
337
+ if (path.startsWith(`${marker}/`)) return path;
338
+ const index = path.lastIndexOf(`/${marker}/`);
339
+ if (index !== -1) return path.slice(index + 1);
340
+ }
341
+ return path.split("/").filter(Boolean).slice(-3).join("/");
342
+ }
343
+ function formatLocation(fileName, line, column) {
344
+ const base = `${shortenSourcePath(fileName)}:${line}`;
345
+ return column === void 0 ? base : `${base}:${column}`;
346
+ }
347
+ function fromDebugSource(source) {
348
+ if (!isRecord(source)) return;
349
+ const fileName = Reflect.get(source, "fileName");
350
+ const lineNumber = Reflect.get(source, "lineNumber");
351
+ if (typeof fileName !== "string" || typeof lineNumber !== "number") return;
352
+ const columnNumber = Reflect.get(source, "columnNumber");
353
+ return formatLocation(fileName, lineNumber, typeof columnNumber === "number" ? columnNumber : void 0);
354
+ }
355
+ function fromDebugStack(stack) {
356
+ const raw = isRecord(stack) ? Reflect.get(stack, "stack") : stack;
357
+ if (typeof raw !== "string") return;
358
+ for (const line of raw.split("\n")) {
359
+ const match = STACK_LOCATION.exec(line);
360
+ const file = match?.[1];
361
+ if (!(match && file) || NON_SOURCE_FRAME.test(file)) continue;
362
+ const path = file.replace(ORIGIN_PREFIX, "").split("?")[0] ?? "";
363
+ if (!SOURCE_EXTENSION.test(path)) continue;
364
+ return formatLocation(path, Number(match[2]), Number(match[3]));
365
+ }
366
+ }
367
+ /**
368
+ * `src/components/login-form.tsx:46:19` when the React build exposes it.
369
+ * Production bundles strip debug info — callers must treat this as optional.
370
+ */
371
+ function resolveSourceLocation(fiber) {
372
+ let current = fiber;
373
+ let steps = 0;
374
+ while (isRecord(current) && steps < MAX_OWNER_WALK) {
375
+ const located = fromDebugSource(Reflect.get(current, "_debugSource")) ?? fromDebugStack(Reflect.get(current, "_debugStack"));
376
+ if (located) return located;
377
+ current = Reflect.get(current, "_debugOwner");
378
+ steps++;
379
+ }
380
+ }
381
+ function inspectReactElement(element) {
382
+ const fiber = getFiberFromNode(element);
383
+ return {
384
+ componentStack: resolveComponentStack(fiber),
385
+ sourceLocation: resolveSourceLocation(fiber)
386
+ };
387
+ }
388
+ //#endregion
389
+ //#region src/feedback/core/redact.ts
390
+ /**
391
+ * The picked element's markup leaves the reporter's browser and ends up in an
392
+ * agent prompt, so it is scrubbed here: typed-in values, credentials and
393
+ * anything marked `data-reflet-redact` never make the trip.
394
+ */
395
+ const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
396
+ const LONG_TOKEN = /\b[\w-]{28,}\b/g;
397
+ const OPAQUE_URI = /^(?:data|blob|javascript):/i;
398
+ const WHITESPACE_RUN = /\s+/g;
399
+ const URL_QUERY = /[?#]/;
400
+ const REDACTED = "[redacted]";
401
+ const ELLIPSIS = "…";
402
+ const MAX_ATTRIBUTE_LENGTH = 120;
403
+ const MAX_TEXT_LENGTH$1 = 200;
404
+ const MAX_CHILD_DEPTH = 3;
405
+ const MAX_CLONED_NODES = 400;
406
+ const DROPPED_TAGS = /* @__PURE__ */ new Set([
407
+ "script",
408
+ "style",
409
+ "noscript",
410
+ "template"
411
+ ]);
412
+ const VALUE_HOLDERS = /* @__PURE__ */ new Set([
413
+ "input",
414
+ "textarea",
415
+ "select",
416
+ "option"
417
+ ]);
418
+ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
419
+ "src",
420
+ "srcset",
421
+ "href",
422
+ "poster",
423
+ "action",
424
+ "formaction"
425
+ ]);
426
+ function truncate$1(value, max) {
427
+ return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
428
+ }
429
+ /** Emails and token-shaped strings, wherever they appear in reported text. */
430
+ function maskSecrets(value) {
431
+ return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
432
+ }
433
+ const SENSITIVE_PARAM = /token|secret|pass(?:word|wd)?|pwd|auth|jwt|credential|session|sid|key|email|ssn|otp|code|signature|nonce/i;
434
+ const LEADING_QUESTION_MARK = /^\?/;
435
+ function decoded(value) {
436
+ try {
437
+ return decodeURIComponent(value);
438
+ } catch {
439
+ return value;
440
+ }
441
+ }
442
+ /**
443
+ * Query strings carry signed-in state (`?token=…`, `?userEmail=…`) straight
444
+ * into stored reports and agent prompts. Values of anything credential- or
445
+ * identity-shaped are masked; every other byte of the address stays intact.
446
+ */
447
+ function redactUrl(href) {
448
+ let url;
449
+ try {
450
+ url = new URL(href);
451
+ } catch {
452
+ return maskSecrets(href);
453
+ }
454
+ const pairs = url.search.replace(LEADING_QUESTION_MARK, "").split("&").filter(Boolean);
455
+ if (!pairs.some((pair) => SENSITIVE_PARAM.test(decoded(pair.split("=")[0] ?? "")))) return href;
456
+ const query = pairs.map((pair) => {
457
+ const equals = pair.indexOf("=");
458
+ if (equals === -1 || !SENSITIVE_PARAM.test(decoded(pair.slice(0, equals)))) return pair;
459
+ return `${pair.slice(0, equals)}=${REDACTED}`;
460
+ }).join("&");
461
+ return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
462
+ }
463
+ function sanitizeUrl(value) {
464
+ if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
465
+ return value.split(URL_QUERY)[0] ?? value;
466
+ }
467
+ function sanitizeAttributes(element) {
468
+ const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
469
+ for (const { name, value } of Array.from(element.attributes)) {
470
+ if (holdsTypedValue && name === "value") {
471
+ element.removeAttribute(name);
472
+ continue;
473
+ }
474
+ const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
475
+ element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
476
+ }
477
+ }
478
+ function collapse(element) {
479
+ element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
480
+ }
481
+ function sanitizeTree(element, depth) {
482
+ sanitizeAttributes(element);
483
+ if (element.hasAttribute("data-reflet-redact")) {
484
+ element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
485
+ return;
486
+ }
487
+ if (element.localName === "svg") {
488
+ element.replaceChildren();
489
+ return;
490
+ }
491
+ for (const child of Array.from(element.childNodes)) {
492
+ if (child.nodeType === Node.COMMENT_NODE) {
493
+ child.remove();
494
+ continue;
495
+ }
496
+ if (child.nodeType === Node.TEXT_NODE) {
497
+ child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
498
+ continue;
499
+ }
500
+ if (!(child instanceof Element)) continue;
501
+ if (DROPPED_TAGS.has(child.localName)) {
502
+ child.remove();
503
+ continue;
504
+ }
505
+ if (depth >= MAX_CHILD_DEPTH) {
506
+ sanitizeAttributes(child);
507
+ collapse(child);
508
+ continue;
509
+ }
510
+ sanitizeTree(child, depth + 1);
511
+ }
512
+ }
513
+ /** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
514
+ function sanitizeMarkup(element, maxLength) {
515
+ const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
516
+ const clone = element.cloneNode(isSmallEnough);
517
+ if (!(clone instanceof Element)) return "";
518
+ sanitizeTree(clone, 0);
519
+ if (!isSmallEnough) collapse(clone);
520
+ return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
521
+ }
522
+ //#endregion
523
+ //#region src/feedback/core/element-selector.ts
524
+ const SAFE_ID = /^[A-Za-z][\w-]*$/;
525
+ const WHITESPACE = /\s+/g;
526
+ const MAX_LABEL_LENGTH = 80;
527
+ const MAX_HTML_LENGTH = 600;
528
+ const MAX_TEXT_LENGTH = 300;
529
+ const MAX_REGION_LENGTH = 160;
530
+ const MAX_HANDLE_VALUE_LENGTH = 80;
531
+ const MAX_DEPTH = 12;
532
+ const MIN_PATH_SEGMENTS = 2;
533
+ const MAX_SELECTOR_LENGTH = 600;
534
+ const MAX_SOURCE_LOCATION_LENGTH = 400;
535
+ const MAX_SELECTION_COMMENT_LENGTH = 2e3;
536
+ const TEST_ATTRIBUTES = [
537
+ "data-testid",
538
+ "data-test-id",
539
+ "data-test",
540
+ "data-cy",
541
+ "data-qa"
542
+ ];
543
+ const NAMING_ATTRIBUTES = ["name", "aria-label"];
544
+ const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
545
+ const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
546
+ /** Elements that start a new visual block — their text gets separated by a space. */
547
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
548
+ "address",
549
+ "article",
550
+ "aside",
551
+ "blockquote",
552
+ "dd",
553
+ "details",
554
+ "div",
555
+ "dl",
556
+ "dt",
557
+ "fieldset",
558
+ "figcaption",
559
+ "figure",
560
+ "footer",
561
+ "form",
562
+ "h1",
563
+ "h2",
564
+ "h3",
565
+ "h4",
566
+ "h5",
567
+ "h6",
568
+ "header",
569
+ "hr",
570
+ "li",
571
+ "main",
572
+ "nav",
573
+ "ol",
574
+ "p",
575
+ "pre",
576
+ "section",
577
+ "summary",
578
+ "table",
579
+ "td",
580
+ "th",
581
+ "tr",
582
+ "ul"
583
+ ]);
584
+ function visibleText(element) {
585
+ const clone = element.cloneNode(true);
586
+ if (!(clone instanceof Element)) return "";
587
+ for (const node of clone.querySelectorAll("*")) if (BLOCK_TAGS.has(node.localName)) node.before(clone.ownerDocument.createTextNode(" "));
588
+ return collapseText(maskSecrets(clone.textContent ?? ""));
589
+ }
590
+ function isUnique(selector) {
591
+ try {
592
+ return document.querySelectorAll(selector).length === 1;
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ function escapeAttributeValue(value) {
598
+ return value.replace(/["\\]/g, "\\$&");
599
+ }
600
+ function attributeHandle(element, attribute, qualifier) {
601
+ const value = element.getAttribute(attribute);
602
+ if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
603
+ const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
604
+ return isUnique(selector) ? selector : null;
605
+ }
606
+ /** Something an engineer would have written on purpose, unlike a positional path. */
607
+ function uniqueHandle(element) {
608
+ const id = element.getAttribute("id");
609
+ if (id && SAFE_ID.test(id) && isUnique(`#${id}`)) return `#${id}`;
610
+ for (const attribute of TEST_ATTRIBUTES) {
611
+ const handle = attributeHandle(element, attribute, "");
612
+ if (handle) return handle;
613
+ }
614
+ for (const attribute of NAMING_ATTRIBUTES) {
615
+ const handle = attributeHandle(element, attribute, element.localName);
616
+ if (handle) return handle;
617
+ }
618
+ return null;
619
+ }
620
+ function positionalSegment(element) {
621
+ const tag = element.localName;
622
+ const parent = element.parentElement;
623
+ if (!parent) return tag;
624
+ const sameTag = Array.from(parent.children).filter((child) => child.localName === tag);
625
+ if (sameTag.length <= 1) return tag;
626
+ return `${tag}:nth-of-type(${sameTag.indexOf(element) + 1})`;
627
+ }
628
+ /**
629
+ * Shortest selector that still resolves back to `element` from the document.
630
+ * Grows the path one ancestor at a time and stops as soon as it is unique, so
631
+ * a stable handle anywhere up the tree replaces the whole positional chain.
632
+ * A bare tag is never enough: positional paths keep a parent for context.
633
+ */
634
+ function buildSelector(element) {
635
+ if (element === document.documentElement) return "html";
636
+ if (element === document.body) return "body";
637
+ const handle = uniqueHandle(element);
638
+ if (handle) return handle;
639
+ const segments = [];
640
+ let current = element;
641
+ for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
642
+ const ancestorHandle = current === element ? null : uniqueHandle(current);
643
+ segments.unshift(ancestorHandle ?? positionalSegment(current));
644
+ const candidate = segments.join(" > ");
645
+ const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
646
+ if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
647
+ current = current.parentElement;
648
+ }
649
+ return segments.join(" > ");
650
+ }
651
+ function truncate(value, max) {
652
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
653
+ }
654
+ function collapseText(value) {
655
+ return value.replace(WHITESPACE, " ").trim();
656
+ }
657
+ function accessibleLabel(element) {
658
+ const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
659
+ if (explicit?.trim()) return explicit.trim();
660
+ return collapseText(element.textContent ?? "");
661
+ }
662
+ /** Short human label such as `button "Sign in"` or `div.card`. */
663
+ function describeElement(element) {
664
+ const tag = element.localName;
665
+ const label = maskSecrets(accessibleLabel(element));
666
+ if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
667
+ const firstClass = element.classList.item(0);
668
+ return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
669
+ }
670
+ function describeLandmark(element) {
671
+ const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
672
+ if (!landmark) return null;
673
+ const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
674
+ return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
675
+ }
676
+ function headingAbove(element) {
677
+ const scope = element.closest(LANDMARK_SELECTOR) ?? document.body;
678
+ const probe = document.createRange();
679
+ probe.selectNodeContents(element);
680
+ probe.collapse(true);
681
+ let closest = null;
682
+ for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
683
+ return collapseText(closest?.textContent ?? "") || null;
684
+ }
685
+ /**
686
+ * Where the element sits from a reader's point of view — `dialog "Members" ›
687
+ * Danger zone` says more to an agent than any DOM path can.
688
+ */
689
+ function describeRegion(element) {
690
+ const landmark = describeLandmark(element);
691
+ const heading = headingAbove(element);
692
+ const parts = [landmark];
693
+ if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
694
+ const region = parts.filter((part) => part !== null).join(" › ");
695
+ return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
696
+ }
697
+ function getElementRect(element) {
698
+ const rect = element.getBoundingClientRect();
699
+ return {
700
+ height: Math.round(rect.height),
701
+ width: Math.round(rect.width),
702
+ x: Math.round(rect.x),
703
+ y: Math.round(rect.y)
704
+ };
705
+ }
706
+ /** Everything an agent needs to find the picked element back in the codebase. */
707
+ function buildElementSelection(element) {
708
+ const { componentStack, sourceLocation } = inspectReactElement(element);
709
+ const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
710
+ return {
711
+ componentStack,
712
+ html: sanitizeMarkup(element, MAX_HTML_LENGTH),
713
+ label: describeElement(element),
714
+ rect: getElementRect(element),
715
+ region: describeRegion(element),
716
+ selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
717
+ sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
718
+ text: text || void 0
719
+ };
720
+ }
721
+ //#endregion
722
+ //#region src/feedback/core/page-context.ts
723
+ const BROWSER_PATTERNS = [
724
+ ["Edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
725
+ ["Opera", /OPR\/([\d.]+)/],
726
+ ["Samsung Internet", /SamsungBrowser\/([\d.]+)/],
727
+ ["Firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
728
+ ["Chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
729
+ ["Safari", /Version\/([\d.]+).*Safari/]
730
+ ];
731
+ const OS_PATTERNS = [
732
+ ["iOS", /iPhone|iPad|iPod/],
733
+ ["Android", /Android/],
734
+ ["Windows", /Windows NT/],
735
+ ["macOS", /Mac OS X|Macintosh/],
736
+ ["ChromeOS", /CrOS/],
737
+ ["Linux", /Linux|X11/]
738
+ ];
739
+ const MOBILE_HINT = /Mobile|iPhone|iPod/;
740
+ const TABLET_HINT = /iPad|Tablet/;
741
+ const TRAILING_ZERO = /\.0$/;
742
+ const ANDROID = /Android/;
743
+ function shortenVersion(version) {
744
+ return version.split(".").slice(0, 2).join(".").replace(TRAILING_ZERO, "");
745
+ }
746
+ function detectDevice(userAgent) {
747
+ if (TABLET_HINT.test(userAgent)) return "tablet";
748
+ if (MOBILE_HINT.test(userAgent)) return "mobile";
749
+ return ANDROID.test(userAgent) ? "tablet" : "desktop";
750
+ }
751
+ function parseUserAgent(userAgent) {
752
+ const info = { device: detectDevice(userAgent) };
753
+ for (const [name, pattern] of BROWSER_PATTERNS) {
754
+ const version = pattern.exec(userAgent)?.[1];
755
+ if (version) {
756
+ info.browser = `${name} ${shortenVersion(version)}`;
757
+ break;
758
+ }
759
+ }
760
+ for (const [name, pattern] of OS_PATTERNS) if (pattern.test(userAgent)) {
761
+ info.os = name;
762
+ break;
763
+ }
764
+ return info;
765
+ }
766
+ const MAX_PAGE_TITLE_LENGTH = 300;
767
+ const MAX_URL_LENGTH = 2e3;
768
+ const MAX_USER_AGENT_LENGTH = 600;
769
+ function clip(value, max) {
770
+ return value.length <= max ? value : value.slice(0, max);
771
+ }
772
+ function optionalText(value, max) {
773
+ return value?.trim() ? clip(value, max) : void 0;
774
+ }
775
+ function resolveTimezone() {
776
+ try {
777
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
778
+ } catch {}
779
+ }
780
+ /** Everything about the page that a bug report needs and the DOM already knows. */
781
+ function collectPageContext(options) {
782
+ const userAgent = navigator.userAgent;
783
+ const agent = parseUserAgent(userAgent);
784
+ return {
785
+ browser: agent.browser,
786
+ device: agent.device,
787
+ language: navigator.language,
788
+ os: agent.os,
789
+ pageTitle: optionalText(document.title, MAX_PAGE_TITLE_LENGTH),
790
+ referrer: optionalText(document.referrer, MAX_URL_LENGTH),
791
+ screen: {
792
+ height: window.screen.height,
793
+ width: window.screen.width
794
+ },
795
+ scroll: {
796
+ x: window.scrollX,
797
+ y: window.scrollY
798
+ },
799
+ sdkVersion: options?.sdkVersion,
800
+ timezone: resolveTimezone(),
801
+ url: redactUrl(window.location.href),
802
+ userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
803
+ viewport: {
804
+ devicePixelRatio: window.devicePixelRatio,
805
+ height: window.innerHeight,
806
+ width: window.innerWidth
807
+ }
808
+ };
809
+ }
810
+ //#endregion
811
+ //#region src/feedback/types.ts
812
+ const SDK_VERSION = "0.5.0";
813
+ const DEFAULT_WIDGET_LABELS = {
814
+ annotateHint: "Draw on the screenshot to point at the problem.",
815
+ attachElement: "Attach this element",
816
+ attachmentUploadFailed: "Your feedback was saved, but some screenshots are still pending. Retry to attach them.",
817
+ attachScreenshot: "Attach a screenshot",
818
+ back: "Back",
819
+ cancel: "Cancel",
820
+ captureFailed: "Screenshot unavailable. Try again or send without it.",
821
+ capturing: "Taking screenshot…",
822
+ clearAnnotations: "Clear drawing",
823
+ descriptionPlaceholder: "What would you like to share?",
824
+ dismissForDays: "Hide for {days} days",
825
+ done: "Done",
826
+ elementNote: "Comment on the picked element",
827
+ elementNotePlaceholder: "What is wrong with this?",
828
+ emailInvalid: "Enter a valid email address.",
829
+ emailLabel: "Email",
830
+ emailPlaceholder: "you@company.com",
831
+ emailPromptBody: "We only use it to follow up on this report.",
832
+ emailPromptTitle: "Add your email?",
833
+ errorGeneric: "Something went wrong. Please try again.",
834
+ errorTitleRequired: "Tell us a bit about it first.",
835
+ minimize: "Minimize feedback",
836
+ moreOptions: "More options",
837
+ moveFeedback: "Move feedback",
838
+ pickAnother: "Pick another",
839
+ pickElement: "Point at an element",
840
+ pickElementHint: "Pick the element you are talking about",
841
+ recapture: "Retake",
842
+ removeScreenshot: "Remove",
843
+ resume: "Resume feedback",
844
+ retakeHint: "Screenshot of this page",
845
+ retryAttachments: "Retry attachments",
846
+ screenshot: "Screenshot",
847
+ sendWithoutEmail: "Send without email",
848
+ submit: "Send feedback",
849
+ successMessage: "We read every report. Thanks for taking the time.",
850
+ successTitle: "Feedback sent",
851
+ title: "Send feedback",
852
+ titlePlaceholder: "Short summary",
853
+ trigger: "Feedback",
854
+ undo: "Undo"
855
+ };
856
+ //#endregion
857
+ //#region src/feedback/core/annotations.ts
858
+ const BOX_TOOLS = [
859
+ "text",
860
+ "rectangle",
861
+ "spotlight",
862
+ "highlight",
863
+ "blur"
864
+ ];
865
+ const MIN_GESTURE_LENGTH = 8;
866
+ const DEFAULT_PATH_TOLERANCE = 2.5;
867
+ function normalizeRect(start, end) {
868
+ return {
869
+ height: Math.abs(end.y - start.y),
870
+ width: Math.abs(end.x - start.x),
871
+ x: Math.min(start.x, end.x),
872
+ y: Math.min(start.y, end.y)
873
+ };
874
+ }
875
+ function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
876
+ if (points.length < 3) return points;
877
+ const [first, ...rest] = points;
878
+ if (!first) return points;
879
+ const kept = [first];
880
+ let anchor = first;
881
+ for (const point of rest.slice(0, -1)) if (Math.hypot(point.x - anchor.x, point.y - anchor.y) >= tolerance) {
882
+ kept.push(point);
883
+ anchor = point;
884
+ }
885
+ const last = points.at(-1);
886
+ if (last) kept.push(last);
887
+ return kept;
888
+ }
889
+ function isDegenerate(annotation) {
890
+ if (annotation.tool === "text") return !annotation.text?.trim();
891
+ if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
892
+ const { start, end } = annotation;
893
+ return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
894
+ }
895
+ function toWire(annotation) {
896
+ const { color, start, end, tool } = annotation;
897
+ if (tool === "pen") return {
898
+ color,
899
+ points: simplifyPath(annotation.points ?? []),
900
+ type: tool,
901
+ x: start.x,
902
+ y: start.y
903
+ };
904
+ if (BOX_TOOLS.includes(tool)) {
905
+ const rect = normalizeRect(start, end);
906
+ return {
907
+ color,
908
+ height: rect.height,
909
+ ...tool === "text" ? { text: annotation.text } : {},
910
+ type: tool,
911
+ width: rect.width,
912
+ x: rect.x,
913
+ y: rect.y
914
+ };
915
+ }
916
+ return {
917
+ color,
918
+ endX: end.x,
919
+ endY: end.y,
920
+ type: tool,
921
+ x: start.x,
922
+ y: start.y
923
+ };
924
+ }
925
+ function toWireAnnotations(annotations) {
926
+ return annotations.filter((item) => !isDegenerate(item)).map(toWire);
927
+ }
928
+ //#endregion
929
+ //#region src/feedback/ui/icons.tsx
930
+ function Svg({ children, className, size = 16 }) {
931
+ return /* @__PURE__ */ jsx("svg", {
932
+ "aria-hidden": "true",
933
+ className,
934
+ fill: "none",
935
+ height: size,
936
+ stroke: "currentColor",
937
+ strokeLinecap: "round",
938
+ strokeLinejoin: "round",
939
+ strokeWidth: "1.8",
940
+ viewBox: "0 0 24 24",
941
+ width: size,
942
+ children
943
+ });
944
+ }
945
+ function ChatIcon({ className }) {
946
+ return /* @__PURE__ */ jsx(Svg, {
947
+ className,
948
+ size: 15,
949
+ 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" })
950
+ });
951
+ }
952
+ function CloseIcon({ className }) {
953
+ return /* @__PURE__ */ jsx(Svg, {
954
+ className,
955
+ children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
956
+ });
957
+ }
958
+ function CameraIcon() {
959
+ 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", {
960
+ cx: "12",
961
+ cy: "13",
962
+ r: "3.2"
963
+ })] });
964
+ }
965
+ function PencilIcon() {
966
+ 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" }) });
967
+ }
968
+ function TargetIcon() {
969
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
970
+ cx: "12",
971
+ cy: "12",
972
+ r: "5"
973
+ })] });
974
+ }
975
+ function TrashIcon() {
976
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
977
+ }
978
+ function ArrowIcon() {
979
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
980
+ }
981
+ function UndoIcon() {
982
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
983
+ }
984
+ function CheckIcon({ size = 28 }) {
985
+ return /* @__PURE__ */ jsx("svg", {
986
+ "aria-hidden": "true",
987
+ fill: "none",
988
+ height: size,
989
+ stroke: "currentColor",
990
+ strokeLinecap: "round",
991
+ strokeLinejoin: "round",
992
+ strokeWidth: "2.4",
993
+ viewBox: "0 0 24 24",
994
+ width: size,
995
+ children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
996
+ });
997
+ }
998
+ function GripIcon() {
999
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" }) });
1000
+ }
1001
+ function MoreIcon() {
1002
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h.01M12 12h.01M19 12h.01" }) });
1003
+ }
1004
+ function ChevronDownIcon() {
1005
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "m7 10 5 5 5-5" }) });
1006
+ }
1007
+ function SquareIcon() {
1008
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
1009
+ height: "14",
1010
+ rx: "2",
1011
+ width: "14",
1012
+ x: "5",
1013
+ y: "5"
1014
+ }) });
1015
+ }
1016
+ function TextIcon() {
1017
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 6V4h14v2M12 4v16M8 20h8" }) });
1018
+ }
1019
+ function SpotlightIcon() {
1020
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M8 4H4v4M16 4h4v4M4 16v4h4M20 16v4h-4" }), /* @__PURE__ */ jsx("rect", {
1021
+ height: "8",
1022
+ rx: "1",
1023
+ width: "8",
1024
+ x: "8",
1025
+ y: "8"
1026
+ })] });
1027
+ }
1028
+ function HighlightIcon() {
1029
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 19h16M8 15l8-11 4 3-8 11-4-3ZM7 15l-2 3" }) });
1030
+ }
1031
+ function BlurIcon() {
1032
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
1033
+ height: "14",
1034
+ rx: "2",
1035
+ width: "14",
1036
+ x: "5",
1037
+ y: "5"
1038
+ }), /* @__PURE__ */ jsx("path", { d: "M8 8h8v8H8z" })] });
1039
+ }
1040
+ //#endregion
1041
+ //#region src/feedback/ui/widget-keys.ts
1042
+ const KEY_EVENTS = [
1043
+ "keydown",
1044
+ "keyup",
1045
+ "keypress"
1046
+ ];
1047
+ function isWidgetNode(node) {
1048
+ if (node instanceof ShadowRoot) return node.host.hasAttribute(WIDGET_MARKER);
1049
+ return node instanceof Element && node.hasAttribute("data-reflet-widget");
1050
+ }
1051
+ function startsInWidget(event) {
1052
+ return event.composedPath().some(isWidgetNode);
1053
+ }
1054
+ function relayInsideWidget(event) {
1055
+ const target = event.composedPath()[0];
1056
+ if (!(event instanceof KeyboardEvent && target && startsInWidget(event))) return;
1057
+ event.stopImmediatePropagation();
1058
+ const relay = new KeyboardEvent(event.type, {
1059
+ altKey: event.altKey,
1060
+ bubbles: true,
1061
+ cancelable: true,
1062
+ charCode: event.charCode,
1063
+ code: event.code,
1064
+ ctrlKey: event.ctrlKey,
1065
+ isComposing: event.isComposing,
1066
+ key: event.key,
1067
+ keyCode: event.keyCode,
1068
+ location: event.location,
1069
+ metaKey: event.metaKey,
1070
+ repeat: event.repeat,
1071
+ shiftKey: event.shiftKey
1072
+ });
1073
+ target.dispatchEvent(relay);
1074
+ if (relay.defaultPrevented) event.preventDefault();
1075
+ }
1076
+ if (typeof window !== "undefined") for (const type of KEY_EVENTS) window.addEventListener(type, relayInsideWidget, true);
1077
+ function listenToKeydown(anchor, onKeyDown, capture = false) {
1078
+ const widgetRoot = anchor?.getRootNode();
1079
+ const targets = widgetRoot instanceof ShadowRoot ? [document, widgetRoot] : [document];
1080
+ const listener = (event) => {
1081
+ if (event instanceof KeyboardEvent) onKeyDown(event);
1082
+ };
1083
+ for (const target of targets) target.addEventListener("keydown", listener, capture);
1084
+ return () => {
1085
+ for (const target of targets) target.removeEventListener("keydown", listener, capture);
1086
+ };
1087
+ }
1088
+ //#endregion
1089
+ //#region src/feedback/core/arrow-geometry.ts
1090
+ function arrowOutline(start, end) {
1091
+ const dx = end.x - start.x;
1092
+ const dy = end.y - start.y;
1093
+ const length = Math.hypot(dx, dy);
1094
+ if (length === 0) return {
1095
+ points: [],
1096
+ strokeWidth: 0
1097
+ };
1098
+ const shaftWidth = Math.min(20, Math.max(6, length * .025), length * .2);
1099
+ const headLength = Math.min(length * .45, shaftWidth * 3);
1100
+ const headHalfWidth = headLength * .55;
1101
+ const shaftLength = length - headLength;
1102
+ const pointOnArrow = (along, across) => ({
1103
+ x: start.x + (dx * along - dy * across) / length,
1104
+ y: start.y + (dy * along + dx * across) / length
1105
+ });
1106
+ return {
1107
+ points: [
1108
+ pointOnArrow(0, 0),
1109
+ pointOnArrow(shaftLength, -shaftWidth / 2),
1110
+ pointOnArrow(shaftLength, -headHalfWidth),
1111
+ {
1112
+ x: end.x,
1113
+ y: end.y
1114
+ },
1115
+ pointOnArrow(shaftLength, headHalfWidth),
1116
+ pointOnArrow(shaftLength, shaftWidth / 2)
1117
+ ],
1118
+ strokeWidth: Math.min(4, length * .02)
1119
+ };
1120
+ }
1121
+ //#endregion
1122
+ //#region src/feedback/core/annotation-renderer.ts
1123
+ const STROKE_WIDTH = 3;
1124
+ const HIGHLIGHT_ALPHA = .3;
1125
+ const REDACTION_FILL = "#111827";
1126
+ const PIXEL_BLOCK = 10;
1127
+ function scalePoint(point, scale) {
1128
+ return {
1129
+ x: point.x * scale,
1130
+ y: point.y * scale
1131
+ };
1132
+ }
1133
+ function scaleRect(rect, scale) {
1134
+ return {
1135
+ height: rect.height * scale,
1136
+ width: rect.width * scale,
1137
+ x: rect.x * scale,
1138
+ y: rect.y * scale
1139
+ };
1140
+ }
1141
+ function isCanvasElement(value) {
1142
+ return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
1143
+ }
1144
+ function redact(context, rect) {
1145
+ const source = context.canvas;
1146
+ const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
1147
+ const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
1148
+ if (isCanvasElement(source)) {
1149
+ const offscreen = document.createElement("canvas");
1150
+ offscreen.width = width;
1151
+ offscreen.height = height;
1152
+ const offscreenContext = offscreen.getContext("2d");
1153
+ if (offscreenContext) {
1154
+ offscreenContext.imageSmoothingEnabled = true;
1155
+ offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
1156
+ context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
1157
+ return;
1158
+ }
1159
+ }
1160
+ context.fillStyle = REDACTION_FILL;
1161
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
1162
+ }
1163
+ function drawArrow(context, annotation, scale) {
1164
+ const { points, strokeWidth } = arrowOutline(annotation.start, annotation.end);
1165
+ const [first, ...rest] = points.map((point) => scalePoint(point, scale));
1166
+ if (!first) return;
1167
+ context.beginPath();
1168
+ context.moveTo(first.x, first.y);
1169
+ for (const point of rest) context.lineTo(point.x, point.y);
1170
+ context.closePath();
1171
+ context.lineWidth = strokeWidth * scale;
1172
+ context.fill();
1173
+ context.stroke();
1174
+ }
1175
+ function drawPen(context, points, scale) {
1176
+ const [first, ...rest] = points;
1177
+ if (!first) return;
1178
+ const head = scalePoint(first, scale);
1179
+ context.beginPath();
1180
+ context.moveTo(head.x, head.y);
1181
+ for (const point of rest) {
1182
+ const scaled = scalePoint(point, scale);
1183
+ context.lineTo(scaled.x, scaled.y);
1184
+ }
1185
+ context.stroke();
1186
+ }
1187
+ function drawSpotlight(context, rect) {
1188
+ const { width, height } = context.canvas;
1189
+ context.save();
1190
+ context.fillStyle = REDACTION_FILL;
1191
+ context.globalAlpha = .55;
1192
+ context.fillRect(0, 0, width, rect.y);
1193
+ context.fillRect(0, rect.y + rect.height, width, height - rect.y - rect.height);
1194
+ context.fillRect(0, rect.y, rect.x, rect.height);
1195
+ context.fillRect(rect.x + rect.width, rect.y, width - rect.x - rect.width, rect.height);
1196
+ context.restore();
1197
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1198
+ }
1199
+ function drawAnnotation(context, annotation, scale) {
1200
+ const { color, tool } = annotation;
1201
+ const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
1202
+ context.save();
1203
+ context.lineWidth = STROKE_WIDTH * scale;
1204
+ context.lineCap = "round";
1205
+ context.lineJoin = "round";
1206
+ context.strokeStyle = color;
1207
+ context.fillStyle = color;
1208
+ switch (tool) {
1209
+ case "text": {
1210
+ const lines = (annotation.text ?? "").split("\n");
1211
+ const lineHeight = rect.height / lines.length;
1212
+ context.font = `${lineHeight / 1.4}px ui-sans-serif, system-ui, sans-serif`;
1213
+ context.textBaseline = "top";
1214
+ for (const [index, line] of lines.entries()) context.fillText(line, rect.x, rect.y + index * lineHeight);
1215
+ break;
1216
+ }
1217
+ case "pen":
1218
+ drawPen(context, annotation.points ?? [], scale);
1219
+ break;
1220
+ case "arrow":
1221
+ drawArrow(context, annotation, scale);
1222
+ break;
1223
+ case "spotlight":
1224
+ drawSpotlight(context, rect);
1225
+ break;
1226
+ case "rectangle":
1227
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1228
+ break;
1229
+ case "highlight":
1230
+ context.globalAlpha = HIGHLIGHT_ALPHA;
1231
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
1232
+ break;
1233
+ default: redact(context, rect);
1234
+ }
1235
+ context.restore();
1236
+ }
1237
+ function drawAnnotations(context, annotations, options) {
1238
+ const scale = options?.scale ?? 1;
1239
+ for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
1240
+ }
1241
+ function loadImage(url) {
1242
+ return new Promise((resolve, reject) => {
1243
+ const image = new Image();
1244
+ image.onload = () => resolve(image);
1245
+ image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
1246
+ image.src = url;
1247
+ });
1248
+ }
1249
+ async function renderAnnotatedImage(source, annotations) {
1250
+ if (annotations.length === 0) return null;
1251
+ const canvas = document.createElement("canvas");
1252
+ canvas.width = source.width;
1253
+ canvas.height = source.height;
1254
+ const context = canvas.getContext("2d");
1255
+ if (!context) return null;
1256
+ const image = await loadImage(source.objectUrl);
1257
+ context.drawImage(image, 0, 0, source.width, source.height);
1258
+ drawAnnotations(context, annotations);
1259
+ return await capturedFromCanvas(canvas, source.width);
1260
+ }
1261
+ //#endregion
1262
+ //#region src/feedback/ui/selection-outline.tsx
1263
+ /**
1264
+ * Keeps the picked element ringed while the reporter writes about it, so the
1265
+ * composer parked next to it is visibly about that element.
1266
+ */
1267
+ function SelectionOutline({ node }) {
1268
+ const boxRef = useRef(null);
1269
+ useEffect(() => {
1270
+ const box = boxRef.current;
1271
+ if (!(box && node)) return;
1272
+ let frame = 0;
1273
+ const draw = () => {
1274
+ frame = 0;
1275
+ const rect = node.getBoundingClientRect();
1276
+ const hidden = rect.width === 0 || rect.height === 0 || rect.bottom < 0 || rect.top > window.innerHeight;
1277
+ box.style.opacity = hidden ? "0" : "1";
1278
+ box.style.width = `${rect.width}px`;
1279
+ box.style.height = `${rect.height}px`;
1280
+ box.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
1281
+ };
1282
+ const schedule = () => {
1283
+ frame ||= requestAnimationFrame(draw);
1284
+ };
1285
+ draw();
1286
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(draw);
1287
+ observer?.observe(node);
1288
+ window.addEventListener("scroll", schedule, {
1289
+ capture: true,
1290
+ passive: true
1291
+ });
1292
+ window.addEventListener("resize", schedule);
1293
+ return () => {
1294
+ cancelAnimationFrame(frame);
1295
+ observer?.disconnect();
1296
+ window.removeEventListener("scroll", schedule, true);
1297
+ window.removeEventListener("resize", schedule);
1298
+ };
1299
+ }, [node]);
1300
+ if (!node) return null;
1301
+ return /* @__PURE__ */ jsx("div", {
1302
+ "aria-hidden": "true",
1303
+ className: "selection-outline",
1304
+ ref: boxRef
1305
+ });
1306
+ }
1307
+ //#endregion
1308
+ //#region src/feedback/core/submission/upload-screenshots.ts
1309
+ async function uploadImage(transport, image) {
1310
+ const { uploadUrl } = await transport.getScreenshotUploadUrl();
1311
+ return transport.uploadImage(uploadUrl, image);
1312
+ }
1313
+ async function prepareScreenshot(transport, screenshot, index) {
1314
+ const { closeUp, image, annotations, context } = screenshot;
1315
+ const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
1316
+ if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
1317
+ try {
1318
+ const storageId = await uploadImage(transport, image);
1319
+ const prepared = [{
1320
+ annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
1321
+ annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
1322
+ captureSource: "widget",
1323
+ filename: `screenshot-${index + 1}.png`,
1324
+ height: image.height,
1325
+ mimeType: image.mimeType,
1326
+ pageUrl: context.url,
1327
+ size: image.blob.size,
1328
+ storageId,
1329
+ width: image.width
1330
+ }];
1331
+ if (closeUp) prepared.push({
1332
+ captureSource: "element",
1333
+ filename: `element-${index + 1}.png`,
1334
+ height: closeUp.height,
1335
+ mimeType: closeUp.mimeType,
1336
+ pageUrl: context.url,
1337
+ size: closeUp.blob.size,
1338
+ storageId: await uploadImage(transport, closeUp),
1339
+ width: closeUp.width
1340
+ });
1341
+ return prepared;
1342
+ } finally {
1343
+ releaseCapture(annotated);
1344
+ }
1345
+ }
1346
+ async function uploadScreenshots(transport, submission) {
1347
+ return (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).flatMap((result) => {
1348
+ if (result.status === "rejected") throw result.reason;
1349
+ return result.value;
1350
+ });
1351
+ }
1352
+ //#endregion
1353
+ //#region src/feedback/core/submit.ts
1354
+ const MAX_TITLE_LENGTH = 100;
1355
+ const NEWLINE = /\r?\n/;
1356
+ function deriveTitle(message) {
1357
+ const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
1358
+ if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
1359
+ const clipped = firstLine.slice(0, 99);
1360
+ const lastSpace = clipped.lastIndexOf(" ");
1361
+ return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
1362
+ }
1363
+ function buildDescription(params) {
1364
+ const { email, isAnonymous, message } = params;
1365
+ const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
1366
+ return `${message.trim()}${contact}`;
1367
+ }
1368
+ async function attachPreparedScreenshots(transport, result) {
1369
+ const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
1370
+ ...screenshot,
1371
+ feedbackId: result.feedbackId
1372
+ })));
1373
+ const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
1374
+ return {
1375
+ feedbackId: result.feedbackId,
1376
+ pendingScreenshots
1377
+ };
1378
+ }
1379
+ async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
1380
+ const { feedbackId } = await transport.create({
1381
+ context: submission.context,
1382
+ description: buildDescription(submission),
1383
+ title: deriveTitle(submission.message)
1384
+ });
1385
+ return attachPreparedScreenshots(transport, {
1386
+ feedbackId,
1387
+ pendingScreenshots
1388
+ });
1389
+ }
1390
+ async function submitWidgetFeedback(transport, submission) {
1391
+ if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
1392
+ return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
1393
+ }
1394
+ function isStorageResponse(value) {
1395
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
1396
+ }
1397
+ async function uploadImageBlob(uploadUrl, image) {
1398
+ const response = await fetch(uploadUrl, {
1399
+ body: image.blob,
1400
+ headers: { "Content-Type": image.mimeType },
1401
+ method: "POST"
1402
+ });
1403
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
1404
+ const payload = await response.json();
1405
+ if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
1406
+ return payload.storageId;
1407
+ }
1408
+ function createFeedbackTransport(client) {
1409
+ return {
1410
+ create: (params) => client.create(params),
1411
+ getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
1412
+ saveScreenshot: (params) => client.saveScreenshot(params),
1413
+ uploadImage: uploadImageBlob
1414
+ };
1415
+ }
1416
+ //#endregion
1417
+ //#region src/feedback/ui/visible-viewport.ts
1418
+ /**
1419
+ * The part of the page the reporter can actually see, in fixed-position
1420
+ * coordinates. On mobile the keyboard shrinks this without touching
1421
+ * `window.innerHeight`, so anything that must stay reachable measures here.
1422
+ */
1423
+ function visibleViewport() {
1424
+ const viewport = window.visualViewport;
1425
+ const left = viewport?.offsetLeft ?? 0;
1426
+ const top = viewport?.offsetTop ?? 0;
1427
+ const width = viewport?.width ?? window.innerWidth;
1428
+ const height = viewport?.height ?? window.innerHeight;
1429
+ return {
1430
+ bottom: window.innerHeight - top - height,
1431
+ height,
1432
+ left,
1433
+ right: window.innerWidth - left - width,
1434
+ top,
1435
+ width
1436
+ };
1437
+ }
1438
+ function onViewportChange(update) {
1439
+ window.addEventListener("resize", update);
1440
+ window.visualViewport?.addEventListener("resize", update);
1441
+ window.visualViewport?.addEventListener("scroll", update);
1442
+ return () => {
1443
+ window.removeEventListener("resize", update);
1444
+ window.visualViewport?.removeEventListener("resize", update);
1445
+ window.visualViewport?.removeEventListener("scroll", update);
1446
+ };
1447
+ }
1448
+ //#endregion
1449
+ //#region src/feedback/ui/floating/use-floating-position.ts
1450
+ const EDGE_GAP$1 = 12;
1451
+ const KEYBOARD_STEP = 24;
1452
+ const KEY_DIRECTIONS = {
1453
+ ArrowDown: {
1454
+ x: 0,
1455
+ y: KEYBOARD_STEP
1456
+ },
1457
+ ArrowLeft: {
1458
+ x: -24,
1459
+ y: 0
1460
+ },
1461
+ ArrowRight: {
1462
+ x: KEYBOARD_STEP,
1463
+ y: 0
1464
+ },
1465
+ ArrowUp: {
1466
+ x: 0,
1467
+ y: -24
1468
+ }
1469
+ };
1470
+ function clampPosition(point, bounds, viewport = visibleViewport()) {
1471
+ return {
1472
+ x: Math.max(viewport.left + EDGE_GAP$1, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP$1)),
1473
+ y: Math.max(viewport.top + EDGE_GAP$1, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP$1))
1474
+ };
1475
+ }
1476
+ function readStoredPosition(persistKey) {
1477
+ if (!persistKey || typeof localStorage === "undefined") return null;
1478
+ try {
1479
+ const stored = JSON.parse(localStorage.getItem(persistKey) ?? "null");
1480
+ if (typeof stored === "object" && stored !== null && "x" in stored && "y" in stored && typeof stored.x === "number" && typeof stored.y === "number") return {
1481
+ x: stored.x,
1482
+ y: stored.y
1483
+ };
1484
+ return null;
1485
+ } catch {
1486
+ return null;
1487
+ }
1488
+ }
1489
+ function useFloatingPosition(corner = "bottom-right", isPanelOpen = false, { anchorSelector = ".root", persistKey } = {}) {
1490
+ const rootRef = useRef(null);
1491
+ const gesture = useRef(null);
1492
+ const [placement, setPlacement] = useState(() => readStoredPosition(persistKey));
1493
+ const [fittedAnchor, setFittedAnchor] = useState(null);
1494
+ const [positionedPanelOpen, setPositionedPanelOpen] = useState(isPanelOpen);
1495
+ const [viewport, setViewport] = useState(visibleViewport);
1496
+ const horizontalEdge = corner.endsWith("right") ? "right" : "left";
1497
+ const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
1498
+ const position = fittedAnchor ?? placement;
1499
+ if (positionedPanelOpen !== isPanelOpen) {
1500
+ setPositionedPanelOpen(isPanelOpen);
1501
+ setPlacement(null);
1502
+ setFittedAnchor(null);
1503
+ }
1504
+ const anchorFor = useCallback((point, bounds) => {
1505
+ const fitted = clampPosition(point, bounds);
1506
+ const visible = visibleViewport();
1507
+ return {
1508
+ x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
1509
+ y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
1510
+ };
1511
+ }, [horizontalEdge, verticalEdge]);
1512
+ const moveTo = (point, bounds) => {
1513
+ const anchor = anchorFor(point, bounds);
1514
+ setFittedAnchor(null);
1515
+ setPlacement((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
1516
+ };
1517
+ useLayoutEffect(() => {
1518
+ const root = rootRef.current;
1519
+ if (!root || root.offsetWidth === 0) return;
1520
+ const bounds = new DOMRect(root.offsetLeft, root.offsetTop, root.offsetWidth, root.offsetHeight);
1521
+ const fitted = clampPosition(bounds, bounds, viewport);
1522
+ if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) {
1523
+ const anchor = anchorFor(fitted, bounds);
1524
+ setFittedAnchor((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
1525
+ }
1526
+ });
1527
+ /** Back to the user's placement first; the layout effect then refits it to the new room. */
1528
+ useEffect(() => {
1529
+ const refit = () => setFittedAnchor(null);
1530
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(refit);
1531
+ if (rootRef.current) observer?.observe(rootRef.current);
1532
+ const stopListening = onViewportChange(() => {
1533
+ setViewport(visibleViewport());
1534
+ refit();
1535
+ });
1536
+ return () => {
1537
+ observer?.disconnect();
1538
+ stopListening();
1539
+ };
1540
+ }, []);
1541
+ useEffect(() => {
1542
+ if (!persistKey) return;
1543
+ try {
1544
+ if (placement) localStorage.setItem(persistKey, JSON.stringify(placement));
1545
+ else localStorage.removeItem(persistKey);
1546
+ } catch {}
1547
+ }, [persistKey, placement]);
1548
+ const onPointerDown = (event) => {
1549
+ const bounds = rootRef.current?.getBoundingClientRect();
1550
+ if (!bounds || event.button !== 0) return;
1551
+ event.currentTarget.setPointerCapture(event.pointerId);
1552
+ gesture.current = {
1553
+ x: event.clientX - bounds.x,
1554
+ y: event.clientY - bounds.y
1555
+ };
1556
+ };
1557
+ const onPointerMove = (event) => {
1558
+ const bounds = rootRef.current?.getBoundingClientRect();
1559
+ const grab = gesture.current;
1560
+ if (!(bounds && grab && event.currentTarget.hasPointerCapture(event.pointerId))) return;
1561
+ moveTo({
1562
+ x: event.clientX - grab.x,
1563
+ y: event.clientY - grab.y
1564
+ }, bounds);
1565
+ };
1566
+ const onKeyDown = (event) => {
1567
+ const direction = KEY_DIRECTIONS[event.key];
1568
+ const bounds = rootRef.current?.getBoundingClientRect();
1569
+ if (!(bounds && direction)) return;
1570
+ event.preventDefault();
1571
+ moveTo({
1572
+ x: bounds.x + direction.x,
1573
+ y: bounds.y + direction.y
1574
+ }, bounds);
1575
+ };
1576
+ const viewportStyles = `${anchorSelector} { --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; }`;
1577
+ const anchorStyles = position ? `${anchorSelector}[data-moved="true"] { left: auto; right: auto; top: auto; bottom: auto; ${horizontalEdge}: ${position.x + viewport[horizontalEdge]}px; ${verticalEdge}: ${position.y + viewport[verticalEdge]}px; }` : "";
1578
+ return {
1579
+ handleProps: {
1580
+ onKeyDown,
1581
+ onLostPointerCapture: () => {
1582
+ gesture.current = null;
1583
+ },
1584
+ onPointerCancel: () => {
1585
+ gesture.current = null;
1586
+ },
1587
+ onPointerDown,
1588
+ onPointerMove,
1589
+ onPointerUp: () => {
1590
+ gesture.current = null;
1591
+ }
1592
+ },
1593
+ /** The user's own placement, independent of any temporary fit. */
1594
+ placement,
1595
+ /** Null while docked by CSS; non-null once moved by the user or fitted into the window. */
1596
+ position,
1597
+ resetPosition: () => {
1598
+ setPlacement(null);
1599
+ setFittedAnchor(null);
1600
+ },
1601
+ rootRef,
1602
+ styles: viewportStyles + anchorStyles
1603
+ };
1604
+ }
1605
+ //#endregion
1606
+ //#region src/feedback/ui/floating/element-walk.ts
1607
+ const ARROW_SIBLING = {
1608
+ ArrowLeft: "previousElementSibling",
1609
+ ArrowRight: "nextElementSibling"
1610
+ };
1611
+ const AIM_KEYS = [
1612
+ "Tab",
1613
+ "ArrowUp",
1614
+ "ArrowDown",
1615
+ "ArrowLeft",
1616
+ "ArrowRight"
1617
+ ];
1618
+ function isWidgetOwned(element) {
1619
+ return element.closest(`[${WIDGET_MARKER}]`) !== null;
1620
+ }
1621
+ function isPickable(element) {
1622
+ if (!element || element === document.documentElement || isWidgetOwned(element)) return false;
1623
+ const rect = element.getBoundingClientRect();
1624
+ return rect.width > 0 && rect.height > 0;
1625
+ }
1626
+ function pickableElements() {
1627
+ return Array.from(document.body.querySelectorAll("*")).filter(isPickable);
1628
+ }
1629
+ function documentStep(from, backwards) {
1630
+ const all = pickableElements();
1631
+ const index = all.indexOf(from);
1632
+ if (all.length === 0) return null;
1633
+ if (index === -1) return all[0] ?? null;
1634
+ return all[(index + (backwards ? -1 : 1) + all.length) % all.length] ?? null;
1635
+ }
1636
+ function climb(from) {
1637
+ let candidate = from.parentElement;
1638
+ while (candidate && !isPickable(candidate)) candidate = candidate.parentElement;
1639
+ return candidate;
1640
+ }
1641
+ function descend(from) {
1642
+ let candidate = from.firstElementChild;
1643
+ while (candidate && !isPickable(candidate)) candidate = candidate.nextElementSibling;
1644
+ return candidate;
1645
+ }
1646
+ function sibling(from, direction) {
1647
+ let candidate = from[direction];
1648
+ while (candidate && !isPickable(candidate)) candidate = candidate[direction];
1649
+ return candidate;
1650
+ }
1651
+ function stepAim(from, key, backwards) {
1652
+ if (!(from && isPickable(from))) return pickableElements()[0] ?? null;
1653
+ if (key === "Tab") return documentStep(from, backwards);
1654
+ if (key === "ArrowUp") return climb(from);
1655
+ if (key === "ArrowDown") return descend(from);
1656
+ const direction = ARROW_SIBLING[key];
1657
+ return direction ? sibling(from, direction) : null;
1658
+ }
1659
+ //#endregion
1660
+ //#region src/feedback/ui/picker.tsx
1661
+ const LABEL_HEIGHT = 24;
1662
+ const LABEL_GAP = 6;
1663
+ const NOTE_WIDTH = 300;
1664
+ const NOTE_GAP = 10;
1665
+ const EDGE_GAP = 12;
1666
+ function elementUnder(x, y) {
1667
+ const element = document.elementFromPoint(x, y);
1668
+ if (!element || isWidgetOwned(element)) return null;
1669
+ return element;
1670
+ }
1671
+ function describeTarget(element) {
1672
+ return {
1673
+ componentName: resolveComponentStack(getFiberFromNode(element))[0],
1674
+ element,
1675
+ label: describeElement(element),
1676
+ rect: element.getBoundingClientRect(),
1677
+ region: describeRegion(element)
1678
+ };
1679
+ }
1680
+ /**
1681
+ * Pins the note card to the element: below it, flipped above when the visible
1682
+ * viewport — what is left of the screen once a mobile keyboard is up — has no
1683
+ * room there. Measures against the part of the element actually on screen, so
1684
+ * picking a full-height section still leaves the card in view.
1685
+ */
1686
+ function noteStyle(rect, view) {
1687
+ 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));
1688
+ const visibleTop = Math.max(rect.top, view.top + EDGE_GAP);
1689
+ const visibleBottom = Math.min(rect.bottom, view.top + view.height - EDGE_GAP);
1690
+ if (view.top + view.height - visibleBottom > visibleTop - view.top) return {
1691
+ left,
1692
+ top: visibleBottom + NOTE_GAP
1693
+ };
1694
+ return {
1695
+ bottom: window.innerHeight - visibleTop + NOTE_GAP,
1696
+ left
1697
+ };
1698
+ }
1699
+ function ElementPicker({ inspect, labels, onCancel, onPick }) {
1700
+ const [target, setTarget] = useState(null);
1701
+ const [pinned, setPinned] = useState(null);
1702
+ const [note, setNote] = useState("");
1703
+ const pickerRef = useRef(null);
1704
+ const noteRef = useRef(null);
1705
+ const pinnedRef = useRef(false);
1706
+ const [view, setView] = useState(visibleViewport);
1707
+ const aimRef = useRef(null);
1708
+ useEffect(() => onViewportChange(() => setView(visibleViewport())), []);
1709
+ useEffect(() => {
1710
+ pinnedRef.current = pinned !== null;
1711
+ if (pinned) noteRef.current?.focus({ preventScroll: true });
1712
+ }, [pinned]);
1713
+ useEffect(() => {
1714
+ aimRef.current = target?.element ?? null;
1715
+ }, [target]);
1716
+ useEffect(() => {
1717
+ const swallow = (event) => {
1718
+ if (startsInWidget(event)) return;
1719
+ event.preventDefault();
1720
+ event.stopPropagation();
1721
+ };
1722
+ const pin = (element, wantsCode) => {
1723
+ if (wantsCode && inspect) inspect.onInspect(element);
1724
+ else setPinned(describeTarget(element));
1725
+ };
1726
+ const aimAt = (x, y) => {
1727
+ const element = elementUnder(x, y);
1728
+ if (!element) return;
1729
+ setTarget((current) => current?.element === element ? {
1730
+ ...current,
1731
+ rect: element.getBoundingClientRect()
1732
+ } : describeTarget(element));
1733
+ };
1734
+ const onMove = (event) => {
1735
+ if (pinnedRef.current || startsInWidget(event)) return;
1736
+ aimAt(event.clientX, event.clientY);
1737
+ };
1738
+ const onPointerDown = (event) => {
1739
+ if (startsInWidget(event)) return;
1740
+ swallow(event);
1741
+ if (!pinnedRef.current) aimAt(event.clientX, event.clientY);
1742
+ };
1743
+ const onPointerUp = (event) => {
1744
+ if (startsInWidget(event)) return;
1745
+ swallow(event);
1746
+ if (pinnedRef.current) return;
1747
+ const element = elementUnder(event.clientX, event.clientY);
1748
+ if (element) pin(element, event.shiftKey);
1749
+ };
1750
+ const moveAim = (key, backwards) => {
1751
+ const next = stepAim(aimRef.current, key, backwards);
1752
+ if (!next) return;
1753
+ next.scrollIntoView({
1754
+ block: "nearest",
1755
+ inline: "nearest"
1756
+ });
1757
+ aimRef.current = next;
1758
+ setTarget(describeTarget(next));
1759
+ };
1760
+ const onEscape = () => {
1761
+ if (pinnedRef.current) {
1762
+ setPinned(null);
1763
+ setNote("");
1764
+ return;
1765
+ }
1766
+ onCancel();
1767
+ };
1768
+ const onKeyDown = (event) => {
1769
+ if (event.key === "Escape") {
1770
+ swallow(event);
1771
+ onEscape();
1772
+ return;
1773
+ }
1774
+ if (pinnedRef.current || startsInWidget(event)) return;
1775
+ if (event.key === "Enter") {
1776
+ swallow(event);
1777
+ if (aimRef.current) pin(aimRef.current, event.shiftKey);
1778
+ return;
1779
+ }
1780
+ if (!AIM_KEYS.includes(event.key)) return;
1781
+ swallow(event);
1782
+ moveAim(event.key, event.shiftKey);
1783
+ };
1784
+ const onScroll = () => {
1785
+ setTarget((current) => current ? {
1786
+ ...current,
1787
+ rect: current.element.getBoundingClientRect()
1788
+ } : current);
1789
+ setPinned((current) => current ? {
1790
+ ...current,
1791
+ rect: current.element.getBoundingClientRect()
1792
+ } : current);
1793
+ };
1794
+ document.addEventListener("pointermove", onMove, true);
1795
+ document.addEventListener("pointerdown", onPointerDown, true);
1796
+ document.addEventListener("pointerup", onPointerUp, true);
1797
+ document.addEventListener("mousedown", swallow, true);
1798
+ document.addEventListener("mouseup", swallow, true);
1799
+ document.addEventListener("click", swallow, true);
1800
+ const stopListeningToKeys = listenToKeydown(pickerRef.current, onKeyDown, true);
1801
+ window.addEventListener("scroll", onScroll, {
1802
+ capture: true,
1803
+ passive: true
1804
+ });
1805
+ const previousCursor = document.body.style.cursor;
1806
+ document.body.style.cursor = "crosshair";
1807
+ return () => {
1808
+ document.removeEventListener("pointermove", onMove, true);
1809
+ document.removeEventListener("pointerdown", onPointerDown, true);
1810
+ document.removeEventListener("pointerup", onPointerUp, true);
1811
+ document.removeEventListener("mousedown", swallow, true);
1812
+ document.removeEventListener("mouseup", swallow, true);
1813
+ document.removeEventListener("click", swallow, true);
1814
+ stopListeningToKeys();
1815
+ window.removeEventListener("scroll", onScroll, true);
1816
+ document.body.style.cursor = previousCursor;
1817
+ };
1818
+ }, [inspect, onCancel]);
1819
+ const rect = (pinned ?? target)?.rect;
1820
+ const labelAbove = rect ? rect.top > 30 : true;
1821
+ return /* @__PURE__ */ jsxs("div", {
1822
+ ref: pickerRef,
1823
+ children: [
1824
+ rect && /* @__PURE__ */ jsx("div", {
1825
+ className: "picker-box",
1826
+ "data-pinned": pinned !== null,
1827
+ style: {
1828
+ height: rect.height,
1829
+ translate: `${rect.left}px ${rect.top}px`,
1830
+ width: rect.width
1831
+ }
1832
+ }),
1833
+ rect && target && !pinned && /* @__PURE__ */ jsxs("div", {
1834
+ className: "picker-label",
1835
+ style: { translate: `${Math.max(4, rect.left)}px ${labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP}px` },
1836
+ children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
1837
+ }),
1838
+ pinned ? /* @__PURE__ */ jsxs("form", {
1839
+ className: "picker-note glass",
1840
+ onSubmit: (event) => {
1841
+ event.preventDefault();
1842
+ onPick(pinned.element, note.trim());
1843
+ },
1844
+ style: noteStyle(pinned.rect, view),
1845
+ children: [
1846
+ /* @__PURE__ */ jsxs("p", {
1847
+ className: "picker-note-target",
1848
+ children: [/* @__PURE__ */ jsx("strong", { children: pinned.componentName ? `<${pinned.componentName}>` : pinned.label }), pinned.region && /* @__PURE__ */ jsx("span", { children: pinned.region })]
1849
+ }),
1850
+ /* @__PURE__ */ jsx("textarea", {
1851
+ "aria-label": labels.elementNote,
1852
+ maxLength: MAX_SELECTION_COMMENT_LENGTH,
1853
+ onChange: (event) => setNote(event.target.value),
1854
+ onKeyDown: (event) => {
1855
+ if (event.key === "Enter" && !event.shiftKey) {
1856
+ event.preventDefault();
1857
+ onPick(pinned.element, note.trim());
1858
+ }
1859
+ },
1860
+ placeholder: labels.elementNotePlaceholder,
1861
+ ref: noteRef,
1862
+ rows: 2,
1863
+ value: note
1864
+ }),
1865
+ /* @__PURE__ */ jsxs("div", {
1866
+ className: "picker-note-actions",
1867
+ children: [
1868
+ /* @__PURE__ */ jsx("button", {
1869
+ className: "picker-cancel",
1870
+ onClick: () => {
1871
+ setPinned(null);
1872
+ setNote("");
1873
+ },
1874
+ type: "button",
1875
+ children: labels.pickAnother
1876
+ }),
1877
+ inspect && /* @__PURE__ */ jsx("button", {
1878
+ className: "picker-cancel",
1879
+ onClick: () => inspect.onInspect(pinned.element),
1880
+ type: "button",
1881
+ children: inspect.label
1882
+ }),
1883
+ /* @__PURE__ */ jsx("button", {
1884
+ "aria-label": labels.attachElement,
1885
+ className: "submit",
1886
+ title: labels.attachElement,
1887
+ type: "submit",
1888
+ children: /* @__PURE__ */ jsx(ArrowIcon, {})
1889
+ })
1890
+ ]
1891
+ })
1892
+ ]
1893
+ }) : /* @__PURE__ */ jsxs("div", {
1894
+ className: "picker-hint",
1895
+ children: [/* @__PURE__ */ jsxs("span", {
1896
+ className: "picker-instruction",
1897
+ children: [
1898
+ labels.pickElementHint,
1899
+ " ",
1900
+ /* @__PURE__ */ jsx("kbd", { children: "Tab" }),
1901
+ /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
1902
+ inspect && /* @__PURE__ */ jsx("kbd", { children: "⇧ Click" }),
1903
+ /* @__PURE__ */ jsx("kbd", { children: "Esc" })
1904
+ ]
1905
+ }), /* @__PURE__ */ jsx("button", {
1906
+ className: "picker-cancel",
1907
+ onClick: onCancel,
1908
+ type: "button",
1909
+ children: labels.cancel
1910
+ })]
1911
+ })
1912
+ ]
1913
+ });
1914
+ }
1915
+ //#endregion
1916
+ export { TrashIcon as A, getFiberFromNode as B, MoreIcon as C, Svg as D, SquareIcon as E, SDK_VERSION as F, capturedFromCanvas as G, shortenSourcePath as H, collectPageContext as I, releaseCapture as K, MAX_SELECTION_COMMENT_LENGTH as L, isDegenerate as M, normalizeRect as N, TargetIcon as O, DEFAULT_WIDGET_LABELS as P, buildElementSelection as R, HighlightIcon as S, SpotlightIcon as T, WIDGET_MARKER as U, resolveComponentStack as V, captureViewport as W, ChatIcon as _, visibleViewport as a, CloseIcon as b, deriveTitle as c, SelectionOutline as d, drawAnnotations as f, CameraIcon as g, BlurIcon as h, onViewportChange as i, UndoIcon as j, TextIcon as k, submitWidgetFeedback as l, ArrowIcon as m, isWidgetOwned as n, attachPreparedScreenshots as o, listenToKeydown as p, useFloatingPosition as r, createFeedbackTransport as s, ElementPicker as t, uploadImageBlob as u, CheckIcon as v, PencilIcon as w, GripIcon as x, ChevronDownIcon as y, describeElement as z };
1917
+
1918
+ //# sourceMappingURL=picker-DDDpVcfr.js.map