reflet-sdk 0.3.0 → 0.3.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/CHANGELOG.md +18 -0
- package/dist/client-BYuSKJw5.js.map +1 -1
- package/dist/{client-CSyPxFPK.d.ts → client-DR_mRfak.d.ts} +2 -2
- package/dist/{client-CSyPxFPK.d.ts.map → client-DR_mRfak.d.ts.map} +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.d.ts.map +1 -1
- package/dist/feedback.js +290 -59
- package/dist/feedback.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/{types-UKPPJGrA.d.ts → types-Dp37I5ES.d.ts} +8 -1
- package/dist/types-Dp37I5ES.d.ts.map +1 -0
- package/package.json +1 -1
- package/dist/types-UKPPJGrA.d.ts.map +0 -1
package/dist/feedback.js
CHANGED
|
@@ -17,7 +17,8 @@ function buildSnapdomOptions(options) {
|
|
|
17
17
|
dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
|
|
18
18
|
embedFonts: true,
|
|
19
19
|
exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
|
|
20
|
-
fast: true
|
|
20
|
+
fast: true,
|
|
21
|
+
reconcile: true
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
function fitWithin(size, maxWidth) {
|
|
@@ -48,6 +49,23 @@ function downscale(canvas, target) {
|
|
|
48
49
|
context.drawImage(canvas, 0, 0, target.width, target.height);
|
|
49
50
|
return resized;
|
|
50
51
|
}
|
|
52
|
+
/** Wraps a rendered canvas as an owned capture, downscaled to `maxWidth`. */
|
|
53
|
+
async function capturedFromCanvas(source, maxWidth) {
|
|
54
|
+
const fitted = fitWithin({
|
|
55
|
+
height: source.height,
|
|
56
|
+
width: source.width
|
|
57
|
+
}, maxWidth);
|
|
58
|
+
const output = fitted.scale === 1 ? source : downscale(source, fitted);
|
|
59
|
+
const blob = await toBlob$1(output);
|
|
60
|
+
if (!blob) return null;
|
|
61
|
+
return {
|
|
62
|
+
blob,
|
|
63
|
+
height: output.height,
|
|
64
|
+
mimeType: "image/png",
|
|
65
|
+
objectUrl: URL.createObjectURL(blob),
|
|
66
|
+
width: output.width
|
|
67
|
+
};
|
|
68
|
+
}
|
|
51
69
|
/**
|
|
52
70
|
* Renders what the user is looking at right now.
|
|
53
71
|
*
|
|
@@ -60,25 +78,11 @@ async function captureViewport(options = {}) {
|
|
|
60
78
|
try {
|
|
61
79
|
const { snapdom } = await import("@zumer/snapdom");
|
|
62
80
|
const target = options.element ?? document.documentElement;
|
|
63
|
-
|
|
81
|
+
return await capturedFromCanvas(await snapdom.toCanvas(target, buildSnapdomOptions({
|
|
64
82
|
devicePixelRatio: window.devicePixelRatio,
|
|
65
83
|
element: options.element,
|
|
66
84
|
excludeSelectors: options.excludeSelectors
|
|
67
|
-
}));
|
|
68
|
-
const fitted = fitWithin({
|
|
69
|
-
height: canvas.height,
|
|
70
|
-
width: canvas.width
|
|
71
|
-
}, options.maxWidth ?? DEFAULT_MAX_WIDTH);
|
|
72
|
-
const output = fitted.scale === 1 ? canvas : downscale(canvas, fitted);
|
|
73
|
-
const blob = await toBlob$1(output);
|
|
74
|
-
if (!blob) return null;
|
|
75
|
-
return {
|
|
76
|
-
blob,
|
|
77
|
-
height: output.height,
|
|
78
|
-
mimeType: "image/png",
|
|
79
|
-
objectUrl: URL.createObjectURL(blob),
|
|
80
|
-
width: output.width
|
|
81
|
-
};
|
|
85
|
+
})), options.maxWidth ?? DEFAULT_MAX_WIDTH);
|
|
82
86
|
} catch {
|
|
83
87
|
return null;
|
|
84
88
|
}
|
|
@@ -229,7 +233,7 @@ const URL_QUERY = /[?#]/;
|
|
|
229
233
|
const REDACTED = "[redacted]";
|
|
230
234
|
const ELLIPSIS = "…";
|
|
231
235
|
const MAX_ATTRIBUTE_LENGTH = 120;
|
|
232
|
-
const MAX_TEXT_LENGTH = 200;
|
|
236
|
+
const MAX_TEXT_LENGTH$1 = 200;
|
|
233
237
|
const MAX_CHILD_DEPTH = 3;
|
|
234
238
|
const MAX_CLONED_NODES = 400;
|
|
235
239
|
const DROPPED_TAGS = /* @__PURE__ */ new Set([
|
|
@@ -259,6 +263,36 @@ function truncate$1(value, max) {
|
|
|
259
263
|
function maskSecrets(value) {
|
|
260
264
|
return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
|
|
261
265
|
}
|
|
266
|
+
const SENSITIVE_PARAM = /token|secret|pass(?:word|wd)?|pwd|auth|jwt|credential|session|sid|key|email|ssn|otp|code|signature|nonce/i;
|
|
267
|
+
const LEADING_QUESTION_MARK = /^\?/;
|
|
268
|
+
function decoded(value) {
|
|
269
|
+
try {
|
|
270
|
+
return decodeURIComponent(value);
|
|
271
|
+
} catch {
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Query strings carry signed-in state (`?token=…`, `?userEmail=…`) straight
|
|
277
|
+
* into stored reports and agent prompts. Values of anything credential- or
|
|
278
|
+
* identity-shaped are masked; every other byte of the address stays intact.
|
|
279
|
+
*/
|
|
280
|
+
function redactUrl(href) {
|
|
281
|
+
let url;
|
|
282
|
+
try {
|
|
283
|
+
url = new URL(href);
|
|
284
|
+
} catch {
|
|
285
|
+
return maskSecrets(href);
|
|
286
|
+
}
|
|
287
|
+
const pairs = url.search.replace(LEADING_QUESTION_MARK, "").split("&").filter(Boolean);
|
|
288
|
+
if (!pairs.some((pair) => SENSITIVE_PARAM.test(decoded(pair.split("=")[0] ?? "")))) return href;
|
|
289
|
+
const query = pairs.map((pair) => {
|
|
290
|
+
const equals = pair.indexOf("=");
|
|
291
|
+
if (equals === -1 || !SENSITIVE_PARAM.test(decoded(pair.slice(0, equals)))) return pair;
|
|
292
|
+
return `${pair.slice(0, equals)}=${REDACTED}`;
|
|
293
|
+
}).join("&");
|
|
294
|
+
return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
|
|
295
|
+
}
|
|
262
296
|
function sanitizeUrl(value) {
|
|
263
297
|
if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
|
|
264
298
|
return value.split(URL_QUERY)[0] ?? value;
|
|
@@ -293,7 +327,7 @@ function sanitizeTree(element, depth) {
|
|
|
293
327
|
continue;
|
|
294
328
|
}
|
|
295
329
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
296
|
-
child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH);
|
|
330
|
+
child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
|
|
297
331
|
continue;
|
|
298
332
|
}
|
|
299
333
|
if (!(child instanceof Element)) continue;
|
|
@@ -324,6 +358,7 @@ const SAFE_ID = /^[A-Za-z][\w-]*$/;
|
|
|
324
358
|
const WHITESPACE = /\s+/g;
|
|
325
359
|
const MAX_LABEL_LENGTH = 80;
|
|
326
360
|
const MAX_HTML_LENGTH = 600;
|
|
361
|
+
const MAX_TEXT_LENGTH = 300;
|
|
327
362
|
const MAX_REGION_LENGTH = 160;
|
|
328
363
|
const MAX_HANDLE_VALUE_LENGTH = 80;
|
|
329
364
|
const MAX_DEPTH = 12;
|
|
@@ -340,6 +375,50 @@ const TEST_ATTRIBUTES = [
|
|
|
340
375
|
const NAMING_ATTRIBUTES = ["name", "aria-label"];
|
|
341
376
|
const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
|
|
342
377
|
const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
|
|
378
|
+
/** Elements that start a new visual block — their text gets separated by a space. */
|
|
379
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
380
|
+
"address",
|
|
381
|
+
"article",
|
|
382
|
+
"aside",
|
|
383
|
+
"blockquote",
|
|
384
|
+
"dd",
|
|
385
|
+
"details",
|
|
386
|
+
"div",
|
|
387
|
+
"dl",
|
|
388
|
+
"dt",
|
|
389
|
+
"fieldset",
|
|
390
|
+
"figcaption",
|
|
391
|
+
"figure",
|
|
392
|
+
"footer",
|
|
393
|
+
"form",
|
|
394
|
+
"h1",
|
|
395
|
+
"h2",
|
|
396
|
+
"h3",
|
|
397
|
+
"h4",
|
|
398
|
+
"h5",
|
|
399
|
+
"h6",
|
|
400
|
+
"header",
|
|
401
|
+
"hr",
|
|
402
|
+
"li",
|
|
403
|
+
"main",
|
|
404
|
+
"nav",
|
|
405
|
+
"ol",
|
|
406
|
+
"p",
|
|
407
|
+
"pre",
|
|
408
|
+
"section",
|
|
409
|
+
"summary",
|
|
410
|
+
"table",
|
|
411
|
+
"td",
|
|
412
|
+
"th",
|
|
413
|
+
"tr",
|
|
414
|
+
"ul"
|
|
415
|
+
]);
|
|
416
|
+
function visibleText(element) {
|
|
417
|
+
const clone = element.cloneNode(true);
|
|
418
|
+
if (!(clone instanceof Element)) return "";
|
|
419
|
+
for (const node of clone.querySelectorAll("*")) if (BLOCK_TAGS.has(node.localName)) node.before(clone.ownerDocument.createTextNode(" "));
|
|
420
|
+
return collapseText(maskSecrets(clone.textContent ?? ""));
|
|
421
|
+
}
|
|
343
422
|
function isUnique(selector) {
|
|
344
423
|
try {
|
|
345
424
|
return document.querySelectorAll(selector).length === 1;
|
|
@@ -427,8 +506,12 @@ function describeLandmark(element) {
|
|
|
427
506
|
return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
|
|
428
507
|
}
|
|
429
508
|
function headingAbove(element) {
|
|
509
|
+
const scope = element.closest(LANDMARK_SELECTOR) ?? document.body;
|
|
510
|
+
const probe = document.createRange();
|
|
511
|
+
probe.selectNodeContents(element);
|
|
512
|
+
probe.collapse(true);
|
|
430
513
|
let closest = null;
|
|
431
|
-
for (const heading of
|
|
514
|
+
for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
|
|
432
515
|
return collapseText(closest?.textContent ?? "") || null;
|
|
433
516
|
}
|
|
434
517
|
/**
|
|
@@ -455,6 +538,7 @@ function getElementRect(element) {
|
|
|
455
538
|
/** Everything an agent needs to find the picked element back in the codebase. */
|
|
456
539
|
function buildElementSelection(element) {
|
|
457
540
|
const { componentStack, sourceLocation } = inspectReactElement(element);
|
|
541
|
+
const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
|
|
458
542
|
return {
|
|
459
543
|
componentStack,
|
|
460
544
|
html: sanitizeMarkup(element, MAX_HTML_LENGTH),
|
|
@@ -462,7 +546,8 @@ function buildElementSelection(element) {
|
|
|
462
546
|
rect: getElementRect(element),
|
|
463
547
|
region: describeRegion(element),
|
|
464
548
|
selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
|
|
465
|
-
sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation
|
|
549
|
+
sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
|
|
550
|
+
text: text || void 0
|
|
466
551
|
};
|
|
467
552
|
}
|
|
468
553
|
//#endregion
|
|
@@ -539,9 +624,13 @@ function collectPageContext(options) {
|
|
|
539
624
|
height: window.screen.height,
|
|
540
625
|
width: window.screen.width
|
|
541
626
|
},
|
|
627
|
+
scroll: {
|
|
628
|
+
x: window.scrollX,
|
|
629
|
+
y: window.scrollY
|
|
630
|
+
},
|
|
542
631
|
sdkVersion: options?.sdkVersion,
|
|
543
632
|
timezone: resolveTimezone(),
|
|
544
|
-
url:
|
|
633
|
+
url: redactUrl(window.location.href),
|
|
545
634
|
userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
|
|
546
635
|
viewport: {
|
|
547
636
|
devicePixelRatio: window.devicePixelRatio,
|
|
@@ -553,7 +642,7 @@ function collectPageContext(options) {
|
|
|
553
642
|
//#endregion
|
|
554
643
|
//#region src/feedback/types.ts
|
|
555
644
|
/** Reported with every submission. */
|
|
556
|
-
const SDK_VERSION = "0.3.
|
|
645
|
+
const SDK_VERSION = "0.3.1";
|
|
557
646
|
const DEFAULT_WIDGET_LABELS = {
|
|
558
647
|
annotateHint: "Draw on the screenshot to point at the problem.",
|
|
559
648
|
attachScreenshot: "Attach a screenshot",
|
|
@@ -2066,6 +2155,128 @@ function startConsoleRecorder(options) {
|
|
|
2066
2155
|
};
|
|
2067
2156
|
}
|
|
2068
2157
|
//#endregion
|
|
2158
|
+
//#region src/feedback/core/element-capture.ts
|
|
2159
|
+
const MIN_CONTEXT_PADDING = 24;
|
|
2160
|
+
const MAX_CONTEXT_WIDTH = 2e3;
|
|
2161
|
+
const CLOSE_UP_MAX_WIDTH = 1200;
|
|
2162
|
+
const SELECTION_COLOR = "#4f46e5";
|
|
2163
|
+
const OUTLINE_COLOR = "#4f46e5";
|
|
2164
|
+
const OUTLINE_GLOW = "#ffffff";
|
|
2165
|
+
const OUTLINE_WIDTH = 3;
|
|
2166
|
+
const GLOW_WIDTH = 6;
|
|
2167
|
+
const DIM_OVERLAY = "rgba(15, 23, 42, 0.45)";
|
|
2168
|
+
/**
|
|
2169
|
+
* Tightest ancestor that keeps some breathing room around the pick — the zoom
|
|
2170
|
+
* stays zoomed instead of growing to a giant section. Only when nothing up
|
|
2171
|
+
* the tree clears the minimum does the widest candidate below the cap win.
|
|
2172
|
+
*/
|
|
2173
|
+
function contextRootFor(element) {
|
|
2174
|
+
const target = element.getBoundingClientRect();
|
|
2175
|
+
const coversAtLeast = (box, margin) => box.left <= target.left - margin && box.right >= target.right + margin && box.top <= target.top - margin && box.bottom >= target.bottom + margin;
|
|
2176
|
+
let tightest = null;
|
|
2177
|
+
let current = element.parentElement;
|
|
2178
|
+
while (current && current !== document.documentElement) {
|
|
2179
|
+
const box = current.getBoundingClientRect();
|
|
2180
|
+
if (coversAtLeast(box, MIN_CONTEXT_PADDING)) return current;
|
|
2181
|
+
tightest ??= box.width < MAX_CONTEXT_WIDTH ? current : null;
|
|
2182
|
+
current = current.parentElement;
|
|
2183
|
+
}
|
|
2184
|
+
return tightest ?? element;
|
|
2185
|
+
}
|
|
2186
|
+
/** Where the picked element sits inside the context shot, in image pixels. */
|
|
2187
|
+
function closeUpGeometry(params) {
|
|
2188
|
+
const scale = params.rootRect.width > 0 ? params.capture.width / params.rootRect.width : 1;
|
|
2189
|
+
return {
|
|
2190
|
+
height: Math.round(params.elementRect.height * scale),
|
|
2191
|
+
scale,
|
|
2192
|
+
width: Math.round(params.elementRect.width * scale),
|
|
2193
|
+
x: Math.round((params.elementRect.left - params.rootRect.left) * scale),
|
|
2194
|
+
y: Math.round((params.elementRect.top - params.rootRect.top) * scale)
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
/** Rectangle annotation marking the picked element on the viewport shot. */
|
|
2198
|
+
function highlightFor(selection, capture) {
|
|
2199
|
+
const scale = capture.width / window.innerWidth;
|
|
2200
|
+
const { rect } = selection;
|
|
2201
|
+
return {
|
|
2202
|
+
color: SELECTION_COLOR,
|
|
2203
|
+
end: {
|
|
2204
|
+
x: (rect.x + rect.width) * scale,
|
|
2205
|
+
y: (rect.y + rect.height) * scale
|
|
2206
|
+
},
|
|
2207
|
+
id: `selection-${rect.x}-${rect.y}`,
|
|
2208
|
+
start: {
|
|
2209
|
+
x: rect.x * scale,
|
|
2210
|
+
y: rect.y * scale
|
|
2211
|
+
},
|
|
2212
|
+
tool: "rectangle"
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
function drawZoneOverlay(context, zone) {
|
|
2216
|
+
context.save();
|
|
2217
|
+
context.fillStyle = DIM_OVERLAY;
|
|
2218
|
+
context.beginPath();
|
|
2219
|
+
context.rect(0, 0, context.canvas.width, context.canvas.height);
|
|
2220
|
+
context.rect(zone.x, zone.y, zone.width, zone.height);
|
|
2221
|
+
context.fill("evenodd");
|
|
2222
|
+
for (const [color, width] of [[OUTLINE_GLOW, GLOW_WIDTH], [OUTLINE_COLOR, OUTLINE_WIDTH]]) {
|
|
2223
|
+
context.strokeStyle = color;
|
|
2224
|
+
context.lineWidth = width * zone.scale;
|
|
2225
|
+
context.strokeRect(zone.x, zone.y, zone.width, zone.height);
|
|
2226
|
+
}
|
|
2227
|
+
context.restore();
|
|
2228
|
+
}
|
|
2229
|
+
function drawCloseUp(capture, zone) {
|
|
2230
|
+
return new Promise((resolve) => {
|
|
2231
|
+
const canvas = document.createElement("canvas");
|
|
2232
|
+
canvas.width = capture.width;
|
|
2233
|
+
canvas.height = capture.height;
|
|
2234
|
+
const context = canvas.getContext("2d");
|
|
2235
|
+
const image = new Image();
|
|
2236
|
+
image.onload = () => {
|
|
2237
|
+
if (!context) {
|
|
2238
|
+
resolve(null);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
context.drawImage(image, 0, 0, capture.width, capture.height);
|
|
2242
|
+
drawZoneOverlay(context, zone);
|
|
2243
|
+
resolve(capturedFromCanvas(canvas, CLOSE_UP_MAX_WIDTH));
|
|
2244
|
+
};
|
|
2245
|
+
image.onerror = () => resolve(null);
|
|
2246
|
+
image.src = capture.objectUrl;
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Zoomed view of the picked zone: the surrounding area captured for context,
|
|
2251
|
+
* everything outside the selection dimmed and the zone framed with a glowing
|
|
2252
|
+
* outline — unambiguous to eyes and vision models alike.
|
|
2253
|
+
* Falls back to the plain context shot when the overlay cannot be drawn.
|
|
2254
|
+
*/
|
|
2255
|
+
async function captureElementCloseUp(element) {
|
|
2256
|
+
try {
|
|
2257
|
+
const root = contextRootFor(element);
|
|
2258
|
+
const rootRect = root.getBoundingClientRect();
|
|
2259
|
+
const elementRect = element.getBoundingClientRect();
|
|
2260
|
+
const capture = await captureViewport({
|
|
2261
|
+
element: root,
|
|
2262
|
+
maxWidth: CLOSE_UP_MAX_WIDTH
|
|
2263
|
+
});
|
|
2264
|
+
if (!capture || root === element) return capture;
|
|
2265
|
+
const outlined = await drawCloseUp(capture, closeUpGeometry({
|
|
2266
|
+
capture,
|
|
2267
|
+
elementRect,
|
|
2268
|
+
rootRect
|
|
2269
|
+
}));
|
|
2270
|
+
if (outlined) {
|
|
2271
|
+
releaseCapture(capture);
|
|
2272
|
+
return outlined;
|
|
2273
|
+
}
|
|
2274
|
+
return capture;
|
|
2275
|
+
} catch {
|
|
2276
|
+
return null;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
//#endregion
|
|
2069
2280
|
//#region src/feedback/core/submit.ts
|
|
2070
2281
|
const MAX_TITLE_LENGTH = 100;
|
|
2071
2282
|
const NEWLINE = /\r?\n/;
|
|
@@ -2164,9 +2375,40 @@ function createFeedbackTransport(client) {
|
|
|
2164
2375
|
};
|
|
2165
2376
|
}
|
|
2166
2377
|
//#endregion
|
|
2378
|
+
//#region src/feedback/ui/use-route-sync.ts
|
|
2379
|
+
/**
|
|
2380
|
+
* Calls `refresh` whenever the app navigates client-side while it is active.
|
|
2381
|
+
*
|
|
2382
|
+
* SPA routes never fire popstate on push/replace — patching history methods is
|
|
2383
|
+
* the only way to keep screenshot and URL describing the same page while the
|
|
2384
|
+
* reporter is composing.
|
|
2385
|
+
*/
|
|
2386
|
+
function useRouteSync(enabled, refresh) {
|
|
2387
|
+
useEffect(() => {
|
|
2388
|
+
if (!enabled) return;
|
|
2389
|
+
const originalPush = window.history.pushState.bind(window.history);
|
|
2390
|
+
const originalReplace = window.history.replaceState.bind(window.history);
|
|
2391
|
+
window.history.pushState = function pushState(data, unused, url) {
|
|
2392
|
+
const result = originalPush(data, unused, url);
|
|
2393
|
+
refresh();
|
|
2394
|
+
return result;
|
|
2395
|
+
};
|
|
2396
|
+
window.history.replaceState = function replaceState(data, unused, url) {
|
|
2397
|
+
const result = originalReplace(data, unused, url);
|
|
2398
|
+
refresh();
|
|
2399
|
+
return result;
|
|
2400
|
+
};
|
|
2401
|
+
window.addEventListener("popstate", refresh);
|
|
2402
|
+
return () => {
|
|
2403
|
+
window.history.pushState = originalPush;
|
|
2404
|
+
window.history.replaceState = originalReplace;
|
|
2405
|
+
window.removeEventListener("popstate", refresh);
|
|
2406
|
+
};
|
|
2407
|
+
}, [enabled, refresh]);
|
|
2408
|
+
}
|
|
2409
|
+
//#endregion
|
|
2167
2410
|
//#region src/feedback/ui/use-widget-state.ts
|
|
2168
2411
|
const SUCCESS_CLOSE_DELAY = 2400;
|
|
2169
|
-
const ELEMENT_CAPTURE_MAX_WIDTH = 1200;
|
|
2170
2412
|
const IS_APPLE = /Mac|iPhone|iPad/;
|
|
2171
2413
|
const MILLISECONDS_PER_DAY = 864e5;
|
|
2172
2414
|
const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
|
|
@@ -2204,23 +2446,6 @@ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined
|
|
|
2204
2446
|
};
|
|
2205
2447
|
return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
|
|
2206
2448
|
}
|
|
2207
|
-
function highlightFor(selection, capture) {
|
|
2208
|
-
const scale = capture.width / window.innerWidth;
|
|
2209
|
-
const { rect } = selection;
|
|
2210
|
-
return {
|
|
2211
|
-
color: "#4f46e5",
|
|
2212
|
-
end: {
|
|
2213
|
-
x: (rect.x + rect.width) * scale,
|
|
2214
|
-
y: (rect.y + rect.height) * scale
|
|
2215
|
-
},
|
|
2216
|
-
id: `selection-${rect.x}-${rect.y}`,
|
|
2217
|
-
start: {
|
|
2218
|
-
x: rect.x * scale,
|
|
2219
|
-
y: rect.y * scale
|
|
2220
|
-
},
|
|
2221
|
-
tool: "rectangle"
|
|
2222
|
-
};
|
|
2223
|
-
}
|
|
2224
2449
|
/**
|
|
2225
2450
|
* A capture whose object URL is revoked the moment it is replaced or dropped —
|
|
2226
2451
|
* holding one without this leaks the blob for the lifetime of the page.
|
|
@@ -2260,12 +2485,9 @@ function useWidgetState(props) {
|
|
|
2260
2485
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
2261
2486
|
const [error, setError] = useState(null);
|
|
2262
2487
|
const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
|
|
2263
|
-
const captureRef = useRef(null);
|
|
2264
2488
|
const pickRef = useRef(0);
|
|
2265
2489
|
const consoleRef = useRef(null);
|
|
2266
|
-
|
|
2267
|
-
captureRef.current = capture;
|
|
2268
|
-
}, [capture]);
|
|
2490
|
+
const frozenContext = useRef(null);
|
|
2269
2491
|
useEffect(() => {
|
|
2270
2492
|
setDismissedUntil(readDismissedUntil(dismissalKey));
|
|
2271
2493
|
}, [dismissalKey]);
|
|
@@ -2278,15 +2500,20 @@ function useWidgetState(props) {
|
|
|
2278
2500
|
recorder.stop();
|
|
2279
2501
|
};
|
|
2280
2502
|
}, [props.captureConsole]);
|
|
2281
|
-
|
|
2503
|
+
/** Frozen at the same instant as the screenshot, so shot and URL always match. */
|
|
2504
|
+
const takeCapture = useCallback(() => {
|
|
2505
|
+
const context = collectPageContext({ sdkVersion: SDK_VERSION });
|
|
2282
2506
|
setIsCapturing(true);
|
|
2283
|
-
captureViewport(
|
|
2507
|
+
captureViewport().catch(() => null).then((image) => {
|
|
2284
2508
|
setIsCapturing(false);
|
|
2285
2509
|
replaceCapture(image);
|
|
2510
|
+
frozenContext.current = image ? context : null;
|
|
2286
2511
|
});
|
|
2287
2512
|
}, [replaceCapture]);
|
|
2513
|
+
useRouteSync(isOpen && step === "compose" && annotations.length === 0 && !selection && props.captureOnOpen !== false, takeCapture);
|
|
2288
2514
|
const reset = useCallback(() => {
|
|
2289
2515
|
pickRef.current++;
|
|
2516
|
+
frozenContext.current = null;
|
|
2290
2517
|
replaceCapture(null);
|
|
2291
2518
|
replaceElementCapture(null);
|
|
2292
2519
|
setAnnotations([]);
|
|
@@ -2331,21 +2558,24 @@ function useWidgetState(props) {
|
|
|
2331
2558
|
props.onOpen,
|
|
2332
2559
|
takeCapture
|
|
2333
2560
|
]);
|
|
2334
|
-
|
|
2561
|
+
/** Fresh pixels so the highlight maps onto the scroll position the element had. */
|
|
2562
|
+
const selectElement = useCallback(async (element) => {
|
|
2335
2563
|
const picked = buildElementSelection(element);
|
|
2336
2564
|
setSelection(picked);
|
|
2337
2565
|
setStep("compose");
|
|
2338
|
-
const current = captureRef.current;
|
|
2339
|
-
if (current) setAnnotations((previous) => [...previous.filter((item) => !item.id.startsWith("selection-")), highlightFor(picked, current)]);
|
|
2340
2566
|
const pick = ++pickRef.current;
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2567
|
+
const context = collectPageContext({ sdkVersion: SDK_VERSION });
|
|
2568
|
+
const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
|
|
2569
|
+
if (pickRef.current !== pick) {
|
|
2570
|
+
releaseCapture(fresh);
|
|
2571
|
+
releaseCapture(closeUp);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
replaceCapture(fresh);
|
|
2575
|
+
replaceElementCapture(closeUp);
|
|
2576
|
+
frozenContext.current = fresh ? context : null;
|
|
2577
|
+
setAnnotations(fresh ? [highlightFor(picked, fresh)] : []);
|
|
2578
|
+
}, [replaceCapture, replaceElementCapture]);
|
|
2349
2579
|
const clearSelection = useCallback(() => {
|
|
2350
2580
|
pickRef.current++;
|
|
2351
2581
|
replaceElementCapture(null);
|
|
@@ -2353,6 +2583,7 @@ function useWidgetState(props) {
|
|
|
2353
2583
|
setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
|
|
2354
2584
|
}, [replaceElementCapture]);
|
|
2355
2585
|
const removeCapture = useCallback(() => {
|
|
2586
|
+
frozenContext.current = null;
|
|
2356
2587
|
replaceCapture(null);
|
|
2357
2588
|
setAnnotations([]);
|
|
2358
2589
|
}, [replaceCapture]);
|
|
@@ -2386,7 +2617,7 @@ function useWidgetState(props) {
|
|
|
2386
2617
|
annotations,
|
|
2387
2618
|
category,
|
|
2388
2619
|
context: {
|
|
2389
|
-
...collectPageContext({ sdkVersion:
|
|
2620
|
+
...frozenContext.current ?? collectPageContext({ sdkVersion: "0.3.1" }),
|
|
2390
2621
|
consoleEvents: consoleRef.current?.events(),
|
|
2391
2622
|
metadata: props.metadata,
|
|
2392
2623
|
selection: selection ?? void 0
|