quick-bug-reporter-react 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/index.cjs +3048 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +331 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +3028 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3028 @@
|
|
|
1
|
+
import html2canvas from 'html2canvas-pro';
|
|
2
|
+
import { createContext, useState, useRef, useMemo, useEffect, useCallback, useContext } from 'react';
|
|
3
|
+
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
import { Square, Camera, Crop, CircleDot, FilePenLine, TriangleAlert, ArrowRight, ArrowLeft, X } from 'lucide-react';
|
|
5
|
+
import { cva } from 'class-variance-authority';
|
|
6
|
+
import { Slot, Dialog as Dialog$1 } from 'radix-ui';
|
|
7
|
+
import { clsx } from 'clsx';
|
|
8
|
+
import { twMerge } from 'tailwind-merge';
|
|
9
|
+
|
|
10
|
+
// src/core/NetworkLogger.ts
|
|
11
|
+
function resolveMethod(input, init) {
|
|
12
|
+
if (init?.method) {
|
|
13
|
+
return init.method;
|
|
14
|
+
}
|
|
15
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
16
|
+
return input.method;
|
|
17
|
+
}
|
|
18
|
+
return "GET";
|
|
19
|
+
}
|
|
20
|
+
function resolveUrl(input) {
|
|
21
|
+
if (typeof input === "string") {
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
if (typeof URL !== "undefined" && input instanceof URL) {
|
|
25
|
+
return input.toString();
|
|
26
|
+
}
|
|
27
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
28
|
+
return input.url;
|
|
29
|
+
}
|
|
30
|
+
return String(input);
|
|
31
|
+
}
|
|
32
|
+
function nowMs() {
|
|
33
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
34
|
+
return performance.now();
|
|
35
|
+
}
|
|
36
|
+
return Date.now();
|
|
37
|
+
}
|
|
38
|
+
var NetworkLogger = class {
|
|
39
|
+
constructor() {
|
|
40
|
+
this.originalFetch = null;
|
|
41
|
+
this.logs = [];
|
|
42
|
+
this.recording = false;
|
|
43
|
+
}
|
|
44
|
+
start() {
|
|
45
|
+
if (this.recording) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof globalThis.fetch !== "function") {
|
|
49
|
+
throw new Error("Fetch API is unavailable in this environment.");
|
|
50
|
+
}
|
|
51
|
+
this.originalFetch = globalThis.fetch;
|
|
52
|
+
const originalFetch = this.originalFetch;
|
|
53
|
+
globalThis.fetch = (async (input, init) => {
|
|
54
|
+
const started = nowMs();
|
|
55
|
+
const method = resolveMethod(input, init);
|
|
56
|
+
const url = resolveUrl(input);
|
|
57
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
58
|
+
try {
|
|
59
|
+
const response = await originalFetch.call(globalThis, input, init);
|
|
60
|
+
this.logs.push({
|
|
61
|
+
method,
|
|
62
|
+
url,
|
|
63
|
+
status: response.status,
|
|
64
|
+
durationMs: Math.max(0, Math.round(nowMs() - started)),
|
|
65
|
+
timestamp
|
|
66
|
+
});
|
|
67
|
+
return response;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.logs.push({
|
|
70
|
+
method,
|
|
71
|
+
url,
|
|
72
|
+
status: null,
|
|
73
|
+
durationMs: Math.max(0, Math.round(nowMs() - started)),
|
|
74
|
+
timestamp
|
|
75
|
+
});
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
this.recording = true;
|
|
80
|
+
}
|
|
81
|
+
stop() {
|
|
82
|
+
if (!this.recording) {
|
|
83
|
+
return this.getLogs();
|
|
84
|
+
}
|
|
85
|
+
if (this.originalFetch) {
|
|
86
|
+
globalThis.fetch = this.originalFetch;
|
|
87
|
+
}
|
|
88
|
+
this.originalFetch = null;
|
|
89
|
+
this.recording = false;
|
|
90
|
+
return this.getLogs();
|
|
91
|
+
}
|
|
92
|
+
clear() {
|
|
93
|
+
this.logs = [];
|
|
94
|
+
}
|
|
95
|
+
getLogs() {
|
|
96
|
+
return [...this.logs];
|
|
97
|
+
}
|
|
98
|
+
isRecording() {
|
|
99
|
+
return this.recording;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/core/ScreenRecorder.ts
|
|
104
|
+
var MIME_TYPES = ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"];
|
|
105
|
+
function mapRecorderError(error) {
|
|
106
|
+
if (error instanceof DOMException) {
|
|
107
|
+
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
|
|
108
|
+
return new Error("Screen or microphone permission was denied.");
|
|
109
|
+
}
|
|
110
|
+
if (error.name === "AbortError") {
|
|
111
|
+
return new Error("Recording setup was cancelled before it started.");
|
|
112
|
+
}
|
|
113
|
+
if (error.name === "NotFoundError") {
|
|
114
|
+
return new Error("No screen/tab or microphone source was found.");
|
|
115
|
+
}
|
|
116
|
+
if (error.name === "NotReadableError") {
|
|
117
|
+
return new Error("Unable to access the selected screen/tab or microphone.");
|
|
118
|
+
}
|
|
119
|
+
return new Error(error.message || "Failed to start recording.");
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
return error;
|
|
123
|
+
}
|
|
124
|
+
return new Error("Failed to start recording.");
|
|
125
|
+
}
|
|
126
|
+
function pickMimeType() {
|
|
127
|
+
if (typeof MediaRecorder === "undefined") {
|
|
128
|
+
return void 0;
|
|
129
|
+
}
|
|
130
|
+
return MIME_TYPES.find((mime) => MediaRecorder.isTypeSupported(mime));
|
|
131
|
+
}
|
|
132
|
+
var ScreenRecorder = class {
|
|
133
|
+
constructor() {
|
|
134
|
+
this.mediaRecorder = null;
|
|
135
|
+
this.displayStream = null;
|
|
136
|
+
this.microphoneStream = null;
|
|
137
|
+
this.mixedStream = null;
|
|
138
|
+
this.audioContext = null;
|
|
139
|
+
this.displayAudioStream = null;
|
|
140
|
+
this.microphoneAudioStream = null;
|
|
141
|
+
this.chunks = [];
|
|
142
|
+
this.recording = false;
|
|
143
|
+
this.stopPromise = null;
|
|
144
|
+
this.stopResolver = null;
|
|
145
|
+
this.lastBlob = null;
|
|
146
|
+
this.onEnded = null;
|
|
147
|
+
}
|
|
148
|
+
async start(options = {}) {
|
|
149
|
+
if (this.recording) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices || typeof navigator.mediaDevices.getDisplayMedia !== "function" || typeof navigator.mediaDevices.getUserMedia !== "function" || typeof MediaRecorder === "undefined") {
|
|
153
|
+
throw new Error("This browser does not support screen and microphone recording.");
|
|
154
|
+
}
|
|
155
|
+
this.cleanupStreams();
|
|
156
|
+
this.resetStopPromise();
|
|
157
|
+
this.chunks = [];
|
|
158
|
+
this.lastBlob = null;
|
|
159
|
+
this.onEnded = options.onEnded ?? null;
|
|
160
|
+
try {
|
|
161
|
+
const displayOptions = {
|
|
162
|
+
video: {
|
|
163
|
+
frameRate: { ideal: 15, max: 24 },
|
|
164
|
+
width: { ideal: 1280, max: 1920 },
|
|
165
|
+
height: { ideal: 720, max: 1080 }
|
|
166
|
+
},
|
|
167
|
+
audio: true,
|
|
168
|
+
// Browser hints only; the user still decides what to share in the native prompt.
|
|
169
|
+
preferCurrentTab: true,
|
|
170
|
+
selfBrowserSurface: "include",
|
|
171
|
+
surfaceSwitching: "include",
|
|
172
|
+
monitorTypeSurfaces: "include"
|
|
173
|
+
};
|
|
174
|
+
const displayStream = await navigator.mediaDevices.getDisplayMedia(displayOptions);
|
|
175
|
+
this.displayStream = displayStream;
|
|
176
|
+
let microphoneStream;
|
|
177
|
+
try {
|
|
178
|
+
microphoneStream = await navigator.mediaDevices.getUserMedia({
|
|
179
|
+
audio: {
|
|
180
|
+
echoCancellation: true,
|
|
181
|
+
noiseSuppression: true
|
|
182
|
+
},
|
|
183
|
+
video: false
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
for (const track of displayStream.getTracks()) {
|
|
187
|
+
track.stop();
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
this.microphoneStream = microphoneStream;
|
|
192
|
+
const microphoneAudioTracks = microphoneStream.getAudioTracks();
|
|
193
|
+
if (microphoneAudioTracks.length === 0) {
|
|
194
|
+
for (const track of displayStream.getTracks()) {
|
|
195
|
+
track.stop();
|
|
196
|
+
}
|
|
197
|
+
for (const track of microphoneStream.getTracks()) {
|
|
198
|
+
track.stop();
|
|
199
|
+
}
|
|
200
|
+
throw new Error("Microphone audio track is unavailable.");
|
|
201
|
+
}
|
|
202
|
+
for (const track of microphoneAudioTracks) {
|
|
203
|
+
track.enabled = true;
|
|
204
|
+
}
|
|
205
|
+
const mixedAudioTracks = await this.buildMixedAudioTracks(displayStream, microphoneStream);
|
|
206
|
+
const tracks = [...displayStream.getVideoTracks(), ...mixedAudioTracks];
|
|
207
|
+
if (tracks.length === 0) {
|
|
208
|
+
for (const track of displayStream.getTracks()) {
|
|
209
|
+
track.stop();
|
|
210
|
+
}
|
|
211
|
+
for (const track of microphoneStream.getTracks()) {
|
|
212
|
+
track.stop();
|
|
213
|
+
}
|
|
214
|
+
throw new Error("No media tracks available for recording.");
|
|
215
|
+
}
|
|
216
|
+
this.mixedStream = new MediaStream(tracks);
|
|
217
|
+
const mimeType = pickMimeType();
|
|
218
|
+
const recorderOptions = {
|
|
219
|
+
videoBitsPerSecond: 12e5,
|
|
220
|
+
audioBitsPerSecond: 96e3
|
|
221
|
+
};
|
|
222
|
+
if (mimeType) {
|
|
223
|
+
recorderOptions.mimeType = mimeType;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
this.mediaRecorder = new MediaRecorder(this.mixedStream, recorderOptions);
|
|
227
|
+
} catch {
|
|
228
|
+
if (mimeType) {
|
|
229
|
+
try {
|
|
230
|
+
this.mediaRecorder = new MediaRecorder(this.mixedStream, { mimeType });
|
|
231
|
+
} catch {
|
|
232
|
+
this.mediaRecorder = new MediaRecorder(this.mixedStream);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
this.mediaRecorder = new MediaRecorder(this.mixedStream);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
239
|
+
if (event.data && event.data.size > 0) {
|
|
240
|
+
this.chunks.push(event.data);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
this.mediaRecorder.onstop = () => {
|
|
244
|
+
const mime = this.mediaRecorder?.mimeType || mimeType || "video/webm";
|
|
245
|
+
const blob = this.chunks.length > 0 ? new Blob(this.chunks, { type: mime }) : null;
|
|
246
|
+
this.lastBlob = blob;
|
|
247
|
+
this.recording = false;
|
|
248
|
+
this.cleanupStreams();
|
|
249
|
+
const resolve = this.stopResolver;
|
|
250
|
+
this.resetStopPromise();
|
|
251
|
+
resolve?.(blob);
|
|
252
|
+
};
|
|
253
|
+
for (const track of displayStream.getVideoTracks()) {
|
|
254
|
+
track.onended = () => {
|
|
255
|
+
if (!this.recording) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
void this.stop().finally(() => {
|
|
259
|
+
this.onEnded?.();
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
this.mediaRecorder.start(1e3);
|
|
264
|
+
this.recording = true;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.cleanupStreams();
|
|
267
|
+
this.recording = false;
|
|
268
|
+
throw mapRecorderError(error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async stop() {
|
|
272
|
+
if (!this.recording && !this.stopPromise) {
|
|
273
|
+
return this.lastBlob;
|
|
274
|
+
}
|
|
275
|
+
if (this.stopPromise) {
|
|
276
|
+
return this.stopPromise;
|
|
277
|
+
}
|
|
278
|
+
if (!this.mediaRecorder) {
|
|
279
|
+
this.recording = false;
|
|
280
|
+
this.cleanupStreams();
|
|
281
|
+
return this.lastBlob;
|
|
282
|
+
}
|
|
283
|
+
this.stopPromise = new Promise((resolve) => {
|
|
284
|
+
this.stopResolver = resolve;
|
|
285
|
+
});
|
|
286
|
+
if (this.mediaRecorder.state === "inactive") {
|
|
287
|
+
this.mediaRecorder.onstop?.(new Event("stop"));
|
|
288
|
+
} else {
|
|
289
|
+
this.mediaRecorder.stop();
|
|
290
|
+
}
|
|
291
|
+
return this.stopPromise;
|
|
292
|
+
}
|
|
293
|
+
isRecording() {
|
|
294
|
+
return this.recording;
|
|
295
|
+
}
|
|
296
|
+
getLastBlob() {
|
|
297
|
+
return this.lastBlob;
|
|
298
|
+
}
|
|
299
|
+
clearLastBlob() {
|
|
300
|
+
this.lastBlob = null;
|
|
301
|
+
}
|
|
302
|
+
dispose() {
|
|
303
|
+
this.cleanupStreams();
|
|
304
|
+
this.recording = false;
|
|
305
|
+
this.resetStopPromise();
|
|
306
|
+
}
|
|
307
|
+
cleanupStreams() {
|
|
308
|
+
if (this.audioContext) {
|
|
309
|
+
void this.audioContext.close().catch(() => void 0);
|
|
310
|
+
}
|
|
311
|
+
if (this.mixedStream) {
|
|
312
|
+
for (const track of this.mixedStream.getTracks()) {
|
|
313
|
+
track.stop();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (this.displayStream) {
|
|
317
|
+
for (const track of this.displayStream.getTracks()) {
|
|
318
|
+
track.stop();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (this.microphoneStream) {
|
|
322
|
+
for (const track of this.microphoneStream.getTracks()) {
|
|
323
|
+
track.stop();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (this.displayAudioStream) {
|
|
327
|
+
for (const track of this.displayAudioStream.getTracks()) {
|
|
328
|
+
track.stop();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (this.microphoneAudioStream) {
|
|
332
|
+
for (const track of this.microphoneAudioStream.getTracks()) {
|
|
333
|
+
track.stop();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
this.mediaRecorder = null;
|
|
337
|
+
this.mixedStream = null;
|
|
338
|
+
this.displayStream = null;
|
|
339
|
+
this.microphoneStream = null;
|
|
340
|
+
this.audioContext = null;
|
|
341
|
+
this.displayAudioStream = null;
|
|
342
|
+
this.microphoneAudioStream = null;
|
|
343
|
+
}
|
|
344
|
+
resetStopPromise() {
|
|
345
|
+
this.stopPromise = null;
|
|
346
|
+
this.stopResolver = null;
|
|
347
|
+
}
|
|
348
|
+
async buildMixedAudioTracks(displayStream, microphoneStream) {
|
|
349
|
+
const displayAudioTracks = displayStream.getAudioTracks();
|
|
350
|
+
const microphoneAudioTracks = microphoneStream.getAudioTracks();
|
|
351
|
+
if (displayAudioTracks.length === 0 && microphoneAudioTracks.length === 0) {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
if (typeof AudioContext === "undefined") {
|
|
355
|
+
return [...displayAudioTracks, ...microphoneAudioTracks];
|
|
356
|
+
}
|
|
357
|
+
const audioContext = new AudioContext();
|
|
358
|
+
await audioContext.resume().catch(() => void 0);
|
|
359
|
+
const destination = audioContext.createMediaStreamDestination();
|
|
360
|
+
if (displayAudioTracks.length > 0) {
|
|
361
|
+
this.displayAudioStream = new MediaStream(displayAudioTracks);
|
|
362
|
+
const displaySource = audioContext.createMediaStreamSource(this.displayAudioStream);
|
|
363
|
+
const displayGain = audioContext.createGain();
|
|
364
|
+
displayGain.gain.value = 1;
|
|
365
|
+
displaySource.connect(displayGain);
|
|
366
|
+
displayGain.connect(destination);
|
|
367
|
+
}
|
|
368
|
+
if (microphoneAudioTracks.length > 0) {
|
|
369
|
+
this.microphoneAudioStream = new MediaStream(microphoneAudioTracks);
|
|
370
|
+
const microphoneSource = audioContext.createMediaStreamSource(this.microphoneAudioStream);
|
|
371
|
+
const microphoneGain = audioContext.createGain();
|
|
372
|
+
microphoneGain.gain.value = 1;
|
|
373
|
+
microphoneSource.connect(microphoneGain);
|
|
374
|
+
microphoneGain.connect(destination);
|
|
375
|
+
}
|
|
376
|
+
this.audioContext = audioContext;
|
|
377
|
+
const mixedAudioTracks = destination.stream.getAudioTracks();
|
|
378
|
+
if (mixedAudioTracks.length > 0) {
|
|
379
|
+
return mixedAudioTracks;
|
|
380
|
+
}
|
|
381
|
+
return [...displayAudioTracks, ...microphoneAudioTracks];
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
var BUG_REPORTER_UI_ATTR = "data-bug-reporter-ui";
|
|
385
|
+
var UNSUPPORTED_COLOR_FUNCTION_PATTERN = /\b(?:lab|lch|oklab|oklch|color)\([^)]*\)/gi;
|
|
386
|
+
var COLOR_FALLBACK_VALUE = "rgb(17, 24, 39)";
|
|
387
|
+
var DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
|
388
|
+
function replaceUnsupportedColorFunctions(value) {
|
|
389
|
+
return value.replace(UNSUPPORTED_COLOR_FUNCTION_PATTERN, COLOR_FALLBACK_VALUE);
|
|
390
|
+
}
|
|
391
|
+
function unclampClonedLayout(clonedDoc) {
|
|
392
|
+
for (const el of [clonedDoc.documentElement, clonedDoc.body]) {
|
|
393
|
+
if (!el) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
el.style.setProperty("height", "auto", "important");
|
|
397
|
+
el.style.setProperty("overflow", "visible", "important");
|
|
398
|
+
}
|
|
399
|
+
const overrideStyle = clonedDoc.createElement("style");
|
|
400
|
+
overrideStyle.textContent = `html, body { height: auto !important; overflow: visible !important; }`;
|
|
401
|
+
clonedDoc.head.appendChild(overrideStyle);
|
|
402
|
+
}
|
|
403
|
+
function sanitizeCloneForModernColors(clonedDocument) {
|
|
404
|
+
const styleElements = clonedDocument.querySelectorAll("style");
|
|
405
|
+
for (const styleElement of styleElements) {
|
|
406
|
+
if (!styleElement.textContent) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
styleElement.textContent = replaceUnsupportedColorFunctions(styleElement.textContent);
|
|
410
|
+
}
|
|
411
|
+
const styledElements = clonedDocument.querySelectorAll("[style]");
|
|
412
|
+
for (const element of styledElements) {
|
|
413
|
+
const style = element.getAttribute("style");
|
|
414
|
+
if (!style) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
element.setAttribute("style", replaceUnsupportedColorFunctions(style));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function canvasToPngBlob(canvas) {
|
|
421
|
+
const blob = await new Promise((resolve) => {
|
|
422
|
+
canvas.toBlob(resolve, "image/png", 1);
|
|
423
|
+
});
|
|
424
|
+
if (!blob) {
|
|
425
|
+
throw new Error("Failed to generate screenshot image.");
|
|
426
|
+
}
|
|
427
|
+
return blob;
|
|
428
|
+
}
|
|
429
|
+
var ScreenshotCapturer = class {
|
|
430
|
+
async capture() {
|
|
431
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
432
|
+
throw new Error("Screenshot capture is not available in this environment.");
|
|
433
|
+
}
|
|
434
|
+
const target = document.documentElement;
|
|
435
|
+
if (!target) {
|
|
436
|
+
throw new Error("Could not find a capture target for screenshot.");
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
return await this.captureViaDomSnapshot(target);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const message = error instanceof Error ? error.message : "Unknown DOM capture error.";
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Quick screenshot failed in this browser (${message}). Try video capture for this page.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async captureViaDomSnapshot(target) {
|
|
448
|
+
const viewportWidth = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);
|
|
449
|
+
const viewportHeight = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);
|
|
450
|
+
const scale = Math.min(Math.max(window.devicePixelRatio || 1, 1), 2);
|
|
451
|
+
const bodyBackgroundColor = window.getComputedStyle(document.body).backgroundColor;
|
|
452
|
+
const backgroundColor = bodyBackgroundColor && bodyBackgroundColor !== "rgba(0, 0, 0, 0)" ? bodyBackgroundColor : DEFAULT_BACKGROUND_COLOR;
|
|
453
|
+
const savedScrollX = window.scrollX;
|
|
454
|
+
const savedScrollY = window.scrollY;
|
|
455
|
+
window.scrollTo(0, 0);
|
|
456
|
+
const htmlEl = document.documentElement;
|
|
457
|
+
const bodyEl = document.body;
|
|
458
|
+
const origHtmlHeight = htmlEl.style.height;
|
|
459
|
+
const origHtmlOverflow = htmlEl.style.overflow;
|
|
460
|
+
const origBodyHeight = bodyEl.style.height;
|
|
461
|
+
const origBodyOverflow = bodyEl.style.overflow;
|
|
462
|
+
htmlEl.style.setProperty("height", "auto", "important");
|
|
463
|
+
htmlEl.style.setProperty("overflow", "visible", "important");
|
|
464
|
+
bodyEl.style.setProperty("height", "auto", "important");
|
|
465
|
+
bodyEl.style.setProperty("overflow", "visible", "important");
|
|
466
|
+
const sharedOptions = {
|
|
467
|
+
backgroundColor,
|
|
468
|
+
logging: false,
|
|
469
|
+
useCORS: true,
|
|
470
|
+
allowTaint: false,
|
|
471
|
+
scale,
|
|
472
|
+
windowWidth: viewportWidth,
|
|
473
|
+
windowHeight: viewportHeight,
|
|
474
|
+
scrollX: 0,
|
|
475
|
+
scrollY: 0,
|
|
476
|
+
ignoreElements: (element) => {
|
|
477
|
+
return element instanceof HTMLElement && element.getAttribute(BUG_REPORTER_UI_ATTR) === "true";
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
let lastError = null;
|
|
481
|
+
const attempts = [
|
|
482
|
+
{ foreignObjectRendering: true, sanitizeColorFunctions: false },
|
|
483
|
+
{ foreignObjectRendering: false, sanitizeColorFunctions: false },
|
|
484
|
+
{ foreignObjectRendering: false, sanitizeColorFunctions: true }
|
|
485
|
+
];
|
|
486
|
+
try {
|
|
487
|
+
for (const attempt of attempts) {
|
|
488
|
+
try {
|
|
489
|
+
const fullCanvas = await html2canvas(target, {
|
|
490
|
+
...sharedOptions,
|
|
491
|
+
foreignObjectRendering: attempt.foreignObjectRendering,
|
|
492
|
+
onclone: (clonedDocument) => {
|
|
493
|
+
unclampClonedLayout(clonedDocument);
|
|
494
|
+
if (attempt.sanitizeColorFunctions) {
|
|
495
|
+
sanitizeCloneForModernColors(clonedDocument);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
const cropW = Math.round(viewportWidth * scale);
|
|
500
|
+
const cropH = Math.round(viewportHeight * scale);
|
|
501
|
+
const cropX = Math.round(savedScrollX * scale);
|
|
502
|
+
const cropY = Math.round(savedScrollY * scale);
|
|
503
|
+
const cropCanvas = document.createElement("canvas");
|
|
504
|
+
cropCanvas.width = cropW;
|
|
505
|
+
cropCanvas.height = cropH;
|
|
506
|
+
const ctx = cropCanvas.getContext("2d");
|
|
507
|
+
if (!ctx) {
|
|
508
|
+
return await canvasToPngBlob(fullCanvas);
|
|
509
|
+
}
|
|
510
|
+
ctx.drawImage(fullCanvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
|
|
511
|
+
return await canvasToPngBlob(cropCanvas);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
lastError = error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
throw lastError ?? new Error("DOM snapshot capture failed.");
|
|
517
|
+
} finally {
|
|
518
|
+
htmlEl.style.height = origHtmlHeight;
|
|
519
|
+
htmlEl.style.overflow = origHtmlOverflow;
|
|
520
|
+
bodyEl.style.height = origBodyHeight;
|
|
521
|
+
bodyEl.style.overflow = origBodyOverflow;
|
|
522
|
+
window.scrollTo(savedScrollX, savedScrollY);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async captureRegion(region) {
|
|
526
|
+
const fullBlob = await this.capture();
|
|
527
|
+
return this.cropBlob(fullBlob, region);
|
|
528
|
+
}
|
|
529
|
+
async cropBlob(blob, region) {
|
|
530
|
+
const bitmap = await createImageBitmap(blob);
|
|
531
|
+
const scale = bitmap.width / (window.innerWidth || 1);
|
|
532
|
+
const sx = Math.round(region.x * scale);
|
|
533
|
+
const sy = Math.round(region.y * scale);
|
|
534
|
+
const sw = Math.round(region.width * scale);
|
|
535
|
+
const sh = Math.round(region.height * scale);
|
|
536
|
+
const canvas = document.createElement("canvas");
|
|
537
|
+
canvas.width = Math.max(1, sw);
|
|
538
|
+
canvas.height = Math.max(1, sh);
|
|
539
|
+
const ctx = canvas.getContext("2d");
|
|
540
|
+
if (!ctx) {
|
|
541
|
+
bitmap.close();
|
|
542
|
+
throw new Error("Could not create canvas for region crop.");
|
|
543
|
+
}
|
|
544
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
545
|
+
bitmap.close();
|
|
546
|
+
return canvasToPngBlob(canvas);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// src/core/types.ts
|
|
551
|
+
var DEFAULT_MAX_RECORDING_MS = 2 * 60 * 1e3;
|
|
552
|
+
function formatNetworkLogs(logs) {
|
|
553
|
+
if (logs.length === 0) {
|
|
554
|
+
return "No network requests captured.";
|
|
555
|
+
}
|
|
556
|
+
return logs.map((entry) => {
|
|
557
|
+
const status = entry.status === null ? "FAILED" : String(entry.status);
|
|
558
|
+
return `[${entry.timestamp}] ${entry.method.toUpperCase()} ${entry.url} -> ${status} (${entry.durationMs}ms)`;
|
|
559
|
+
}).join("\n");
|
|
560
|
+
}
|
|
561
|
+
function toErrorMessage(error) {
|
|
562
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
563
|
+
return error.message;
|
|
564
|
+
}
|
|
565
|
+
return "Unknown error";
|
|
566
|
+
}
|
|
567
|
+
function toBlobFile(blob, fileName, fallbackMimeType) {
|
|
568
|
+
return new File([blob], fileName, {
|
|
569
|
+
type: blob.type || fallbackMimeType,
|
|
570
|
+
lastModified: Date.now()
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function collectClientEnvironmentMetadata() {
|
|
574
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
|
575
|
+
return {
|
|
576
|
+
locale: null,
|
|
577
|
+
timezone: null,
|
|
578
|
+
language: null,
|
|
579
|
+
languages: [],
|
|
580
|
+
platform: null,
|
|
581
|
+
referrer: null,
|
|
582
|
+
colorScheme: "unknown",
|
|
583
|
+
viewport: {
|
|
584
|
+
width: null,
|
|
585
|
+
height: null,
|
|
586
|
+
pixelRatio: null
|
|
587
|
+
},
|
|
588
|
+
screen: {
|
|
589
|
+
width: null,
|
|
590
|
+
height: null,
|
|
591
|
+
availWidth: null,
|
|
592
|
+
availHeight: null,
|
|
593
|
+
colorDepth: null
|
|
594
|
+
},
|
|
595
|
+
device: {
|
|
596
|
+
hardwareConcurrency: null,
|
|
597
|
+
deviceMemoryGb: null,
|
|
598
|
+
maxTouchPoints: null,
|
|
599
|
+
online: null,
|
|
600
|
+
cookieEnabled: null
|
|
601
|
+
},
|
|
602
|
+
connection: {
|
|
603
|
+
effectiveType: null,
|
|
604
|
+
downlinkMbps: null,
|
|
605
|
+
rttMs: null,
|
|
606
|
+
saveData: null
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const nav = navigator;
|
|
611
|
+
const connection = nav.connection || nav.mozConnection || nav.webkitConnection;
|
|
612
|
+
let colorScheme = "unknown";
|
|
613
|
+
if (typeof window.matchMedia === "function") {
|
|
614
|
+
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
615
|
+
colorScheme = "dark";
|
|
616
|
+
} else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
|
617
|
+
colorScheme = "light";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
let timezone = null;
|
|
621
|
+
try {
|
|
622
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
|
|
623
|
+
} catch {
|
|
624
|
+
timezone = null;
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
locale: typeof navigator.language === "string" ? navigator.language : null,
|
|
628
|
+
timezone,
|
|
629
|
+
language: typeof navigator.language === "string" ? navigator.language : null,
|
|
630
|
+
languages: Array.isArray(navigator.languages) ? [...navigator.languages] : [],
|
|
631
|
+
platform: typeof nav.userAgentData?.platform === "string" && nav.userAgentData.platform || (typeof navigator.platform === "string" ? navigator.platform : null),
|
|
632
|
+
referrer: typeof document !== "undefined" ? document.referrer || null : null,
|
|
633
|
+
colorScheme,
|
|
634
|
+
viewport: {
|
|
635
|
+
width: typeof window.innerWidth === "number" ? window.innerWidth : null,
|
|
636
|
+
height: typeof window.innerHeight === "number" ? window.innerHeight : null,
|
|
637
|
+
pixelRatio: typeof window.devicePixelRatio === "number" ? window.devicePixelRatio : null
|
|
638
|
+
},
|
|
639
|
+
screen: {
|
|
640
|
+
width: typeof window.screen?.width === "number" ? window.screen.width : null,
|
|
641
|
+
height: typeof window.screen?.height === "number" ? window.screen.height : null,
|
|
642
|
+
availWidth: typeof window.screen?.availWidth === "number" ? window.screen.availWidth : null,
|
|
643
|
+
availHeight: typeof window.screen?.availHeight === "number" ? window.screen.availHeight : null,
|
|
644
|
+
colorDepth: typeof window.screen?.colorDepth === "number" ? window.screen.colorDepth : null
|
|
645
|
+
},
|
|
646
|
+
device: {
|
|
647
|
+
hardwareConcurrency: typeof navigator.hardwareConcurrency === "number" ? navigator.hardwareConcurrency : null,
|
|
648
|
+
deviceMemoryGb: typeof nav.deviceMemory === "number" ? nav.deviceMemory : null,
|
|
649
|
+
maxTouchPoints: typeof navigator.maxTouchPoints === "number" ? navigator.maxTouchPoints : null,
|
|
650
|
+
online: typeof navigator.onLine === "boolean" ? navigator.onLine : null,
|
|
651
|
+
cookieEnabled: typeof navigator.cookieEnabled === "boolean" ? navigator.cookieEnabled : null
|
|
652
|
+
},
|
|
653
|
+
connection: {
|
|
654
|
+
effectiveType: typeof connection?.effectiveType === "string" ? connection.effectiveType : null,
|
|
655
|
+
downlinkMbps: typeof connection?.downlink === "number" ? connection.downlink : null,
|
|
656
|
+
rttMs: typeof connection?.rtt === "number" ? connection.rtt : null,
|
|
657
|
+
saveData: typeof connection?.saveData === "boolean" ? connection.saveData : null
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/core/BugSession.ts
|
|
663
|
+
var BugSession = class {
|
|
664
|
+
constructor(options = {}) {
|
|
665
|
+
this.recording = false;
|
|
666
|
+
this.startedAtMs = null;
|
|
667
|
+
this.autoStopTimeout = null;
|
|
668
|
+
this.stopInFlight = null;
|
|
669
|
+
this.lastArtifacts = null;
|
|
670
|
+
this.maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_RECORDING_MS;
|
|
671
|
+
this.screenRecorder = options.screenRecorder ?? new ScreenRecorder();
|
|
672
|
+
this.screenshotCapturer = options.screenshotCapturer ?? new ScreenshotCapturer();
|
|
673
|
+
this.networkLogger = options.networkLogger ?? new NetworkLogger();
|
|
674
|
+
this.onAutoStop = options.onAutoStop;
|
|
675
|
+
}
|
|
676
|
+
async start() {
|
|
677
|
+
if (this.recording) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
this.clearAutoStopTimer();
|
|
681
|
+
this.networkLogger.clear();
|
|
682
|
+
this.lastArtifacts = null;
|
|
683
|
+
this.networkLogger.start();
|
|
684
|
+
try {
|
|
685
|
+
await this.screenRecorder.start({
|
|
686
|
+
onEnded: () => {
|
|
687
|
+
void this.handleForcedStop("screen_ended");
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
} catch (error) {
|
|
691
|
+
this.networkLogger.stop();
|
|
692
|
+
this.networkLogger.clear();
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
this.recording = true;
|
|
696
|
+
this.startedAtMs = Date.now();
|
|
697
|
+
this.autoStopTimeout = setTimeout(() => {
|
|
698
|
+
void this.handleForcedStop("time_limit");
|
|
699
|
+
}, this.maxDurationMs);
|
|
700
|
+
}
|
|
701
|
+
async captureScreenshot(region) {
|
|
702
|
+
if (this.recording) {
|
|
703
|
+
await this.stop("manual");
|
|
704
|
+
}
|
|
705
|
+
this.clearAutoStopTimer();
|
|
706
|
+
this.networkLogger.clear();
|
|
707
|
+
this.lastArtifacts = null;
|
|
708
|
+
const startedAtMs = Date.now();
|
|
709
|
+
this.networkLogger.start();
|
|
710
|
+
try {
|
|
711
|
+
const screenshotBlob = region ? await this.screenshotCapturer.captureRegion(region) : await this.screenshotCapturer.capture();
|
|
712
|
+
const networkLogs = this.networkLogger.stop();
|
|
713
|
+
const stoppedAtMs = Date.now();
|
|
714
|
+
const artifacts = {
|
|
715
|
+
videoBlob: null,
|
|
716
|
+
screenshotBlob,
|
|
717
|
+
networkLogs,
|
|
718
|
+
captureMode: "screenshot",
|
|
719
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
720
|
+
stoppedAt: new Date(stoppedAtMs).toISOString(),
|
|
721
|
+
elapsedMs: Math.max(0, stoppedAtMs - startedAtMs),
|
|
722
|
+
stopReason: "manual"
|
|
723
|
+
};
|
|
724
|
+
this.lastArtifacts = artifacts;
|
|
725
|
+
return artifacts;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.networkLogger.stop();
|
|
728
|
+
this.networkLogger.clear();
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async stop(reason = "manual") {
|
|
733
|
+
if (this.stopInFlight) {
|
|
734
|
+
return this.stopInFlight;
|
|
735
|
+
}
|
|
736
|
+
if (!this.recording) {
|
|
737
|
+
return this.lastArtifacts;
|
|
738
|
+
}
|
|
739
|
+
this.stopInFlight = this.stopInternal(reason).finally(() => {
|
|
740
|
+
this.stopInFlight = null;
|
|
741
|
+
});
|
|
742
|
+
return this.stopInFlight;
|
|
743
|
+
}
|
|
744
|
+
isRecording() {
|
|
745
|
+
return this.recording;
|
|
746
|
+
}
|
|
747
|
+
getElapsedMs() {
|
|
748
|
+
if (this.recording && this.startedAtMs) {
|
|
749
|
+
return Math.max(0, Date.now() - this.startedAtMs);
|
|
750
|
+
}
|
|
751
|
+
return this.lastArtifacts?.elapsedMs ?? 0;
|
|
752
|
+
}
|
|
753
|
+
getMaxDurationMs() {
|
|
754
|
+
return this.maxDurationMs;
|
|
755
|
+
}
|
|
756
|
+
getLastArtifacts() {
|
|
757
|
+
return this.lastArtifacts;
|
|
758
|
+
}
|
|
759
|
+
getLastCaptureMode() {
|
|
760
|
+
return this.lastArtifacts?.captureMode ?? null;
|
|
761
|
+
}
|
|
762
|
+
resetArtifacts() {
|
|
763
|
+
this.lastArtifacts = null;
|
|
764
|
+
this.screenRecorder.clearLastBlob();
|
|
765
|
+
this.networkLogger.clear();
|
|
766
|
+
}
|
|
767
|
+
async dispose() {
|
|
768
|
+
await this.stop("manual");
|
|
769
|
+
this.clearAutoStopTimer();
|
|
770
|
+
this.screenRecorder.dispose();
|
|
771
|
+
this.networkLogger.stop();
|
|
772
|
+
this.networkLogger.clear();
|
|
773
|
+
}
|
|
774
|
+
async stopInternal(reason) {
|
|
775
|
+
this.clearAutoStopTimer();
|
|
776
|
+
const startedAtMs = this.startedAtMs ?? Date.now();
|
|
777
|
+
this.recording = false;
|
|
778
|
+
this.startedAtMs = null;
|
|
779
|
+
const videoBlob = await this.screenRecorder.stop();
|
|
780
|
+
const networkLogs = this.networkLogger.stop();
|
|
781
|
+
const stoppedAtMs = Date.now();
|
|
782
|
+
const artifacts = {
|
|
783
|
+
videoBlob,
|
|
784
|
+
screenshotBlob: null,
|
|
785
|
+
networkLogs,
|
|
786
|
+
captureMode: "video",
|
|
787
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
788
|
+
stoppedAt: new Date(stoppedAtMs).toISOString(),
|
|
789
|
+
elapsedMs: Math.max(0, stoppedAtMs - startedAtMs),
|
|
790
|
+
stopReason: reason
|
|
791
|
+
};
|
|
792
|
+
this.lastArtifacts = artifacts;
|
|
793
|
+
return artifacts;
|
|
794
|
+
}
|
|
795
|
+
async handleForcedStop(reason) {
|
|
796
|
+
const artifacts = await this.stop(reason);
|
|
797
|
+
if (artifacts && this.onAutoStop) {
|
|
798
|
+
this.onAutoStop(artifacts);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
clearAutoStopTimer() {
|
|
802
|
+
if (this.autoStopTimeout) {
|
|
803
|
+
clearTimeout(this.autoStopTimeout);
|
|
804
|
+
this.autoStopTimeout = null;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// src/core/BugReporter.ts
|
|
810
|
+
var BugReporter = class {
|
|
811
|
+
constructor(options) {
|
|
812
|
+
this.integration = options.integration;
|
|
813
|
+
this.session = options.session ?? new BugSession({
|
|
814
|
+
maxDurationMs: options.maxDurationMs ?? DEFAULT_MAX_RECORDING_MS,
|
|
815
|
+
onAutoStop: options.onAutoStop
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
async start() {
|
|
819
|
+
await this.session.start();
|
|
820
|
+
}
|
|
821
|
+
async captureScreenshot(region) {
|
|
822
|
+
return this.session.captureScreenshot(region);
|
|
823
|
+
}
|
|
824
|
+
async stop() {
|
|
825
|
+
return this.session.stop("manual");
|
|
826
|
+
}
|
|
827
|
+
async submit(title, description, options = {}) {
|
|
828
|
+
if (this.isRecording()) {
|
|
829
|
+
await this.stop();
|
|
830
|
+
}
|
|
831
|
+
const artifacts = this.session.getLastArtifacts();
|
|
832
|
+
if (!artifacts) {
|
|
833
|
+
throw new Error("Capture a screenshot or record and stop a bug session before submitting.");
|
|
834
|
+
}
|
|
835
|
+
const normalizedTitle = title.trim();
|
|
836
|
+
if (!normalizedTitle) {
|
|
837
|
+
throw new Error("A bug title is required.");
|
|
838
|
+
}
|
|
839
|
+
const normalizedDescription = description.trim() || "No additional details provided.";
|
|
840
|
+
const metadata = {
|
|
841
|
+
...collectClientEnvironmentMetadata(),
|
|
842
|
+
captureMode: artifacts.captureMode,
|
|
843
|
+
capture: {
|
|
844
|
+
startedAt: artifacts.startedAt,
|
|
845
|
+
stoppedAt: artifacts.stoppedAt,
|
|
846
|
+
elapsedMs: artifacts.elapsedMs
|
|
847
|
+
},
|
|
848
|
+
...options.metadata || {}
|
|
849
|
+
};
|
|
850
|
+
const payload = {
|
|
851
|
+
title: normalizedTitle,
|
|
852
|
+
description: normalizedDescription,
|
|
853
|
+
videoBlob: artifacts.videoBlob,
|
|
854
|
+
screenshotBlob: options.screenshotBlob ?? artifacts.screenshotBlob,
|
|
855
|
+
networkLogs: artifacts.networkLogs,
|
|
856
|
+
captureMode: artifacts.captureMode,
|
|
857
|
+
pageUrl: typeof window !== "undefined" ? window.location.href : "",
|
|
858
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
|
859
|
+
startedAt: artifacts.startedAt,
|
|
860
|
+
stoppedAt: artifacts.stoppedAt,
|
|
861
|
+
elapsedMs: artifacts.elapsedMs,
|
|
862
|
+
metadata
|
|
863
|
+
};
|
|
864
|
+
options.onProgress?.("Submitting to " + this.integration.provider + "\u2026");
|
|
865
|
+
const result = await this.integration.submit(payload, options.onProgress);
|
|
866
|
+
this.session.resetArtifacts();
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
isRecording() {
|
|
870
|
+
return this.session.isRecording();
|
|
871
|
+
}
|
|
872
|
+
getElapsedMs() {
|
|
873
|
+
return this.session.getElapsedMs();
|
|
874
|
+
}
|
|
875
|
+
getMaxDurationMs() {
|
|
876
|
+
return this.session.getMaxDurationMs();
|
|
877
|
+
}
|
|
878
|
+
getLastArtifacts() {
|
|
879
|
+
return this.session.getLastArtifacts();
|
|
880
|
+
}
|
|
881
|
+
clearDraft() {
|
|
882
|
+
this.session.resetArtifacts();
|
|
883
|
+
}
|
|
884
|
+
setIntegration(integration) {
|
|
885
|
+
this.integration = integration;
|
|
886
|
+
}
|
|
887
|
+
getSelectedProvider() {
|
|
888
|
+
return this.integration.provider;
|
|
889
|
+
}
|
|
890
|
+
async dispose() {
|
|
891
|
+
await this.session.dispose();
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/integrations/linear.ts
|
|
896
|
+
var DEFAULT_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
|
|
897
|
+
var noop = () => {
|
|
898
|
+
};
|
|
899
|
+
function isRecord(value) {
|
|
900
|
+
return typeof value === "object" && value !== null;
|
|
901
|
+
}
|
|
902
|
+
function parseErrorMessage(body, fallback) {
|
|
903
|
+
if (!isRecord(body)) {
|
|
904
|
+
return fallback;
|
|
905
|
+
}
|
|
906
|
+
if (Array.isArray(body.errors)) {
|
|
907
|
+
const messages = body.errors.map((entry) => {
|
|
908
|
+
if (isRecord(entry) && typeof entry.message === "string") {
|
|
909
|
+
return entry.message;
|
|
910
|
+
}
|
|
911
|
+
return "";
|
|
912
|
+
}).filter(Boolean);
|
|
913
|
+
if (messages.length > 0) {
|
|
914
|
+
return messages.join("; ");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return fallback;
|
|
918
|
+
}
|
|
919
|
+
function ensureString(value, message) {
|
|
920
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
921
|
+
throw new Error(message);
|
|
922
|
+
}
|
|
923
|
+
return value;
|
|
924
|
+
}
|
|
925
|
+
function buildCleanDescription(payload, mediaLinks) {
|
|
926
|
+
const lines = [
|
|
927
|
+
payload.description,
|
|
928
|
+
"",
|
|
929
|
+
"### Context",
|
|
930
|
+
`- Reported At: ${payload.stoppedAt}`,
|
|
931
|
+
`- Capture Mode: ${payload.captureMode === "screenshot" ? "Screenshot" : "Video"}`,
|
|
932
|
+
`- Page URL: ${payload.pageUrl || "Unknown"}`,
|
|
933
|
+
""
|
|
934
|
+
];
|
|
935
|
+
if (mediaLinks.screenshotUrl || mediaLinks.recordingUrl) {
|
|
936
|
+
lines.push("### Media");
|
|
937
|
+
if (mediaLinks.screenshotUrl) {
|
|
938
|
+
lines.push(`- Screenshot: [Open screenshot](${mediaLinks.screenshotUrl})`);
|
|
939
|
+
}
|
|
940
|
+
if (mediaLinks.recordingUrl) {
|
|
941
|
+
lines.push(`- Recording: [Open recording](${mediaLinks.recordingUrl})`);
|
|
942
|
+
}
|
|
943
|
+
lines.push("");
|
|
944
|
+
}
|
|
945
|
+
lines.push(
|
|
946
|
+
"*Network logs and client metadata are attached as comments below.*"
|
|
947
|
+
);
|
|
948
|
+
return lines.join("\n");
|
|
949
|
+
}
|
|
950
|
+
var LinearIntegration = class {
|
|
951
|
+
constructor(options) {
|
|
952
|
+
this.provider = "linear";
|
|
953
|
+
this.apiKey = options.apiKey;
|
|
954
|
+
this.teamId = options.teamId;
|
|
955
|
+
this.projectId = options.projectId;
|
|
956
|
+
this.graphqlEndpoint = options.graphqlEndpoint ?? DEFAULT_GRAPHQL_ENDPOINT;
|
|
957
|
+
this.submitProxyEndpoint = options.submitProxyEndpoint;
|
|
958
|
+
this.createIssueProxyEndpoint = options.createIssueProxyEndpoint;
|
|
959
|
+
this.uploadProxyEndpoint = options.uploadProxyEndpoint;
|
|
960
|
+
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
961
|
+
}
|
|
962
|
+
async submit(payload, onProgress) {
|
|
963
|
+
if (this.submitProxyEndpoint) {
|
|
964
|
+
return this.submitViaProxy(payload, onProgress);
|
|
965
|
+
}
|
|
966
|
+
const progress = onProgress ?? noop;
|
|
967
|
+
let screenshotUrl = null;
|
|
968
|
+
if (payload.screenshotBlob) {
|
|
969
|
+
progress("Uploading screenshot\u2026");
|
|
970
|
+
screenshotUrl = await this.uploadAsset(payload.screenshotBlob, "bug-screenshot.png", "image/png");
|
|
971
|
+
}
|
|
972
|
+
let recordingUrl = null;
|
|
973
|
+
if (payload.videoBlob) {
|
|
974
|
+
progress("Uploading recording\u2026");
|
|
975
|
+
recordingUrl = await this.uploadAsset(payload.videoBlob, "bug-recording.webm", "video/webm");
|
|
976
|
+
}
|
|
977
|
+
progress("Creating Linear issue\u2026");
|
|
978
|
+
const description = buildCleanDescription(payload, { screenshotUrl, recordingUrl });
|
|
979
|
+
const issue = await this.createIssue(payload.title, description);
|
|
980
|
+
progress("Attaching logs\u2026");
|
|
981
|
+
const logsComment = "### Network Logs\n```text\n" + formatNetworkLogs(payload.networkLogs) + "\n```";
|
|
982
|
+
await this.addComment(issue.id, logsComment);
|
|
983
|
+
const metadataComment = "### Client Metadata\n```json\n" + JSON.stringify(payload.metadata, null, 2) + "\n```";
|
|
984
|
+
await this.addComment(issue.id, metadataComment);
|
|
985
|
+
progress("Done!");
|
|
986
|
+
return {
|
|
987
|
+
provider: this.provider,
|
|
988
|
+
issueId: issue.id,
|
|
989
|
+
issueKey: issue.identifier,
|
|
990
|
+
issueUrl: issue.url,
|
|
991
|
+
warnings: []
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
async submitViaProxy(payload, onProgress) {
|
|
995
|
+
if (!this.submitProxyEndpoint) {
|
|
996
|
+
throw new Error("Linear submit proxy endpoint is not configured.");
|
|
997
|
+
}
|
|
998
|
+
const formData = new FormData();
|
|
999
|
+
formData.set("provider", "linear");
|
|
1000
|
+
formData.set("title", payload.title);
|
|
1001
|
+
formData.set("description", payload.description);
|
|
1002
|
+
formData.set("pageUrl", payload.pageUrl);
|
|
1003
|
+
formData.set("userAgent", payload.userAgent);
|
|
1004
|
+
formData.set("reportedAt", payload.stoppedAt);
|
|
1005
|
+
formData.set("captureMode", payload.captureMode);
|
|
1006
|
+
formData.set("clientMetadata", JSON.stringify(payload.metadata));
|
|
1007
|
+
const formattedLogs = formatNetworkLogs(payload.networkLogs);
|
|
1008
|
+
formData.set("networkLogs", formattedLogs);
|
|
1009
|
+
formData.append("requestsLogFile", new Blob([formattedLogs], { type: "text/plain" }), "network-logs.txt");
|
|
1010
|
+
if (payload.videoBlob) {
|
|
1011
|
+
const file = toBlobFile(payload.videoBlob, "bug-recording.webm", "video/webm");
|
|
1012
|
+
formData.append("screenRecordingFile", file, file.name);
|
|
1013
|
+
}
|
|
1014
|
+
if (payload.screenshotBlob) {
|
|
1015
|
+
const file = toBlobFile(payload.screenshotBlob, "bug-screenshot.png", "image/png");
|
|
1016
|
+
formData.append("screenshotFile", file, file.name);
|
|
1017
|
+
}
|
|
1018
|
+
(onProgress ?? noop)("Submitting to Linear\u2026");
|
|
1019
|
+
const response = await this.fetchImpl(this.submitProxyEndpoint, {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
body: formData
|
|
1022
|
+
});
|
|
1023
|
+
const body = await response.json().catch(() => null);
|
|
1024
|
+
if (!response.ok) {
|
|
1025
|
+
const errorMessage = typeof body?.error === "string" ? body.error : body?.error?.message || "Linear proxy submission failed.";
|
|
1026
|
+
throw new Error(errorMessage);
|
|
1027
|
+
}
|
|
1028
|
+
const linear = body?.linear;
|
|
1029
|
+
if (!linear?.id || !linear.identifier) {
|
|
1030
|
+
throw new Error("Linear proxy submission failed: invalid response.");
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
provider: this.provider,
|
|
1034
|
+
issueId: linear.id,
|
|
1035
|
+
issueKey: linear.identifier,
|
|
1036
|
+
issueUrl: linear.url ?? null,
|
|
1037
|
+
warnings: Array.isArray(body?.warnings) ? body.warnings : []
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
async createIssue(title, description) {
|
|
1041
|
+
if (this.createIssueProxyEndpoint) {
|
|
1042
|
+
const response2 = await this.fetchImpl(this.createIssueProxyEndpoint, {
|
|
1043
|
+
method: "POST",
|
|
1044
|
+
headers: {
|
|
1045
|
+
"Content-Type": "application/json"
|
|
1046
|
+
},
|
|
1047
|
+
body: JSON.stringify({
|
|
1048
|
+
title,
|
|
1049
|
+
description,
|
|
1050
|
+
teamId: this.teamId,
|
|
1051
|
+
projectId: this.projectId
|
|
1052
|
+
})
|
|
1053
|
+
});
|
|
1054
|
+
const payload = await response2.json().catch(() => null);
|
|
1055
|
+
if (!response2.ok || !payload?.id || !payload.identifier) {
|
|
1056
|
+
throw new Error(payload?.error || "Linear issue creation proxy failed.");
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
id: payload.id,
|
|
1060
|
+
identifier: payload.identifier,
|
|
1061
|
+
url: payload.url ?? null
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
if (!this.apiKey || !this.teamId) {
|
|
1065
|
+
throw new Error(
|
|
1066
|
+
"Linear integration is missing credentials. Configure apiKey + teamId or a createIssueProxyEndpoint."
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
const query = `
|
|
1070
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
1071
|
+
issueCreate(input: $input) {
|
|
1072
|
+
success
|
|
1073
|
+
issue {
|
|
1074
|
+
id
|
|
1075
|
+
identifier
|
|
1076
|
+
url
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
`;
|
|
1081
|
+
const issueInput = {
|
|
1082
|
+
teamId: this.teamId,
|
|
1083
|
+
title,
|
|
1084
|
+
description
|
|
1085
|
+
};
|
|
1086
|
+
if (this.projectId) {
|
|
1087
|
+
issueInput.projectId = this.projectId;
|
|
1088
|
+
}
|
|
1089
|
+
const response = await this.fetchImpl(this.graphqlEndpoint, {
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
headers: {
|
|
1092
|
+
Authorization: this.apiKey,
|
|
1093
|
+
"Content-Type": "application/json"
|
|
1094
|
+
},
|
|
1095
|
+
body: JSON.stringify({
|
|
1096
|
+
query,
|
|
1097
|
+
variables: {
|
|
1098
|
+
input: issueInput
|
|
1099
|
+
}
|
|
1100
|
+
})
|
|
1101
|
+
});
|
|
1102
|
+
const body = await response.json().catch(() => null);
|
|
1103
|
+
if (!response.ok) {
|
|
1104
|
+
throw new Error(parseErrorMessage(body, `Linear issue creation failed (${response.status})`));
|
|
1105
|
+
}
|
|
1106
|
+
const parsedIssueCreate = isRecord(body) && isRecord(body.data) && isRecord(body.data.issueCreate) ? body.data.issueCreate : null;
|
|
1107
|
+
const parsedIssue = parsedIssueCreate && isRecord(parsedIssueCreate.issue) ? parsedIssueCreate.issue : null;
|
|
1108
|
+
if (!parsedIssueCreate || !parsedIssue || parsedIssueCreate.success !== true) {
|
|
1109
|
+
throw new Error(parseErrorMessage(body, "Linear issue creation failed."));
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
id: ensureString(parsedIssue.id, "Linear did not return issue id."),
|
|
1113
|
+
identifier: ensureString(parsedIssue.identifier, "Linear did not return issue identifier."),
|
|
1114
|
+
url: typeof parsedIssue.url === "string" ? parsedIssue.url : null
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
async addComment(issueId, body) {
|
|
1118
|
+
if (!this.apiKey) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const query = `
|
|
1122
|
+
mutation CommentCreate($input: CommentCreateInput!) {
|
|
1123
|
+
commentCreate(input: $input) {
|
|
1124
|
+
success
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
`;
|
|
1128
|
+
const response = await this.fetchImpl(this.graphqlEndpoint, {
|
|
1129
|
+
method: "POST",
|
|
1130
|
+
headers: {
|
|
1131
|
+
Authorization: this.apiKey,
|
|
1132
|
+
"Content-Type": "application/json"
|
|
1133
|
+
},
|
|
1134
|
+
body: JSON.stringify({
|
|
1135
|
+
query,
|
|
1136
|
+
variables: {
|
|
1137
|
+
input: { issueId, body }
|
|
1138
|
+
}
|
|
1139
|
+
})
|
|
1140
|
+
});
|
|
1141
|
+
if (!response.ok) ;
|
|
1142
|
+
}
|
|
1143
|
+
async uploadAsset(blob, fileName, fallbackMimeType) {
|
|
1144
|
+
const file = toBlobFile(blob, fileName, fallbackMimeType);
|
|
1145
|
+
if (this.uploadProxyEndpoint) {
|
|
1146
|
+
const formData = new FormData();
|
|
1147
|
+
formData.set("file", file, file.name);
|
|
1148
|
+
formData.set("filename", file.name);
|
|
1149
|
+
formData.set("contentType", file.type || fallbackMimeType);
|
|
1150
|
+
const response = await this.fetchImpl(this.uploadProxyEndpoint, {
|
|
1151
|
+
method: "POST",
|
|
1152
|
+
body: formData
|
|
1153
|
+
});
|
|
1154
|
+
const payload = await response.json().catch(() => null);
|
|
1155
|
+
if (!response.ok || !payload?.assetUrl) {
|
|
1156
|
+
throw new Error(payload?.error || "Linear upload proxy failed.");
|
|
1157
|
+
}
|
|
1158
|
+
return payload.assetUrl;
|
|
1159
|
+
}
|
|
1160
|
+
if (!this.apiKey) {
|
|
1161
|
+
throw new Error("Linear upload requires apiKey or uploadProxyEndpoint.");
|
|
1162
|
+
}
|
|
1163
|
+
const uploadTarget = await this.requestUploadTarget(file);
|
|
1164
|
+
const uploadHeaders = uploadTarget.headers.reduce((acc, entry) => {
|
|
1165
|
+
if (entry.key && entry.value) {
|
|
1166
|
+
acc[entry.key] = entry.value;
|
|
1167
|
+
}
|
|
1168
|
+
return acc;
|
|
1169
|
+
}, {});
|
|
1170
|
+
const uploadResponse = await this.fetchImpl(uploadTarget.uploadUrl, {
|
|
1171
|
+
method: "PUT",
|
|
1172
|
+
headers: uploadHeaders,
|
|
1173
|
+
body: file
|
|
1174
|
+
});
|
|
1175
|
+
if (!uploadResponse.ok) {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
"Linear media upload failed. Configure an uploadProxyEndpoint if your browser blocks direct uploads."
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
return uploadTarget.assetUrl;
|
|
1181
|
+
}
|
|
1182
|
+
async requestUploadTarget(file) {
|
|
1183
|
+
const query = `
|
|
1184
|
+
mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) {
|
|
1185
|
+
fileUpload(contentType: $contentType, filename: $filename, size: $size) {
|
|
1186
|
+
success
|
|
1187
|
+
uploadFile {
|
|
1188
|
+
uploadUrl
|
|
1189
|
+
assetUrl
|
|
1190
|
+
headers {
|
|
1191
|
+
key
|
|
1192
|
+
value
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
`;
|
|
1198
|
+
const response = await this.fetchImpl(this.graphqlEndpoint, {
|
|
1199
|
+
method: "POST",
|
|
1200
|
+
headers: {
|
|
1201
|
+
Authorization: ensureString(this.apiKey, "Linear upload requires apiKey."),
|
|
1202
|
+
"Content-Type": "application/json"
|
|
1203
|
+
},
|
|
1204
|
+
body: JSON.stringify({
|
|
1205
|
+
query,
|
|
1206
|
+
variables: {
|
|
1207
|
+
contentType: file.type || "application/octet-stream",
|
|
1208
|
+
filename: file.name,
|
|
1209
|
+
size: file.size
|
|
1210
|
+
}
|
|
1211
|
+
})
|
|
1212
|
+
});
|
|
1213
|
+
const body = await response.json().catch(() => null);
|
|
1214
|
+
if (!response.ok) {
|
|
1215
|
+
throw new Error(parseErrorMessage(body, `Linear upload target request failed (${response.status})`));
|
|
1216
|
+
}
|
|
1217
|
+
const uploadFile = isRecord(body) && isRecord(body.data) && isRecord(body.data.fileUpload) && isRecord(body.data.fileUpload.uploadFile) ? body.data.fileUpload.uploadFile : null;
|
|
1218
|
+
const headers = Array.isArray(uploadFile?.headers) ? uploadFile.headers.filter((entry) => {
|
|
1219
|
+
return isRecord(entry) && typeof entry.key === "string" && typeof entry.value === "string";
|
|
1220
|
+
}) : [];
|
|
1221
|
+
if (!uploadFile || typeof uploadFile.uploadUrl !== "string" || typeof uploadFile.assetUrl !== "string") {
|
|
1222
|
+
throw new Error(parseErrorMessage(body, "Linear did not return a valid upload target."));
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
uploadUrl: uploadFile.uploadUrl,
|
|
1226
|
+
assetUrl: uploadFile.assetUrl,
|
|
1227
|
+
headers
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
// src/integrations/jira.ts
|
|
1233
|
+
var noop2 = () => {
|
|
1234
|
+
};
|
|
1235
|
+
function isRecord2(value) {
|
|
1236
|
+
return typeof value === "object" && value !== null;
|
|
1237
|
+
}
|
|
1238
|
+
function toBasicAuth(email, token) {
|
|
1239
|
+
if (typeof btoa !== "function") {
|
|
1240
|
+
throw new Error("btoa is unavailable; use backend proxy endpoints for Jira auth.");
|
|
1241
|
+
}
|
|
1242
|
+
return `Basic ${btoa(`${email}:${token}`)}`;
|
|
1243
|
+
}
|
|
1244
|
+
function getErrorDetail(payload, fallback) {
|
|
1245
|
+
if (!isRecord2(payload)) {
|
|
1246
|
+
return fallback;
|
|
1247
|
+
}
|
|
1248
|
+
if (Array.isArray(payload.errorMessages) && payload.errorMessages.length > 0) {
|
|
1249
|
+
return payload.errorMessages.map((entry) => String(entry)).join("; ");
|
|
1250
|
+
}
|
|
1251
|
+
if (isRecord2(payload.errors)) {
|
|
1252
|
+
return Object.values(payload.errors).map((entry) => String(entry)).join("; ");
|
|
1253
|
+
}
|
|
1254
|
+
return fallback;
|
|
1255
|
+
}
|
|
1256
|
+
function buildCleanDescription2(payload) {
|
|
1257
|
+
const lines = [
|
|
1258
|
+
payload.description,
|
|
1259
|
+
"",
|
|
1260
|
+
"Context:",
|
|
1261
|
+
`- Reported At: ${payload.stoppedAt}`,
|
|
1262
|
+
`- Capture Mode: ${payload.captureMode === "screenshot" ? "Screenshot" : "Video"}`,
|
|
1263
|
+
`- Page URL: ${payload.pageUrl || "Unknown"}`
|
|
1264
|
+
];
|
|
1265
|
+
const hasScreenshot = Boolean(payload.screenshotBlob);
|
|
1266
|
+
const hasVideo = Boolean(payload.videoBlob);
|
|
1267
|
+
if (hasScreenshot || hasVideo) {
|
|
1268
|
+
lines.push("", "Attachments:");
|
|
1269
|
+
if (hasScreenshot) lines.push("- Screenshot attached");
|
|
1270
|
+
if (hasVideo) lines.push("- Screen recording attached");
|
|
1271
|
+
lines.push("- Network logs attached (network-logs.txt)");
|
|
1272
|
+
lines.push("- Client metadata attached (client-metadata.json)");
|
|
1273
|
+
}
|
|
1274
|
+
return lines.join("\n");
|
|
1275
|
+
}
|
|
1276
|
+
function toJiraAdf(text) {
|
|
1277
|
+
const paragraphs = text.split(/\n{2,}/).map((chunk) => chunk.trim()).filter(Boolean).map((chunk) => ({
|
|
1278
|
+
type: "paragraph",
|
|
1279
|
+
content: [{ type: "text", text: chunk }]
|
|
1280
|
+
}));
|
|
1281
|
+
return {
|
|
1282
|
+
type: "doc",
|
|
1283
|
+
version: 1,
|
|
1284
|
+
content: paragraphs
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
var JiraIntegration = class {
|
|
1288
|
+
constructor(options) {
|
|
1289
|
+
this.provider = "jira";
|
|
1290
|
+
this.baseUrl = options.baseUrl?.replace(/\/$/, "");
|
|
1291
|
+
this.email = options.email;
|
|
1292
|
+
this.apiToken = options.apiToken;
|
|
1293
|
+
this.projectKey = options.projectKey;
|
|
1294
|
+
this.issueType = options.issueType ?? "Bug";
|
|
1295
|
+
this.submitProxyEndpoint = options.submitProxyEndpoint;
|
|
1296
|
+
this.createIssueProxyEndpoint = options.createIssueProxyEndpoint;
|
|
1297
|
+
this.uploadAttachmentProxyEndpoint = options.uploadAttachmentProxyEndpoint;
|
|
1298
|
+
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
1299
|
+
}
|
|
1300
|
+
async submit(payload, onProgress) {
|
|
1301
|
+
if (this.submitProxyEndpoint) {
|
|
1302
|
+
return this.submitViaProxy(payload, onProgress);
|
|
1303
|
+
}
|
|
1304
|
+
const progress = onProgress ?? noop2;
|
|
1305
|
+
progress("Creating Jira issue\u2026");
|
|
1306
|
+
const issue = await this.createIssue(payload);
|
|
1307
|
+
if (payload.screenshotBlob) {
|
|
1308
|
+
progress("Uploading screenshot\u2026");
|
|
1309
|
+
await this.uploadAttachment(issue.key, payload.screenshotBlob, "bug-screenshot.png", "image/png");
|
|
1310
|
+
}
|
|
1311
|
+
if (payload.videoBlob) {
|
|
1312
|
+
progress("Uploading recording\u2026");
|
|
1313
|
+
await this.uploadAttachment(issue.key, payload.videoBlob, "bug-recording.webm", "video/webm");
|
|
1314
|
+
}
|
|
1315
|
+
progress("Attaching logs\u2026");
|
|
1316
|
+
const logsBlob = new Blob([formatNetworkLogs(payload.networkLogs)], { type: "text/plain" });
|
|
1317
|
+
await this.uploadAttachment(issue.key, logsBlob, "network-logs.txt", "text/plain");
|
|
1318
|
+
const metadataBlob = new Blob([JSON.stringify(payload.metadata, null, 2)], { type: "application/json" });
|
|
1319
|
+
await this.uploadAttachment(issue.key, metadataBlob, "client-metadata.json", "application/json");
|
|
1320
|
+
progress("Done!");
|
|
1321
|
+
return {
|
|
1322
|
+
provider: this.provider,
|
|
1323
|
+
issueId: issue.id,
|
|
1324
|
+
issueKey: issue.key,
|
|
1325
|
+
issueUrl: issue.url,
|
|
1326
|
+
warnings: []
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
async submitViaProxy(payload, onProgress) {
|
|
1330
|
+
if (!this.submitProxyEndpoint) {
|
|
1331
|
+
throw new Error("Jira submit proxy endpoint is not configured.");
|
|
1332
|
+
}
|
|
1333
|
+
const formData = new FormData();
|
|
1334
|
+
formData.set("provider", "jira");
|
|
1335
|
+
formData.set("title", payload.title);
|
|
1336
|
+
formData.set("description", payload.description);
|
|
1337
|
+
formData.set("pageUrl", payload.pageUrl);
|
|
1338
|
+
formData.set("userAgent", payload.userAgent);
|
|
1339
|
+
formData.set("reportedAt", payload.stoppedAt);
|
|
1340
|
+
formData.set("captureMode", payload.captureMode);
|
|
1341
|
+
formData.set("clientMetadata", JSON.stringify(payload.metadata));
|
|
1342
|
+
const formattedLogs = formatNetworkLogs(payload.networkLogs);
|
|
1343
|
+
formData.append("requestsLogFile", new Blob([formattedLogs], { type: "text/plain" }), "network-logs.txt");
|
|
1344
|
+
formData.append("clientMetadataFile", new Blob([JSON.stringify(payload.metadata, null, 2)], { type: "application/json" }), "client-metadata.json");
|
|
1345
|
+
if (payload.videoBlob) {
|
|
1346
|
+
const file = toBlobFile(payload.videoBlob, "bug-recording.webm", "video/webm");
|
|
1347
|
+
formData.append("screenRecordingFile", file, file.name);
|
|
1348
|
+
}
|
|
1349
|
+
if (payload.screenshotBlob) {
|
|
1350
|
+
const file = toBlobFile(payload.screenshotBlob, "bug-screenshot.png", "image/png");
|
|
1351
|
+
formData.append("screenshotFile", file, file.name);
|
|
1352
|
+
}
|
|
1353
|
+
(onProgress ?? noop2)("Submitting to Jira\u2026");
|
|
1354
|
+
const response = await this.fetchImpl(this.submitProxyEndpoint, {
|
|
1355
|
+
method: "POST",
|
|
1356
|
+
body: formData
|
|
1357
|
+
});
|
|
1358
|
+
const body = await response.json().catch(() => null);
|
|
1359
|
+
if (!response.ok) {
|
|
1360
|
+
const errorMessage = typeof body?.error === "string" ? body.error : body?.error?.message || "Jira proxy submission failed.";
|
|
1361
|
+
throw new Error(errorMessage);
|
|
1362
|
+
}
|
|
1363
|
+
const jira = body?.jira;
|
|
1364
|
+
if (!jira?.id || !jira.key) {
|
|
1365
|
+
throw new Error("Jira proxy submission failed: invalid response.");
|
|
1366
|
+
}
|
|
1367
|
+
return {
|
|
1368
|
+
provider: this.provider,
|
|
1369
|
+
issueId: jira.id,
|
|
1370
|
+
issueKey: jira.key,
|
|
1371
|
+
issueUrl: jira.url ?? null,
|
|
1372
|
+
warnings: Array.isArray(body?.warnings) ? body.warnings : []
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
async createIssue(payload) {
|
|
1376
|
+
if (this.createIssueProxyEndpoint) {
|
|
1377
|
+
const response2 = await this.fetchImpl(this.createIssueProxyEndpoint, {
|
|
1378
|
+
method: "POST",
|
|
1379
|
+
headers: {
|
|
1380
|
+
"Content-Type": "application/json"
|
|
1381
|
+
},
|
|
1382
|
+
body: JSON.stringify({
|
|
1383
|
+
summary: payload.title,
|
|
1384
|
+
description: buildCleanDescription2(payload),
|
|
1385
|
+
issueType: this.issueType,
|
|
1386
|
+
projectKey: this.projectKey
|
|
1387
|
+
})
|
|
1388
|
+
});
|
|
1389
|
+
const data2 = await response2.json().catch(() => null);
|
|
1390
|
+
if (!response2.ok || !data2?.id || !data2.key || !data2.url) {
|
|
1391
|
+
throw new Error(data2?.error || "Jira issue creation proxy failed.");
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
id: data2.id,
|
|
1395
|
+
key: data2.key,
|
|
1396
|
+
url: data2.url
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
if (!this.baseUrl || !this.email || !this.apiToken || !this.projectKey) {
|
|
1400
|
+
throw new Error(
|
|
1401
|
+
"Jira integration is missing credentials. Configure baseUrl + email + apiToken + projectKey or a createIssueProxyEndpoint."
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
const response = await this.fetchImpl(`${this.baseUrl}/rest/api/3/issue`, {
|
|
1405
|
+
method: "POST",
|
|
1406
|
+
credentials: "omit",
|
|
1407
|
+
headers: {
|
|
1408
|
+
Authorization: toBasicAuth(this.email, this.apiToken),
|
|
1409
|
+
"Content-Type": "application/json",
|
|
1410
|
+
Accept: "application/json"
|
|
1411
|
+
},
|
|
1412
|
+
body: JSON.stringify({
|
|
1413
|
+
fields: {
|
|
1414
|
+
project: { key: this.projectKey },
|
|
1415
|
+
summary: payload.title,
|
|
1416
|
+
description: toJiraAdf(buildCleanDescription2(payload)),
|
|
1417
|
+
issuetype: { name: this.issueType }
|
|
1418
|
+
}
|
|
1419
|
+
})
|
|
1420
|
+
});
|
|
1421
|
+
const data = await response.json().catch(() => null);
|
|
1422
|
+
if (!response.ok) {
|
|
1423
|
+
throw new Error(getErrorDetail(data, `Jira issue creation failed (${response.status}).`));
|
|
1424
|
+
}
|
|
1425
|
+
if (!isRecord2(data) || typeof data.id !== "string" || typeof data.key !== "string") {
|
|
1426
|
+
throw new Error("Jira issue creation failed: invalid API response.");
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
id: data.id,
|
|
1430
|
+
key: data.key,
|
|
1431
|
+
url: `${this.baseUrl}/browse/${data.key}`
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
async uploadAttachment(issueKey, blob, fileName, fallbackMimeType) {
|
|
1435
|
+
const file = toBlobFile(blob, fileName, fallbackMimeType);
|
|
1436
|
+
if (this.uploadAttachmentProxyEndpoint) {
|
|
1437
|
+
const formData2 = new FormData();
|
|
1438
|
+
formData2.set("issueKey", issueKey);
|
|
1439
|
+
formData2.set("file", file, file.name);
|
|
1440
|
+
const response2 = await this.fetchImpl(this.uploadAttachmentProxyEndpoint, {
|
|
1441
|
+
method: "POST",
|
|
1442
|
+
body: formData2
|
|
1443
|
+
});
|
|
1444
|
+
if (!response2.ok) {
|
|
1445
|
+
const data = await response2.json().catch(() => null);
|
|
1446
|
+
throw new Error(data?.error || "Jira attachment upload proxy failed.");
|
|
1447
|
+
}
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (!this.baseUrl || !this.email || !this.apiToken) {
|
|
1451
|
+
throw new Error("Jira attachment upload requires credentials or uploadAttachmentProxyEndpoint.");
|
|
1452
|
+
}
|
|
1453
|
+
const formData = new FormData();
|
|
1454
|
+
formData.set("file", file, file.name);
|
|
1455
|
+
const response = await this.fetchImpl(`${this.baseUrl}/rest/api/3/issue/${issueKey}/attachments`, {
|
|
1456
|
+
method: "POST",
|
|
1457
|
+
credentials: "omit",
|
|
1458
|
+
headers: {
|
|
1459
|
+
Authorization: toBasicAuth(this.email, this.apiToken),
|
|
1460
|
+
Accept: "application/json",
|
|
1461
|
+
"X-Atlassian-Token": "no-check"
|
|
1462
|
+
},
|
|
1463
|
+
body: formData
|
|
1464
|
+
});
|
|
1465
|
+
if (!response.ok) {
|
|
1466
|
+
const data = await response.json().catch(() => null);
|
|
1467
|
+
throw new Error(getErrorDetail(data, `Jira attachment upload failed (${response.status}).`));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
function RegionSelector({ onSelect, onCancel }) {
|
|
1472
|
+
const overlayRef = useRef(null);
|
|
1473
|
+
const [start, setStart] = useState(null);
|
|
1474
|
+
const [current, setCurrent] = useState(null);
|
|
1475
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
1476
|
+
const getRect = useCallback(() => {
|
|
1477
|
+
if (!start || !current) {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
const x = Math.min(start.x, current.x);
|
|
1481
|
+
const y = Math.min(start.y, current.y);
|
|
1482
|
+
const width = Math.abs(current.x - start.x);
|
|
1483
|
+
const height = Math.abs(current.y - start.y);
|
|
1484
|
+
if (width < 8 || height < 8) {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
return { x, y, width, height };
|
|
1488
|
+
}, [start, current]);
|
|
1489
|
+
useEffect(() => {
|
|
1490
|
+
const handleKeyDown = (event) => {
|
|
1491
|
+
if (event.key === "Escape") {
|
|
1492
|
+
onCancel();
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
1496
|
+
return () => {
|
|
1497
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
1498
|
+
};
|
|
1499
|
+
}, [onCancel]);
|
|
1500
|
+
const handlePointerDown = (event) => {
|
|
1501
|
+
event.preventDefault();
|
|
1502
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1503
|
+
const point = { x: event.clientX, y: event.clientY };
|
|
1504
|
+
setStart(point);
|
|
1505
|
+
setCurrent(point);
|
|
1506
|
+
setIsDragging(true);
|
|
1507
|
+
};
|
|
1508
|
+
const handlePointerMove = (event) => {
|
|
1509
|
+
if (!isDragging) {
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
setCurrent({ x: event.clientX, y: event.clientY });
|
|
1513
|
+
};
|
|
1514
|
+
const handlePointerUp = (event) => {
|
|
1515
|
+
if (!isDragging) {
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
1519
|
+
setIsDragging(false);
|
|
1520
|
+
const finalPoint = { x: event.clientX, y: event.clientY };
|
|
1521
|
+
setCurrent(finalPoint);
|
|
1522
|
+
const sX = start?.x ?? 0;
|
|
1523
|
+
const sY = start?.y ?? 0;
|
|
1524
|
+
const x = Math.min(sX, finalPoint.x);
|
|
1525
|
+
const y = Math.min(sY, finalPoint.y);
|
|
1526
|
+
const width = Math.abs(finalPoint.x - sX);
|
|
1527
|
+
const height = Math.abs(finalPoint.y - sY);
|
|
1528
|
+
if (width >= 8 && height >= 8) {
|
|
1529
|
+
onSelect({ x, y, width, height });
|
|
1530
|
+
} else {
|
|
1531
|
+
onCancel();
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
const rect = getRect();
|
|
1535
|
+
return /* @__PURE__ */ jsxs(
|
|
1536
|
+
"div",
|
|
1537
|
+
{
|
|
1538
|
+
ref: overlayRef,
|
|
1539
|
+
className: "fixed inset-0 z-[1200] cursor-crosshair",
|
|
1540
|
+
"data-bug-reporter-ui": "true",
|
|
1541
|
+
onPointerDown: handlePointerDown,
|
|
1542
|
+
onPointerMove: handlePointerMove,
|
|
1543
|
+
onPointerUp: handlePointerUp,
|
|
1544
|
+
children: [
|
|
1545
|
+
/* @__PURE__ */ jsx("div", { className: "absolute inset-0 bg-black/30" }),
|
|
1546
|
+
rect && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1547
|
+
/* @__PURE__ */ jsx(
|
|
1548
|
+
"div",
|
|
1549
|
+
{
|
|
1550
|
+
className: "absolute border-2 border-dashed border-white bg-white/10 shadow-[0_0_0_9999px_rgba(0,0,0,0.3)]",
|
|
1551
|
+
style: {
|
|
1552
|
+
left: rect.x,
|
|
1553
|
+
top: rect.y,
|
|
1554
|
+
width: rect.width,
|
|
1555
|
+
height: rect.height
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
),
|
|
1559
|
+
/* @__PURE__ */ jsxs(
|
|
1560
|
+
"div",
|
|
1561
|
+
{
|
|
1562
|
+
className: "absolute rounded bg-black/70 px-2 py-0.5 text-xs font-medium text-white",
|
|
1563
|
+
style: {
|
|
1564
|
+
left: rect.x,
|
|
1565
|
+
top: rect.y + rect.height + 6
|
|
1566
|
+
},
|
|
1567
|
+
children: [
|
|
1568
|
+
Math.round(rect.width),
|
|
1569
|
+
" \xD7 ",
|
|
1570
|
+
Math.round(rect.height)
|
|
1571
|
+
]
|
|
1572
|
+
}
|
|
1573
|
+
)
|
|
1574
|
+
] }),
|
|
1575
|
+
!isDragging && !rect && /* @__PURE__ */ jsxs("div", { className: "absolute left-1/2 top-8 -translate-x-1/2 rounded-xl bg-black/70 px-5 py-3 text-sm font-medium text-white shadow-lg", children: [
|
|
1576
|
+
"Drag to select an area \xB7 Press ",
|
|
1577
|
+
/* @__PURE__ */ jsx("kbd", { className: "rounded border border-white/30 bg-white/10 px-1.5 py-0.5 font-mono text-xs", children: "Esc" }),
|
|
1578
|
+
" to cancel"
|
|
1579
|
+
] })
|
|
1580
|
+
]
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
var BugReporterContext = createContext(null);
|
|
1585
|
+
function getProviderLabel(provider) {
|
|
1586
|
+
return provider === "linear" ? "Linear" : "Jira";
|
|
1587
|
+
}
|
|
1588
|
+
function BugReporterProvider({
|
|
1589
|
+
children,
|
|
1590
|
+
integrations,
|
|
1591
|
+
defaultProvider,
|
|
1592
|
+
maxDurationMs = DEFAULT_MAX_RECORDING_MS
|
|
1593
|
+
}) {
|
|
1594
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
1595
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
1596
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
1597
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
1598
|
+
const [submissionProgress, setSubmissionProgress] = useState(null);
|
|
1599
|
+
const [isCapturingScreenshot, setIsCapturingScreenshot] = useState(false);
|
|
1600
|
+
const [isSelectingRegion, setIsSelectingRegion] = useState(false);
|
|
1601
|
+
const [error, setError] = useState(null);
|
|
1602
|
+
const [success, setSuccess] = useState(null);
|
|
1603
|
+
const [autoStopNotice, setAutoStopNotice] = useState(null);
|
|
1604
|
+
const [selectedProvider, setSelectedProvider] = useState(null);
|
|
1605
|
+
const [draftMode, setDraftMode] = useState(null);
|
|
1606
|
+
const [videoBlob, setVideoBlob] = useState(null);
|
|
1607
|
+
const [videoPreviewUrl, setVideoPreviewUrl] = useState(null);
|
|
1608
|
+
const [screenshotBlob, setScreenshotBlob] = useState(null);
|
|
1609
|
+
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState(null);
|
|
1610
|
+
const [screenshotAnnotation, setScreenshotAnnotation] = useState({
|
|
1611
|
+
annotatedBlob: null,
|
|
1612
|
+
highlights: [],
|
|
1613
|
+
imageWidth: 0,
|
|
1614
|
+
imageHeight: 0
|
|
1615
|
+
});
|
|
1616
|
+
const reporterRef = useRef(null);
|
|
1617
|
+
const availableProviders = useMemo(() => {
|
|
1618
|
+
return ["linear", "jira"].filter((provider) => Boolean(integrations[provider]));
|
|
1619
|
+
}, [integrations]);
|
|
1620
|
+
const hasDraft = useMemo(() => {
|
|
1621
|
+
if (draftMode === "video") {
|
|
1622
|
+
return Boolean(videoBlob);
|
|
1623
|
+
}
|
|
1624
|
+
if (draftMode === "screenshot") {
|
|
1625
|
+
return Boolean(screenshotBlob);
|
|
1626
|
+
}
|
|
1627
|
+
return false;
|
|
1628
|
+
}, [draftMode, screenshotBlob, videoBlob]);
|
|
1629
|
+
useEffect(() => {
|
|
1630
|
+
if (!videoBlob) {
|
|
1631
|
+
setVideoPreviewUrl((current) => {
|
|
1632
|
+
if (current) {
|
|
1633
|
+
URL.revokeObjectURL(current);
|
|
1634
|
+
}
|
|
1635
|
+
return null;
|
|
1636
|
+
});
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const nextUrl = URL.createObjectURL(videoBlob);
|
|
1640
|
+
setVideoPreviewUrl((current) => {
|
|
1641
|
+
if (current) {
|
|
1642
|
+
URL.revokeObjectURL(current);
|
|
1643
|
+
}
|
|
1644
|
+
return nextUrl;
|
|
1645
|
+
});
|
|
1646
|
+
return () => {
|
|
1647
|
+
URL.revokeObjectURL(nextUrl);
|
|
1648
|
+
};
|
|
1649
|
+
}, [videoBlob]);
|
|
1650
|
+
useEffect(() => {
|
|
1651
|
+
if (availableProviders.length === 0) {
|
|
1652
|
+
setSelectedProvider(null);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (defaultProvider && availableProviders.includes(defaultProvider)) {
|
|
1656
|
+
setSelectedProvider((current) => current ?? defaultProvider);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
setSelectedProvider((current) => {
|
|
1660
|
+
if (current && availableProviders.includes(current)) {
|
|
1661
|
+
return current;
|
|
1662
|
+
}
|
|
1663
|
+
return availableProviders[0];
|
|
1664
|
+
});
|
|
1665
|
+
}, [availableProviders, defaultProvider]);
|
|
1666
|
+
useEffect(() => {
|
|
1667
|
+
if (!screenshotBlob) {
|
|
1668
|
+
setScreenshotPreviewUrl((current) => {
|
|
1669
|
+
if (current) {
|
|
1670
|
+
URL.revokeObjectURL(current);
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
});
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const nextUrl = URL.createObjectURL(screenshotBlob);
|
|
1677
|
+
setScreenshotPreviewUrl((current) => {
|
|
1678
|
+
if (current) {
|
|
1679
|
+
URL.revokeObjectURL(current);
|
|
1680
|
+
}
|
|
1681
|
+
return nextUrl;
|
|
1682
|
+
});
|
|
1683
|
+
return () => {
|
|
1684
|
+
URL.revokeObjectURL(nextUrl);
|
|
1685
|
+
};
|
|
1686
|
+
}, [screenshotBlob]);
|
|
1687
|
+
const handleAutoStop = useCallback(
|
|
1688
|
+
(artifacts) => {
|
|
1689
|
+
setIsRecording(false);
|
|
1690
|
+
setElapsedMs(artifacts.elapsedMs);
|
|
1691
|
+
setDraftMode("video");
|
|
1692
|
+
setVideoBlob(artifacts.videoBlob ?? null);
|
|
1693
|
+
if (artifacts.stopReason === "time_limit") {
|
|
1694
|
+
const durationSeconds = Math.round(maxDurationMs / 1e3);
|
|
1695
|
+
setAutoStopNotice(`Recording reached the ${durationSeconds}-second limit and stopped automatically.`);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (artifacts.stopReason === "screen_ended") {
|
|
1699
|
+
setAutoStopNotice("Screen sharing ended and recording was stopped.");
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
[maxDurationMs]
|
|
1703
|
+
);
|
|
1704
|
+
useEffect(() => {
|
|
1705
|
+
if (!selectedProvider) {
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
const integration = integrations[selectedProvider];
|
|
1709
|
+
if (!integration) {
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (!reporterRef.current) {
|
|
1713
|
+
reporterRef.current = new BugReporter({
|
|
1714
|
+
integration,
|
|
1715
|
+
maxDurationMs,
|
|
1716
|
+
onAutoStop: handleAutoStop
|
|
1717
|
+
});
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
reporterRef.current.setIntegration(integration);
|
|
1721
|
+
}, [handleAutoStop, integrations, maxDurationMs, selectedProvider]);
|
|
1722
|
+
useEffect(() => {
|
|
1723
|
+
if (!isRecording) {
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const interval = window.setInterval(() => {
|
|
1727
|
+
const reporter = reporterRef.current;
|
|
1728
|
+
if (!reporter) {
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
setElapsedMs(reporter.getElapsedMs());
|
|
1732
|
+
if (!reporter.isRecording()) {
|
|
1733
|
+
setIsRecording(false);
|
|
1734
|
+
}
|
|
1735
|
+
}, 250);
|
|
1736
|
+
return () => {
|
|
1737
|
+
window.clearInterval(interval);
|
|
1738
|
+
};
|
|
1739
|
+
}, [isRecording]);
|
|
1740
|
+
useEffect(() => {
|
|
1741
|
+
return () => {
|
|
1742
|
+
setScreenshotPreviewUrl((current) => {
|
|
1743
|
+
if (current) {
|
|
1744
|
+
URL.revokeObjectURL(current);
|
|
1745
|
+
}
|
|
1746
|
+
return null;
|
|
1747
|
+
});
|
|
1748
|
+
setVideoPreviewUrl((current) => {
|
|
1749
|
+
if (current) {
|
|
1750
|
+
URL.revokeObjectURL(current);
|
|
1751
|
+
}
|
|
1752
|
+
return null;
|
|
1753
|
+
});
|
|
1754
|
+
if (reporterRef.current) {
|
|
1755
|
+
void reporterRef.current.dispose();
|
|
1756
|
+
reporterRef.current = null;
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
}, []);
|
|
1760
|
+
const resetMessages = useCallback(() => {
|
|
1761
|
+
setError(null);
|
|
1762
|
+
setSuccess(null);
|
|
1763
|
+
setAutoStopNotice(null);
|
|
1764
|
+
}, []);
|
|
1765
|
+
const clearDraft = useCallback(() => {
|
|
1766
|
+
reporterRef.current?.clearDraft();
|
|
1767
|
+
setDraftMode(null);
|
|
1768
|
+
setVideoBlob(null);
|
|
1769
|
+
setScreenshotBlob(null);
|
|
1770
|
+
setScreenshotAnnotation({
|
|
1771
|
+
annotatedBlob: null,
|
|
1772
|
+
highlights: [],
|
|
1773
|
+
imageWidth: 0,
|
|
1774
|
+
imageHeight: 0
|
|
1775
|
+
});
|
|
1776
|
+
setElapsedMs(0);
|
|
1777
|
+
}, []);
|
|
1778
|
+
const getOrCreateReporter = useCallback(() => {
|
|
1779
|
+
if (reporterRef.current) {
|
|
1780
|
+
return reporterRef.current;
|
|
1781
|
+
}
|
|
1782
|
+
const fallbackProvider = selectedProvider ?? availableProviders[0];
|
|
1783
|
+
if (!fallbackProvider) {
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
const integration = integrations[fallbackProvider];
|
|
1787
|
+
if (!integration) {
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
reporterRef.current = new BugReporter({
|
|
1791
|
+
integration,
|
|
1792
|
+
maxDurationMs,
|
|
1793
|
+
onAutoStop: handleAutoStop
|
|
1794
|
+
});
|
|
1795
|
+
if (!selectedProvider) {
|
|
1796
|
+
setSelectedProvider(fallbackProvider);
|
|
1797
|
+
}
|
|
1798
|
+
return reporterRef.current;
|
|
1799
|
+
}, [availableProviders, handleAutoStop, integrations, maxDurationMs, selectedProvider]);
|
|
1800
|
+
const openModal = useCallback(() => {
|
|
1801
|
+
setIsOpen(true);
|
|
1802
|
+
}, []);
|
|
1803
|
+
const closeModal = useCallback(() => {
|
|
1804
|
+
setIsOpen(false);
|
|
1805
|
+
}, []);
|
|
1806
|
+
const startRecording = useCallback(async () => {
|
|
1807
|
+
const reporter = getOrCreateReporter();
|
|
1808
|
+
if (!reporter) {
|
|
1809
|
+
setError("No bug tracker integration is configured.");
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
resetMessages();
|
|
1813
|
+
clearDraft();
|
|
1814
|
+
try {
|
|
1815
|
+
await reporter.start();
|
|
1816
|
+
setElapsedMs(0);
|
|
1817
|
+
setIsRecording(true);
|
|
1818
|
+
setDraftMode("video");
|
|
1819
|
+
return true;
|
|
1820
|
+
} catch (error2) {
|
|
1821
|
+
setIsRecording(false);
|
|
1822
|
+
setError(toErrorMessage(error2));
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
}, [clearDraft, getOrCreateReporter, resetMessages]);
|
|
1826
|
+
const stopRecording = useCallback(async () => {
|
|
1827
|
+
const reporter = reporterRef.current;
|
|
1828
|
+
if (!reporter) {
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
try {
|
|
1832
|
+
const artifacts = await reporter.stop();
|
|
1833
|
+
setElapsedMs(artifacts?.elapsedMs ?? reporter.getElapsedMs());
|
|
1834
|
+
setIsRecording(false);
|
|
1835
|
+
if (artifacts?.videoBlob) {
|
|
1836
|
+
setDraftMode("video");
|
|
1837
|
+
setVideoBlob(artifacts.videoBlob);
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
return false;
|
|
1841
|
+
} catch (error2) {
|
|
1842
|
+
setError(toErrorMessage(error2));
|
|
1843
|
+
return false;
|
|
1844
|
+
}
|
|
1845
|
+
}, []);
|
|
1846
|
+
const startRegionSelection = useCallback(() => {
|
|
1847
|
+
if (isRecording) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
resetMessages();
|
|
1851
|
+
setIsSelectingRegion(true);
|
|
1852
|
+
}, [isRecording, resetMessages]);
|
|
1853
|
+
const cancelRegionSelection = useCallback(() => {
|
|
1854
|
+
setIsSelectingRegion(false);
|
|
1855
|
+
}, []);
|
|
1856
|
+
const handleRegionSelected = useCallback(
|
|
1857
|
+
async (region) => {
|
|
1858
|
+
setIsSelectingRegion(false);
|
|
1859
|
+
const reporter = getOrCreateReporter();
|
|
1860
|
+
if (!reporter) {
|
|
1861
|
+
setError("No bug tracker integration is configured.");
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
resetMessages();
|
|
1865
|
+
clearDraft();
|
|
1866
|
+
setIsCapturingScreenshot(true);
|
|
1867
|
+
try {
|
|
1868
|
+
const artifacts = await reporter.captureScreenshot(region);
|
|
1869
|
+
if (!artifacts.screenshotBlob) {
|
|
1870
|
+
throw new Error("Region screenshot returned no image.");
|
|
1871
|
+
}
|
|
1872
|
+
setDraftMode("screenshot");
|
|
1873
|
+
setScreenshotBlob(artifacts.screenshotBlob);
|
|
1874
|
+
setScreenshotAnnotation({
|
|
1875
|
+
annotatedBlob: null,
|
|
1876
|
+
highlights: [],
|
|
1877
|
+
imageWidth: 0,
|
|
1878
|
+
imageHeight: 0
|
|
1879
|
+
});
|
|
1880
|
+
setElapsedMs(artifacts.elapsedMs);
|
|
1881
|
+
setIsRecording(false);
|
|
1882
|
+
setAutoStopNotice(null);
|
|
1883
|
+
setIsOpen(true);
|
|
1884
|
+
} catch (error2) {
|
|
1885
|
+
setError(toErrorMessage(error2));
|
|
1886
|
+
} finally {
|
|
1887
|
+
setIsCapturingScreenshot(false);
|
|
1888
|
+
}
|
|
1889
|
+
},
|
|
1890
|
+
[clearDraft, getOrCreateReporter, resetMessages]
|
|
1891
|
+
);
|
|
1892
|
+
const captureQuickScreenshot = useCallback(async () => {
|
|
1893
|
+
const reporter = getOrCreateReporter();
|
|
1894
|
+
if (!reporter) {
|
|
1895
|
+
setError("No bug tracker integration is configured.");
|
|
1896
|
+
return false;
|
|
1897
|
+
}
|
|
1898
|
+
if (isRecording) {
|
|
1899
|
+
const stopped = await stopRecording();
|
|
1900
|
+
if (!stopped) {
|
|
1901
|
+
return false;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
resetMessages();
|
|
1905
|
+
clearDraft();
|
|
1906
|
+
setIsCapturingScreenshot(true);
|
|
1907
|
+
try {
|
|
1908
|
+
const artifacts = await reporter.captureScreenshot();
|
|
1909
|
+
if (!artifacts.screenshotBlob) {
|
|
1910
|
+
throw new Error("Quick screenshot returned no image.");
|
|
1911
|
+
}
|
|
1912
|
+
setDraftMode("screenshot");
|
|
1913
|
+
setScreenshotBlob(artifacts.screenshotBlob);
|
|
1914
|
+
setScreenshotAnnotation({
|
|
1915
|
+
annotatedBlob: null,
|
|
1916
|
+
highlights: [],
|
|
1917
|
+
imageWidth: 0,
|
|
1918
|
+
imageHeight: 0
|
|
1919
|
+
});
|
|
1920
|
+
setElapsedMs(artifacts.elapsedMs);
|
|
1921
|
+
setIsRecording(false);
|
|
1922
|
+
setAutoStopNotice(null);
|
|
1923
|
+
return true;
|
|
1924
|
+
} catch (error2) {
|
|
1925
|
+
setError(toErrorMessage(error2));
|
|
1926
|
+
return false;
|
|
1927
|
+
} finally {
|
|
1928
|
+
setIsCapturingScreenshot(false);
|
|
1929
|
+
}
|
|
1930
|
+
}, [clearDraft, getOrCreateReporter, isRecording, resetMessages, stopRecording]);
|
|
1931
|
+
const updateScreenshotAnnotation = useCallback((annotation) => {
|
|
1932
|
+
setScreenshotAnnotation(annotation);
|
|
1933
|
+
}, []);
|
|
1934
|
+
const submitReport = useCallback(
|
|
1935
|
+
async (title, description) => {
|
|
1936
|
+
const reporter = getOrCreateReporter();
|
|
1937
|
+
if (!reporter) {
|
|
1938
|
+
setError("No bug tracker integration is configured.");
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
if (!selectedProvider || !integrations[selectedProvider]) {
|
|
1942
|
+
setError("Select a bug tracker provider before submitting.");
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
const artifacts = reporter.getLastArtifacts();
|
|
1946
|
+
if (!artifacts || !draftMode || artifacts.captureMode !== draftMode) {
|
|
1947
|
+
setError("Capture evidence first, then tag and submit.");
|
|
1948
|
+
return null;
|
|
1949
|
+
}
|
|
1950
|
+
reporter.setIntegration(integrations[selectedProvider]);
|
|
1951
|
+
setIsSubmitting(true);
|
|
1952
|
+
setSubmissionProgress("Preparing submission\u2026");
|
|
1953
|
+
setError(null);
|
|
1954
|
+
setSuccess(null);
|
|
1955
|
+
const screenshotBlobForSubmit = draftMode === "screenshot" ? screenshotAnnotation.annotatedBlob ?? screenshotBlob : null;
|
|
1956
|
+
const metadata = {
|
|
1957
|
+
annotation: draftMode === "screenshot" && screenshotAnnotation.highlights.length > 0 ? {
|
|
1958
|
+
imageWidth: screenshotAnnotation.imageWidth,
|
|
1959
|
+
imageHeight: screenshotAnnotation.imageHeight,
|
|
1960
|
+
highlights: screenshotAnnotation.highlights
|
|
1961
|
+
} : void 0
|
|
1962
|
+
};
|
|
1963
|
+
try {
|
|
1964
|
+
const result = await reporter.submit(title, description, {
|
|
1965
|
+
screenshotBlob: screenshotBlobForSubmit,
|
|
1966
|
+
metadata,
|
|
1967
|
+
onProgress: setSubmissionProgress
|
|
1968
|
+
});
|
|
1969
|
+
setSuccess(`Submitted to ${getProviderLabel(result.provider)} (${result.issueKey}).`);
|
|
1970
|
+
clearDraft();
|
|
1971
|
+
setIsOpen(false);
|
|
1972
|
+
return result;
|
|
1973
|
+
} catch (error2) {
|
|
1974
|
+
setError(toErrorMessage(error2));
|
|
1975
|
+
return null;
|
|
1976
|
+
} finally {
|
|
1977
|
+
setIsSubmitting(false);
|
|
1978
|
+
setSubmissionProgress(null);
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
[
|
|
1982
|
+
clearDraft,
|
|
1983
|
+
draftMode,
|
|
1984
|
+
getOrCreateReporter,
|
|
1985
|
+
integrations,
|
|
1986
|
+
screenshotAnnotation,
|
|
1987
|
+
screenshotBlob,
|
|
1988
|
+
selectedProvider
|
|
1989
|
+
]
|
|
1990
|
+
);
|
|
1991
|
+
const value = useMemo(
|
|
1992
|
+
() => ({
|
|
1993
|
+
isOpen,
|
|
1994
|
+
openModal,
|
|
1995
|
+
closeModal,
|
|
1996
|
+
draftMode,
|
|
1997
|
+
hasDraft,
|
|
1998
|
+
isRecording,
|
|
1999
|
+
elapsedMs,
|
|
2000
|
+
maxDurationMs,
|
|
2001
|
+
isSubmitting,
|
|
2002
|
+
submissionProgress,
|
|
2003
|
+
isCapturingScreenshot,
|
|
2004
|
+
isSelectingRegion,
|
|
2005
|
+
error,
|
|
2006
|
+
success,
|
|
2007
|
+
autoStopNotice,
|
|
2008
|
+
availableProviders,
|
|
2009
|
+
selectedProvider,
|
|
2010
|
+
setSelectedProvider,
|
|
2011
|
+
startRecording,
|
|
2012
|
+
stopRecording,
|
|
2013
|
+
captureQuickScreenshot,
|
|
2014
|
+
startRegionSelection,
|
|
2015
|
+
videoPreviewUrl,
|
|
2016
|
+
screenshotPreviewUrl,
|
|
2017
|
+
screenshotHighlightCount: screenshotAnnotation.highlights.length,
|
|
2018
|
+
updateScreenshotAnnotation,
|
|
2019
|
+
clearDraft,
|
|
2020
|
+
submitReport,
|
|
2021
|
+
resetMessages
|
|
2022
|
+
}),
|
|
2023
|
+
[
|
|
2024
|
+
autoStopNotice,
|
|
2025
|
+
availableProviders,
|
|
2026
|
+
captureQuickScreenshot,
|
|
2027
|
+
clearDraft,
|
|
2028
|
+
closeModal,
|
|
2029
|
+
draftMode,
|
|
2030
|
+
elapsedMs,
|
|
2031
|
+
error,
|
|
2032
|
+
hasDraft,
|
|
2033
|
+
isCapturingScreenshot,
|
|
2034
|
+
isOpen,
|
|
2035
|
+
isRecording,
|
|
2036
|
+
isSelectingRegion,
|
|
2037
|
+
isSubmitting,
|
|
2038
|
+
submissionProgress,
|
|
2039
|
+
maxDurationMs,
|
|
2040
|
+
openModal,
|
|
2041
|
+
resetMessages,
|
|
2042
|
+
screenshotAnnotation.highlights.length,
|
|
2043
|
+
startRegionSelection,
|
|
2044
|
+
videoPreviewUrl,
|
|
2045
|
+
screenshotPreviewUrl,
|
|
2046
|
+
selectedProvider,
|
|
2047
|
+
startRecording,
|
|
2048
|
+
stopRecording,
|
|
2049
|
+
submitReport,
|
|
2050
|
+
success,
|
|
2051
|
+
updateScreenshotAnnotation
|
|
2052
|
+
]
|
|
2053
|
+
);
|
|
2054
|
+
return /* @__PURE__ */ jsxs(BugReporterContext.Provider, { value, children: [
|
|
2055
|
+
children,
|
|
2056
|
+
isSelectingRegion && /* @__PURE__ */ jsx(
|
|
2057
|
+
RegionSelector,
|
|
2058
|
+
{
|
|
2059
|
+
onSelect: (region) => void handleRegionSelected(region),
|
|
2060
|
+
onCancel: cancelRegionSelection
|
|
2061
|
+
}
|
|
2062
|
+
)
|
|
2063
|
+
] });
|
|
2064
|
+
}
|
|
2065
|
+
function useBugReporter() {
|
|
2066
|
+
const context = useContext(BugReporterContext);
|
|
2067
|
+
if (!context) {
|
|
2068
|
+
throw new Error("useBugReporter must be used within BugReporterProvider.");
|
|
2069
|
+
}
|
|
2070
|
+
return context;
|
|
2071
|
+
}
|
|
2072
|
+
function cn(...inputs) {
|
|
2073
|
+
return twMerge(clsx(inputs));
|
|
2074
|
+
}
|
|
2075
|
+
var buttonVariants = cva(
|
|
2076
|
+
"focus-visible:border-indigo-500 focus-visible:ring-indigo-500/50 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 dark:aria-invalid:border-red-500/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
|
2077
|
+
{
|
|
2078
|
+
variants: {
|
|
2079
|
+
variant: {
|
|
2080
|
+
default: "bg-gray-900 text-white hover:bg-gray-800",
|
|
2081
|
+
outline: "border-gray-300 bg-gray-100/30 hover:bg-gray-200/50 hover:text-gray-900 aria-expanded:bg-gray-100 aria-expanded:text-gray-900",
|
|
2082
|
+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 aria-expanded:bg-gray-100 aria-expanded:text-gray-900",
|
|
2083
|
+
ghost: "hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800 aria-expanded:bg-gray-100 aria-expanded:text-gray-900",
|
|
2084
|
+
destructive: "bg-red-500/10 hover:bg-red-500/20 focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/20 text-red-600 focus-visible:border-red-500/40 dark:hover:bg-red-500/30",
|
|
2085
|
+
link: "text-gray-900 underline-offset-4 hover:underline"
|
|
2086
|
+
},
|
|
2087
|
+
size: {
|
|
2088
|
+
default: "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
|
2089
|
+
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
|
2090
|
+
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
2091
|
+
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
2092
|
+
icon: "size-9",
|
|
2093
|
+
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
|
2094
|
+
"icon-sm": "size-8",
|
|
2095
|
+
"icon-lg": "size-10"
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
defaultVariants: {
|
|
2099
|
+
variant: "default",
|
|
2100
|
+
size: "default"
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
);
|
|
2104
|
+
function Button({
|
|
2105
|
+
className,
|
|
2106
|
+
variant = "default",
|
|
2107
|
+
size = "default",
|
|
2108
|
+
asChild = false,
|
|
2109
|
+
...props
|
|
2110
|
+
}) {
|
|
2111
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
2112
|
+
return /* @__PURE__ */ jsx(
|
|
2113
|
+
Comp,
|
|
2114
|
+
{
|
|
2115
|
+
"data-slot": "button",
|
|
2116
|
+
"data-variant": variant,
|
|
2117
|
+
"data-size": size,
|
|
2118
|
+
className: cn(buttonVariants({ variant, size, className })),
|
|
2119
|
+
...props
|
|
2120
|
+
}
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
function formatElapsed(ms) {
|
|
2124
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
|
|
2125
|
+
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
|
|
2126
|
+
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
|
2127
|
+
return `${minutes}:${seconds}`;
|
|
2128
|
+
}
|
|
2129
|
+
function FloatingBugButton() {
|
|
2130
|
+
const {
|
|
2131
|
+
availableProviders,
|
|
2132
|
+
captureQuickScreenshot,
|
|
2133
|
+
draftMode,
|
|
2134
|
+
elapsedMs,
|
|
2135
|
+
hasDraft,
|
|
2136
|
+
isCapturingScreenshot,
|
|
2137
|
+
isRecording,
|
|
2138
|
+
isSelectingRegion,
|
|
2139
|
+
maxDurationMs,
|
|
2140
|
+
openModal,
|
|
2141
|
+
startRecording,
|
|
2142
|
+
startRegionSelection,
|
|
2143
|
+
stopRecording
|
|
2144
|
+
} = useBugReporter();
|
|
2145
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
2146
|
+
const containerRef = useRef(null);
|
|
2147
|
+
const elapsed = useMemo(() => formatElapsed(elapsedMs), [elapsedMs]);
|
|
2148
|
+
const maxElapsed = useMemo(() => formatElapsed(maxDurationMs), [maxDurationMs]);
|
|
2149
|
+
useEffect(() => {
|
|
2150
|
+
if (!menuOpen) {
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const onPointerDown = (event) => {
|
|
2154
|
+
const target = event.target;
|
|
2155
|
+
if (!(target instanceof Node)) {
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (!containerRef.current?.contains(target)) {
|
|
2159
|
+
setMenuOpen(false);
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
window.addEventListener("pointerdown", onPointerDown);
|
|
2163
|
+
return () => {
|
|
2164
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
2165
|
+
};
|
|
2166
|
+
}, [menuOpen]);
|
|
2167
|
+
if (availableProviders.length === 0) {
|
|
2168
|
+
return null;
|
|
2169
|
+
}
|
|
2170
|
+
const handleQuickScreenshot = async () => {
|
|
2171
|
+
setMenuOpen(false);
|
|
2172
|
+
await captureQuickScreenshot();
|
|
2173
|
+
openModal();
|
|
2174
|
+
};
|
|
2175
|
+
const handleStartRecording = async () => {
|
|
2176
|
+
setMenuOpen(false);
|
|
2177
|
+
await startRecording();
|
|
2178
|
+
};
|
|
2179
|
+
const handleStopRecording = async () => {
|
|
2180
|
+
const ok = await stopRecording();
|
|
2181
|
+
if (ok) {
|
|
2182
|
+
openModal();
|
|
2183
|
+
}
|
|
2184
|
+
};
|
|
2185
|
+
return /* @__PURE__ */ jsx(
|
|
2186
|
+
"div",
|
|
2187
|
+
{
|
|
2188
|
+
ref: containerRef,
|
|
2189
|
+
className: "fixed bottom-4 right-4 z-[1100] flex flex-col items-end",
|
|
2190
|
+
"data-bug-reporter-ui": "true",
|
|
2191
|
+
children: isRecording ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
2192
|
+
/* @__PURE__ */ jsxs(Button, { className: "h-11 gap-2 rounded-full px-4 shadow-lg shadow-black/20", type: "button", variant: "destructive", onClick: () => void handleStopRecording(), children: [
|
|
2193
|
+
/* @__PURE__ */ jsx(Square, { className: "size-4" }),
|
|
2194
|
+
"Stop recording"
|
|
2195
|
+
] }),
|
|
2196
|
+
/* @__PURE__ */ jsxs("p", { className: "rounded-full border border-red-300 bg-red-50 px-3 py-1 text-xs text-red-700", children: [
|
|
2197
|
+
"Recording ",
|
|
2198
|
+
elapsed,
|
|
2199
|
+
" / ",
|
|
2200
|
+
maxElapsed
|
|
2201
|
+
] })
|
|
2202
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2203
|
+
menuOpen ? /* @__PURE__ */ jsxs("div", { className: "mb-2 w-72 rounded-2xl border border-gray-200 bg-white p-2 shadow-xl", "data-bug-reporter-ui": "true", children: [
|
|
2204
|
+
/* @__PURE__ */ jsxs(
|
|
2205
|
+
Button,
|
|
2206
|
+
{
|
|
2207
|
+
className: "h-10 w-full justify-start gap-2 rounded-xl",
|
|
2208
|
+
disabled: isCapturingScreenshot || isSelectingRegion,
|
|
2209
|
+
type: "button",
|
|
2210
|
+
variant: "ghost",
|
|
2211
|
+
onClick: () => void handleQuickScreenshot(),
|
|
2212
|
+
children: [
|
|
2213
|
+
/* @__PURE__ */ jsx(Camera, { className: "size-4" }),
|
|
2214
|
+
"Full page screenshot"
|
|
2215
|
+
]
|
|
2216
|
+
}
|
|
2217
|
+
),
|
|
2218
|
+
/* @__PURE__ */ jsxs(
|
|
2219
|
+
Button,
|
|
2220
|
+
{
|
|
2221
|
+
className: "h-10 w-full justify-start gap-2 rounded-xl",
|
|
2222
|
+
disabled: isCapturingScreenshot || isSelectingRegion,
|
|
2223
|
+
type: "button",
|
|
2224
|
+
variant: "ghost",
|
|
2225
|
+
onClick: () => {
|
|
2226
|
+
setMenuOpen(false);
|
|
2227
|
+
startRegionSelection();
|
|
2228
|
+
},
|
|
2229
|
+
children: [
|
|
2230
|
+
/* @__PURE__ */ jsx(Crop, { className: "size-4" }),
|
|
2231
|
+
"Select area"
|
|
2232
|
+
]
|
|
2233
|
+
}
|
|
2234
|
+
),
|
|
2235
|
+
/* @__PURE__ */ jsxs(
|
|
2236
|
+
Button,
|
|
2237
|
+
{
|
|
2238
|
+
className: "h-10 w-full justify-start gap-2 rounded-xl",
|
|
2239
|
+
disabled: isCapturingScreenshot || isSelectingRegion,
|
|
2240
|
+
type: "button",
|
|
2241
|
+
variant: "ghost",
|
|
2242
|
+
onClick: () => void handleStartRecording(),
|
|
2243
|
+
children: [
|
|
2244
|
+
/* @__PURE__ */ jsx(CircleDot, { className: "size-4" }),
|
|
2245
|
+
"Record flow video"
|
|
2246
|
+
]
|
|
2247
|
+
}
|
|
2248
|
+
),
|
|
2249
|
+
hasDraft ? /* @__PURE__ */ jsxs(
|
|
2250
|
+
Button,
|
|
2251
|
+
{
|
|
2252
|
+
className: "h-10 w-full justify-start gap-2 rounded-xl",
|
|
2253
|
+
disabled: isCapturingScreenshot || isSelectingRegion,
|
|
2254
|
+
type: "button",
|
|
2255
|
+
variant: "ghost",
|
|
2256
|
+
onClick: () => {
|
|
2257
|
+
setMenuOpen(false);
|
|
2258
|
+
openModal();
|
|
2259
|
+
},
|
|
2260
|
+
children: [
|
|
2261
|
+
/* @__PURE__ */ jsx(FilePenLine, { className: "size-4" }),
|
|
2262
|
+
"Continue draft",
|
|
2263
|
+
/* @__PURE__ */ jsx("span", { className: "ml-auto text-xs text-gray-500", children: draftMode === "screenshot" ? "Screenshot" : "Video" })
|
|
2264
|
+
]
|
|
2265
|
+
}
|
|
2266
|
+
) : null
|
|
2267
|
+
] }) : null,
|
|
2268
|
+
/* @__PURE__ */ jsxs(
|
|
2269
|
+
Button,
|
|
2270
|
+
{
|
|
2271
|
+
className: "h-11 gap-2 rounded-full px-4 shadow-lg shadow-black/15",
|
|
2272
|
+
disabled: isCapturingScreenshot || isSelectingRegion,
|
|
2273
|
+
type: "button",
|
|
2274
|
+
onClick: () => setMenuOpen((current) => !current),
|
|
2275
|
+
children: [
|
|
2276
|
+
/* @__PURE__ */ jsxs("span", { className: "relative inline-flex items-center", children: [
|
|
2277
|
+
/* @__PURE__ */ jsx(TriangleAlert, { className: "size-4" }),
|
|
2278
|
+
hasDraft ? /* @__PURE__ */ jsx(
|
|
2279
|
+
"span",
|
|
2280
|
+
{
|
|
2281
|
+
"aria-hidden": true,
|
|
2282
|
+
className: "absolute -right-1.5 -top-1.5 inline-flex size-2 rounded-full bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.2)]"
|
|
2283
|
+
}
|
|
2284
|
+
) : null
|
|
2285
|
+
] }),
|
|
2286
|
+
"Report Bug"
|
|
2287
|
+
]
|
|
2288
|
+
}
|
|
2289
|
+
)
|
|
2290
|
+
] })
|
|
2291
|
+
}
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
function Dialog({
|
|
2295
|
+
...props
|
|
2296
|
+
}) {
|
|
2297
|
+
return /* @__PURE__ */ jsx(Dialog$1.Root, { "data-slot": "dialog", ...props });
|
|
2298
|
+
}
|
|
2299
|
+
function DialogPortal({
|
|
2300
|
+
...props
|
|
2301
|
+
}) {
|
|
2302
|
+
return /* @__PURE__ */ jsx(Dialog$1.Portal, { "data-slot": "dialog-portal", ...props });
|
|
2303
|
+
}
|
|
2304
|
+
function DialogOverlay({
|
|
2305
|
+
className,
|
|
2306
|
+
...props
|
|
2307
|
+
}) {
|
|
2308
|
+
return /* @__PURE__ */ jsx(
|
|
2309
|
+
Dialog$1.Overlay,
|
|
2310
|
+
{
|
|
2311
|
+
"data-slot": "dialog-overlay",
|
|
2312
|
+
className: cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/60 duration-100 supports-backdrop-filter:backdrop-blur-sm fixed inset-0 isolate z-50", className),
|
|
2313
|
+
...props
|
|
2314
|
+
}
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2317
|
+
function DialogContent({
|
|
2318
|
+
className,
|
|
2319
|
+
children,
|
|
2320
|
+
showCloseButton = true,
|
|
2321
|
+
...props
|
|
2322
|
+
}) {
|
|
2323
|
+
return /* @__PURE__ */ jsxs(DialogPortal, { children: [
|
|
2324
|
+
/* @__PURE__ */ jsx(DialogOverlay, {}),
|
|
2325
|
+
/* @__PURE__ */ jsxs(
|
|
2326
|
+
Dialog$1.Content,
|
|
2327
|
+
{
|
|
2328
|
+
"data-slot": "dialog-content",
|
|
2329
|
+
className: cn(
|
|
2330
|
+
"bg-white text-gray-900 data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-gray-900/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 shadow-xl duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
|
2331
|
+
className
|
|
2332
|
+
),
|
|
2333
|
+
...props,
|
|
2334
|
+
children: [
|
|
2335
|
+
children,
|
|
2336
|
+
showCloseButton && /* @__PURE__ */ jsx(Dialog$1.Close, { "data-slot": "dialog-close", asChild: true, children: /* @__PURE__ */ jsxs(Button, { variant: "ghost", className: "absolute top-4 right-4", size: "icon-sm", children: [
|
|
2337
|
+
/* @__PURE__ */ jsx(X, {}),
|
|
2338
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "Close" })
|
|
2339
|
+
] }) })
|
|
2340
|
+
]
|
|
2341
|
+
}
|
|
2342
|
+
)
|
|
2343
|
+
] });
|
|
2344
|
+
}
|
|
2345
|
+
function DialogHeader({ className, ...props }) {
|
|
2346
|
+
return /* @__PURE__ */ jsx(
|
|
2347
|
+
"div",
|
|
2348
|
+
{
|
|
2349
|
+
"data-slot": "dialog-header",
|
|
2350
|
+
className: cn("gap-2 flex flex-col", className),
|
|
2351
|
+
...props
|
|
2352
|
+
}
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
function DialogFooter({
|
|
2356
|
+
className,
|
|
2357
|
+
showCloseButton = false,
|
|
2358
|
+
children,
|
|
2359
|
+
...props
|
|
2360
|
+
}) {
|
|
2361
|
+
return /* @__PURE__ */ jsxs(
|
|
2362
|
+
"div",
|
|
2363
|
+
{
|
|
2364
|
+
"data-slot": "dialog-footer",
|
|
2365
|
+
className: cn(
|
|
2366
|
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
2367
|
+
className
|
|
2368
|
+
),
|
|
2369
|
+
...props,
|
|
2370
|
+
children: [
|
|
2371
|
+
children,
|
|
2372
|
+
showCloseButton && /* @__PURE__ */ jsx(Dialog$1.Close, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "outline", children: "Close" }) })
|
|
2373
|
+
]
|
|
2374
|
+
}
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
function DialogTitle({
|
|
2378
|
+
className,
|
|
2379
|
+
...props
|
|
2380
|
+
}) {
|
|
2381
|
+
return /* @__PURE__ */ jsx(
|
|
2382
|
+
Dialog$1.Title,
|
|
2383
|
+
{
|
|
2384
|
+
"data-slot": "dialog-title",
|
|
2385
|
+
className: cn("text-base leading-none font-medium", className),
|
|
2386
|
+
...props
|
|
2387
|
+
}
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
function DialogDescription({
|
|
2391
|
+
className,
|
|
2392
|
+
...props
|
|
2393
|
+
}) {
|
|
2394
|
+
return /* @__PURE__ */ jsx(
|
|
2395
|
+
Dialog$1.Description,
|
|
2396
|
+
{
|
|
2397
|
+
"data-slot": "dialog-description",
|
|
2398
|
+
className: cn("text-gray-500 *:[a]:hover:text-gray-900 text-sm *:[a]:underline *:[a]:underline-offset-3", className),
|
|
2399
|
+
...props
|
|
2400
|
+
}
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
function Input({ className, type, ...props }) {
|
|
2404
|
+
return /* @__PURE__ */ jsx(
|
|
2405
|
+
"input",
|
|
2406
|
+
{
|
|
2407
|
+
type,
|
|
2408
|
+
"data-slot": "input",
|
|
2409
|
+
className: cn(
|
|
2410
|
+
"bg-gray-100/30 border-gray-300 focus-visible:border-indigo-500 focus-visible:ring-indigo-500/50 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 dark:aria-invalid:border-red-500/50 h-9 rounded-4xl border px-3 py-1 text-base text-gray-900 transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-gray-900 placeholder:text-gray-400 w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
2411
|
+
className
|
|
2412
|
+
),
|
|
2413
|
+
...props
|
|
2414
|
+
}
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
function Textarea({ className, ...props }) {
|
|
2418
|
+
return /* @__PURE__ */ jsx(
|
|
2419
|
+
"textarea",
|
|
2420
|
+
{
|
|
2421
|
+
"data-slot": "textarea",
|
|
2422
|
+
className: cn(
|
|
2423
|
+
"border-gray-300 bg-gray-100/30 focus-visible:border-indigo-500 focus-visible:ring-indigo-500/50 aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 dark:aria-invalid:border-red-500/50 resize-none rounded-xl border px-3 py-3 text-base text-gray-900 transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-gray-400 flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
2424
|
+
className
|
|
2425
|
+
),
|
|
2426
|
+
...props
|
|
2427
|
+
}
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
function clamp(value) {
|
|
2431
|
+
if (value < 0) {
|
|
2432
|
+
return 0;
|
|
2433
|
+
}
|
|
2434
|
+
if (value > 1) {
|
|
2435
|
+
return 1;
|
|
2436
|
+
}
|
|
2437
|
+
return value;
|
|
2438
|
+
}
|
|
2439
|
+
function normalizeRect(start, end) {
|
|
2440
|
+
const x = clamp(Math.min(start.x, end.x));
|
|
2441
|
+
const y = clamp(Math.min(start.y, end.y));
|
|
2442
|
+
const maxX = clamp(Math.max(start.x, end.x));
|
|
2443
|
+
const maxY = clamp(Math.max(start.y, end.y));
|
|
2444
|
+
return {
|
|
2445
|
+
x,
|
|
2446
|
+
y,
|
|
2447
|
+
width: Math.max(0, maxX - x),
|
|
2448
|
+
height: Math.max(0, maxY - y)
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
function toDisplayRect(rect, width, height) {
|
|
2452
|
+
return {
|
|
2453
|
+
x: rect.x * width,
|
|
2454
|
+
y: rect.y * height,
|
|
2455
|
+
width: rect.width * width,
|
|
2456
|
+
height: rect.height * height
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
async function renderAnnotatedBlob(imageUrl, imageWidth, imageHeight, highlights) {
|
|
2460
|
+
const image = new Image();
|
|
2461
|
+
image.src = imageUrl;
|
|
2462
|
+
await new Promise((resolve, reject) => {
|
|
2463
|
+
const handleLoad = () => {
|
|
2464
|
+
cleanup();
|
|
2465
|
+
resolve();
|
|
2466
|
+
};
|
|
2467
|
+
const handleError = () => {
|
|
2468
|
+
cleanup();
|
|
2469
|
+
reject(new Error("Failed to prepare screenshot annotation image."));
|
|
2470
|
+
};
|
|
2471
|
+
const cleanup = () => {
|
|
2472
|
+
image.removeEventListener("load", handleLoad);
|
|
2473
|
+
image.removeEventListener("error", handleError);
|
|
2474
|
+
};
|
|
2475
|
+
image.addEventListener("load", handleLoad, { once: true });
|
|
2476
|
+
image.addEventListener("error", handleError, { once: true });
|
|
2477
|
+
});
|
|
2478
|
+
const canvas = document.createElement("canvas");
|
|
2479
|
+
canvas.width = imageWidth;
|
|
2480
|
+
canvas.height = imageHeight;
|
|
2481
|
+
const context = canvas.getContext("2d");
|
|
2482
|
+
if (!context) {
|
|
2483
|
+
throw new Error("Could not prepare screenshot annotation canvas.");
|
|
2484
|
+
}
|
|
2485
|
+
context.drawImage(image, 0, 0, imageWidth, imageHeight);
|
|
2486
|
+
context.lineWidth = Math.max(2, Math.round(Math.min(imageWidth, imageHeight) / 300));
|
|
2487
|
+
context.strokeStyle = "#ef4444";
|
|
2488
|
+
context.fillStyle = "rgba(239, 68, 68, 0.18)";
|
|
2489
|
+
for (const rect of highlights) {
|
|
2490
|
+
const draw = toDisplayRect(rect, imageWidth, imageHeight);
|
|
2491
|
+
context.fillRect(draw.x, draw.y, draw.width, draw.height);
|
|
2492
|
+
context.strokeRect(draw.x, draw.y, draw.width, draw.height);
|
|
2493
|
+
}
|
|
2494
|
+
const blob = await new Promise((resolve) => {
|
|
2495
|
+
canvas.toBlob(resolve, "image/png", 1);
|
|
2496
|
+
});
|
|
2497
|
+
if (!blob) {
|
|
2498
|
+
throw new Error("Failed to generate screenshot annotation.");
|
|
2499
|
+
}
|
|
2500
|
+
return blob;
|
|
2501
|
+
}
|
|
2502
|
+
function ScreenshotAnnotator({ imageUrl, disabled = false, onChange }) {
|
|
2503
|
+
const imageRef = useRef(null);
|
|
2504
|
+
const canvasRef = useRef(null);
|
|
2505
|
+
const [highlights, setHighlights] = useState([]);
|
|
2506
|
+
const [startPoint, setStartPoint] = useState(null);
|
|
2507
|
+
const [activeRect, setActiveRect] = useState(null);
|
|
2508
|
+
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
|
2509
|
+
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
|
|
2510
|
+
const isDrawing = Boolean(startPoint && activeRect);
|
|
2511
|
+
const syncCanvasSize = useCallback(() => {
|
|
2512
|
+
const image = imageRef.current;
|
|
2513
|
+
const canvas = canvasRef.current;
|
|
2514
|
+
if (!image || !canvas) {
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
const width = Math.max(1, Math.round(image.clientWidth));
|
|
2518
|
+
const height = Math.max(1, Math.round(image.clientHeight));
|
|
2519
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
2520
|
+
canvas.width = width;
|
|
2521
|
+
canvas.height = height;
|
|
2522
|
+
}
|
|
2523
|
+
setDisplaySize({ width, height });
|
|
2524
|
+
}, []);
|
|
2525
|
+
const handleImageLoad = useCallback(() => {
|
|
2526
|
+
const image = imageRef.current;
|
|
2527
|
+
if (!image) {
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
setNaturalSize({
|
|
2531
|
+
width: Math.max(1, image.naturalWidth || 1),
|
|
2532
|
+
height: Math.max(1, image.naturalHeight || 1)
|
|
2533
|
+
});
|
|
2534
|
+
syncCanvasSize();
|
|
2535
|
+
}, [syncCanvasSize]);
|
|
2536
|
+
useEffect(() => {
|
|
2537
|
+
const onResize = () => {
|
|
2538
|
+
syncCanvasSize();
|
|
2539
|
+
};
|
|
2540
|
+
window.addEventListener("resize", onResize);
|
|
2541
|
+
return () => {
|
|
2542
|
+
window.removeEventListener("resize", onResize);
|
|
2543
|
+
};
|
|
2544
|
+
}, [syncCanvasSize]);
|
|
2545
|
+
const drawCanvas = useCallback(() => {
|
|
2546
|
+
const canvas = canvasRef.current;
|
|
2547
|
+
if (!canvas) {
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
const context = canvas.getContext("2d");
|
|
2551
|
+
if (!context) {
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
2555
|
+
context.lineWidth = 2;
|
|
2556
|
+
context.strokeStyle = "#ef4444";
|
|
2557
|
+
context.fillStyle = "rgba(239, 68, 68, 0.18)";
|
|
2558
|
+
for (const rect of highlights) {
|
|
2559
|
+
const draw = toDisplayRect(rect, canvas.width, canvas.height);
|
|
2560
|
+
context.fillRect(draw.x, draw.y, draw.width, draw.height);
|
|
2561
|
+
context.strokeRect(draw.x, draw.y, draw.width, draw.height);
|
|
2562
|
+
}
|
|
2563
|
+
if (activeRect) {
|
|
2564
|
+
const draw = toDisplayRect(activeRect, canvas.width, canvas.height);
|
|
2565
|
+
context.fillRect(draw.x, draw.y, draw.width, draw.height);
|
|
2566
|
+
context.strokeRect(draw.x, draw.y, draw.width, draw.height);
|
|
2567
|
+
}
|
|
2568
|
+
}, [activeRect, highlights]);
|
|
2569
|
+
useEffect(() => {
|
|
2570
|
+
drawCanvas();
|
|
2571
|
+
}, [drawCanvas, displaySize]);
|
|
2572
|
+
useEffect(() => {
|
|
2573
|
+
let cancelled = false;
|
|
2574
|
+
const publish = async () => {
|
|
2575
|
+
if (naturalSize.width <= 0 || naturalSize.height <= 0) {
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
if (highlights.length === 0) {
|
|
2579
|
+
onChange({
|
|
2580
|
+
annotatedBlob: null,
|
|
2581
|
+
highlights: [],
|
|
2582
|
+
imageWidth: naturalSize.width,
|
|
2583
|
+
imageHeight: naturalSize.height
|
|
2584
|
+
});
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
try {
|
|
2588
|
+
const annotatedBlob = await renderAnnotatedBlob(imageUrl, naturalSize.width, naturalSize.height, highlights);
|
|
2589
|
+
if (cancelled) {
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
onChange({
|
|
2593
|
+
annotatedBlob,
|
|
2594
|
+
highlights,
|
|
2595
|
+
imageWidth: naturalSize.width,
|
|
2596
|
+
imageHeight: naturalSize.height
|
|
2597
|
+
});
|
|
2598
|
+
} catch {
|
|
2599
|
+
if (cancelled) {
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
onChange({
|
|
2603
|
+
annotatedBlob: null,
|
|
2604
|
+
highlights,
|
|
2605
|
+
imageWidth: naturalSize.width,
|
|
2606
|
+
imageHeight: naturalSize.height
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
};
|
|
2610
|
+
void publish();
|
|
2611
|
+
return () => {
|
|
2612
|
+
cancelled = true;
|
|
2613
|
+
};
|
|
2614
|
+
}, [highlights, imageUrl, naturalSize.height, naturalSize.width, onChange]);
|
|
2615
|
+
const toPoint = useCallback((event) => {
|
|
2616
|
+
const canvas = canvasRef.current;
|
|
2617
|
+
if (!canvas || canvas.width <= 0 || canvas.height <= 0) {
|
|
2618
|
+
return null;
|
|
2619
|
+
}
|
|
2620
|
+
const bounds = canvas.getBoundingClientRect();
|
|
2621
|
+
if (bounds.width <= 0 || bounds.height <= 0) {
|
|
2622
|
+
return null;
|
|
2623
|
+
}
|
|
2624
|
+
const x = clamp((event.clientX - bounds.left) / bounds.width);
|
|
2625
|
+
const y = clamp((event.clientY - bounds.top) / bounds.height);
|
|
2626
|
+
return { x, y };
|
|
2627
|
+
}, []);
|
|
2628
|
+
const handlePointerDown = (event) => {
|
|
2629
|
+
if (disabled) {
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const point = toPoint(event);
|
|
2633
|
+
if (!point) {
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
2637
|
+
setStartPoint(point);
|
|
2638
|
+
setActiveRect({ x: point.x, y: point.y, width: 0, height: 0 });
|
|
2639
|
+
};
|
|
2640
|
+
const handlePointerMove = (event) => {
|
|
2641
|
+
if (disabled || !startPoint) {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const point = toPoint(event);
|
|
2645
|
+
if (!point) {
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
setActiveRect(normalizeRect(startPoint, point));
|
|
2649
|
+
};
|
|
2650
|
+
const completeDrawing = (point) => {
|
|
2651
|
+
if (!point || !startPoint) {
|
|
2652
|
+
setStartPoint(null);
|
|
2653
|
+
setActiveRect(null);
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
const rect = normalizeRect(startPoint, point);
|
|
2657
|
+
if (rect.width >= 0.01 && rect.height >= 0.01) {
|
|
2658
|
+
setHighlights((current) => [...current, rect]);
|
|
2659
|
+
}
|
|
2660
|
+
setStartPoint(null);
|
|
2661
|
+
setActiveRect(null);
|
|
2662
|
+
};
|
|
2663
|
+
const handlePointerUp = (event) => {
|
|
2664
|
+
if (disabled) {
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const point = toPoint(event);
|
|
2668
|
+
completeDrawing(point);
|
|
2669
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
2670
|
+
};
|
|
2671
|
+
const handlePointerCancel = () => {
|
|
2672
|
+
setStartPoint(null);
|
|
2673
|
+
setActiveRect(null);
|
|
2674
|
+
};
|
|
2675
|
+
const clearHighlights = () => {
|
|
2676
|
+
setHighlights([]);
|
|
2677
|
+
setStartPoint(null);
|
|
2678
|
+
setActiveRect(null);
|
|
2679
|
+
};
|
|
2680
|
+
const undoLast = () => {
|
|
2681
|
+
setHighlights((current) => current.slice(0, -1));
|
|
2682
|
+
};
|
|
2683
|
+
const highlightCountLabel = useMemo(() => {
|
|
2684
|
+
const count = highlights.length;
|
|
2685
|
+
return `${count} highlight${count === 1 ? "" : "s"}`;
|
|
2686
|
+
}, [highlights.length]);
|
|
2687
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
2688
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
|
|
2689
|
+
/* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-500", children: [
|
|
2690
|
+
"Drag over the screenshot to highlight the exact area with an issue. ",
|
|
2691
|
+
highlightCountLabel,
|
|
2692
|
+
" added."
|
|
2693
|
+
] }),
|
|
2694
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
2695
|
+
/* @__PURE__ */ jsx(Button, { disabled: disabled || highlights.length === 0, size: "sm", type: "button", variant: "outline", onClick: undoLast, children: "Undo" }),
|
|
2696
|
+
/* @__PURE__ */ jsx(
|
|
2697
|
+
Button,
|
|
2698
|
+
{
|
|
2699
|
+
disabled: disabled || highlights.length === 0 && !isDrawing,
|
|
2700
|
+
size: "sm",
|
|
2701
|
+
type: "button",
|
|
2702
|
+
variant: "outline",
|
|
2703
|
+
onClick: clearHighlights,
|
|
2704
|
+
children: "Clear"
|
|
2705
|
+
}
|
|
2706
|
+
)
|
|
2707
|
+
] })
|
|
2708
|
+
] }),
|
|
2709
|
+
/* @__PURE__ */ jsxs("div", { className: "relative overflow-hidden rounded-lg border border-gray-200 bg-black/5", children: [
|
|
2710
|
+
/* @__PURE__ */ jsx(
|
|
2711
|
+
"img",
|
|
2712
|
+
{
|
|
2713
|
+
ref: imageRef,
|
|
2714
|
+
alt: "Captured screenshot",
|
|
2715
|
+
className: "mx-auto h-auto max-h-[45vh] w-full select-none object-contain",
|
|
2716
|
+
onLoad: handleImageLoad,
|
|
2717
|
+
src: imageUrl
|
|
2718
|
+
}
|
|
2719
|
+
),
|
|
2720
|
+
/* @__PURE__ */ jsx(
|
|
2721
|
+
"canvas",
|
|
2722
|
+
{
|
|
2723
|
+
ref: canvasRef,
|
|
2724
|
+
className: `absolute inset-0 h-full w-full ${disabled ? "cursor-not-allowed" : "cursor-crosshair"}`,
|
|
2725
|
+
onPointerCancel: handlePointerCancel,
|
|
2726
|
+
onPointerDown: handlePointerDown,
|
|
2727
|
+
onPointerMove: handlePointerMove,
|
|
2728
|
+
onPointerUp: handlePointerUp
|
|
2729
|
+
}
|
|
2730
|
+
)
|
|
2731
|
+
] })
|
|
2732
|
+
] });
|
|
2733
|
+
}
|
|
2734
|
+
function formatElapsed2(ms) {
|
|
2735
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1e3));
|
|
2736
|
+
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
|
|
2737
|
+
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
|
|
2738
|
+
return `${minutes}:${seconds}`;
|
|
2739
|
+
}
|
|
2740
|
+
function providerLabel(provider) {
|
|
2741
|
+
return provider === "linear" ? "Linear" : "Jira";
|
|
2742
|
+
}
|
|
2743
|
+
function BugReporterModal() {
|
|
2744
|
+
const {
|
|
2745
|
+
autoStopNotice,
|
|
2746
|
+
availableProviders,
|
|
2747
|
+
captureQuickScreenshot,
|
|
2748
|
+
clearDraft,
|
|
2749
|
+
closeModal,
|
|
2750
|
+
draftMode,
|
|
2751
|
+
elapsedMs,
|
|
2752
|
+
error,
|
|
2753
|
+
hasDraft,
|
|
2754
|
+
isCapturingScreenshot,
|
|
2755
|
+
isOpen,
|
|
2756
|
+
isSubmitting,
|
|
2757
|
+
submissionProgress,
|
|
2758
|
+
openModal,
|
|
2759
|
+
resetMessages,
|
|
2760
|
+
screenshotHighlightCount,
|
|
2761
|
+
screenshotPreviewUrl,
|
|
2762
|
+
selectedProvider,
|
|
2763
|
+
setSelectedProvider,
|
|
2764
|
+
startRecording,
|
|
2765
|
+
startRegionSelection,
|
|
2766
|
+
submitReport,
|
|
2767
|
+
success,
|
|
2768
|
+
updateScreenshotAnnotation,
|
|
2769
|
+
videoPreviewUrl
|
|
2770
|
+
} = useBugReporter();
|
|
2771
|
+
const [title, setTitle] = useState("");
|
|
2772
|
+
const [description, setDescription] = useState("");
|
|
2773
|
+
const [step, setStep] = useState("review");
|
|
2774
|
+
const elapsedLabel = useMemo(() => formatElapsed2(elapsedMs), [elapsedMs]);
|
|
2775
|
+
const handleDialogOpenChange = (open) => {
|
|
2776
|
+
if (open) {
|
|
2777
|
+
openModal();
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
closeModal();
|
|
2781
|
+
setStep("review");
|
|
2782
|
+
};
|
|
2783
|
+
const handleSubmit = async (event) => {
|
|
2784
|
+
event.preventDefault();
|
|
2785
|
+
const result = await submitReport(title, description);
|
|
2786
|
+
if (result) {
|
|
2787
|
+
setTitle("");
|
|
2788
|
+
setDescription("");
|
|
2789
|
+
setStep("review");
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
const hasIntegrations = availableProviders.length > 0;
|
|
2793
|
+
const canSubmit = !isSubmitting && !isCapturingScreenshot && hasIntegrations && !!selectedProvider && hasDraft && title.trim().length > 0;
|
|
2794
|
+
return /* @__PURE__ */ jsx(Dialog, { open: isOpen, onOpenChange: handleDialogOpenChange, children: /* @__PURE__ */ jsx(
|
|
2795
|
+
DialogContent,
|
|
2796
|
+
{
|
|
2797
|
+
className: "max-h-[90vh] overflow-y-auto sm:max-w-2xl",
|
|
2798
|
+
"data-bug-reporter-ui": "true",
|
|
2799
|
+
showCloseButton: !isSubmitting,
|
|
2800
|
+
children: /* @__PURE__ */ jsx("form", { className: "space-y-4", onSubmit: handleSubmit, children: step === "review" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2801
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
2802
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Review capture" }),
|
|
2803
|
+
/* @__PURE__ */ jsxs(DialogDescription, { children: [
|
|
2804
|
+
"Step 1 of 2. Review your ",
|
|
2805
|
+
draftMode === "video" ? "video" : "screenshot",
|
|
2806
|
+
", annotate if needed, or retake."
|
|
2807
|
+
] })
|
|
2808
|
+
] }),
|
|
2809
|
+
!hasDraft ? /* @__PURE__ */ jsxs("div", { className: "space-y-2 rounded-xl border border-gray-200 bg-gray-100/20 px-4 py-3 text-sm", children: [
|
|
2810
|
+
/* @__PURE__ */ jsx("p", { className: "font-medium", children: "No capture draft yet" }),
|
|
2811
|
+
/* @__PURE__ */ jsx("p", { className: "text-gray-500", children: "Start from the Report Bug button and choose Quick screenshot or Record flow first." })
|
|
2812
|
+
] }) : draftMode === "screenshot" ? /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
2813
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
|
|
2814
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-gray-500", children: "Drag on the image to highlight exactly where the issue appears." }),
|
|
2815
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
2816
|
+
/* @__PURE__ */ jsxs(
|
|
2817
|
+
Button,
|
|
2818
|
+
{
|
|
2819
|
+
className: "gap-2",
|
|
2820
|
+
disabled: isSubmitting || isCapturingScreenshot,
|
|
2821
|
+
size: "sm",
|
|
2822
|
+
type: "button",
|
|
2823
|
+
variant: "outline",
|
|
2824
|
+
onClick: () => {
|
|
2825
|
+
closeModal();
|
|
2826
|
+
void captureQuickScreenshot().then(() => openModal());
|
|
2827
|
+
},
|
|
2828
|
+
children: [
|
|
2829
|
+
/* @__PURE__ */ jsx(Camera, { className: "size-4" }),
|
|
2830
|
+
"Full page"
|
|
2831
|
+
]
|
|
2832
|
+
}
|
|
2833
|
+
),
|
|
2834
|
+
/* @__PURE__ */ jsxs(
|
|
2835
|
+
Button,
|
|
2836
|
+
{
|
|
2837
|
+
className: "gap-2",
|
|
2838
|
+
disabled: isSubmitting || isCapturingScreenshot,
|
|
2839
|
+
size: "sm",
|
|
2840
|
+
type: "button",
|
|
2841
|
+
variant: "outline",
|
|
2842
|
+
onClick: () => {
|
|
2843
|
+
closeModal();
|
|
2844
|
+
startRegionSelection();
|
|
2845
|
+
},
|
|
2846
|
+
children: [
|
|
2847
|
+
/* @__PURE__ */ jsx(Crop, { className: "size-4" }),
|
|
2848
|
+
"Select area"
|
|
2849
|
+
]
|
|
2850
|
+
}
|
|
2851
|
+
)
|
|
2852
|
+
] })
|
|
2853
|
+
] }),
|
|
2854
|
+
screenshotPreviewUrl ? /* @__PURE__ */ jsx(
|
|
2855
|
+
ScreenshotAnnotator,
|
|
2856
|
+
{
|
|
2857
|
+
imageUrl: screenshotPreviewUrl,
|
|
2858
|
+
onChange: updateScreenshotAnnotation
|
|
2859
|
+
},
|
|
2860
|
+
screenshotPreviewUrl
|
|
2861
|
+
) : null,
|
|
2862
|
+
/* @__PURE__ */ jsxs("p", { className: "text-xs text-gray-500", children: [
|
|
2863
|
+
"Highlights added: ",
|
|
2864
|
+
screenshotHighlightCount
|
|
2865
|
+
] })
|
|
2866
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
2867
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
|
|
2868
|
+
/* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-500", children: [
|
|
2869
|
+
"Duration: ",
|
|
2870
|
+
elapsedLabel,
|
|
2871
|
+
". Screen + microphone attached."
|
|
2872
|
+
] }),
|
|
2873
|
+
/* @__PURE__ */ jsxs(
|
|
2874
|
+
Button,
|
|
2875
|
+
{
|
|
2876
|
+
className: "gap-2",
|
|
2877
|
+
disabled: isSubmitting || isCapturingScreenshot,
|
|
2878
|
+
size: "sm",
|
|
2879
|
+
type: "button",
|
|
2880
|
+
variant: "outline",
|
|
2881
|
+
onClick: () => {
|
|
2882
|
+
closeModal();
|
|
2883
|
+
void startRecording();
|
|
2884
|
+
},
|
|
2885
|
+
children: [
|
|
2886
|
+
/* @__PURE__ */ jsx(CircleDot, { className: "size-4" }),
|
|
2887
|
+
"Record again"
|
|
2888
|
+
]
|
|
2889
|
+
}
|
|
2890
|
+
)
|
|
2891
|
+
] }),
|
|
2892
|
+
videoPreviewUrl ? /* @__PURE__ */ jsx(
|
|
2893
|
+
"video",
|
|
2894
|
+
{
|
|
2895
|
+
className: "w-full rounded-lg border border-gray-200 bg-black",
|
|
2896
|
+
controls: true,
|
|
2897
|
+
playsInline: true,
|
|
2898
|
+
preload: "metadata",
|
|
2899
|
+
src: videoPreviewUrl
|
|
2900
|
+
}
|
|
2901
|
+
) : null,
|
|
2902
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-amber-700", children: "Video recording uses the browser share prompt each time by web platform design." })
|
|
2903
|
+
] }),
|
|
2904
|
+
error ? /* @__PURE__ */ jsx("p", { className: "text-sm text-red-600", children: error }) : null,
|
|
2905
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
2906
|
+
/* @__PURE__ */ jsx(
|
|
2907
|
+
Button,
|
|
2908
|
+
{
|
|
2909
|
+
disabled: isSubmitting || isCapturingScreenshot,
|
|
2910
|
+
type: "button",
|
|
2911
|
+
variant: "outline",
|
|
2912
|
+
onClick: () => {
|
|
2913
|
+
clearDraft();
|
|
2914
|
+
resetMessages();
|
|
2915
|
+
closeModal();
|
|
2916
|
+
setStep("review");
|
|
2917
|
+
},
|
|
2918
|
+
children: "Discard draft"
|
|
2919
|
+
}
|
|
2920
|
+
),
|
|
2921
|
+
/* @__PURE__ */ jsxs(
|
|
2922
|
+
Button,
|
|
2923
|
+
{
|
|
2924
|
+
className: "gap-2",
|
|
2925
|
+
disabled: !hasDraft || isCapturingScreenshot,
|
|
2926
|
+
type: "button",
|
|
2927
|
+
onClick: () => {
|
|
2928
|
+
resetMessages();
|
|
2929
|
+
setStep("context");
|
|
2930
|
+
},
|
|
2931
|
+
children: [
|
|
2932
|
+
"Next",
|
|
2933
|
+
/* @__PURE__ */ jsx(ArrowRight, { className: "size-4" })
|
|
2934
|
+
]
|
|
2935
|
+
}
|
|
2936
|
+
)
|
|
2937
|
+
] })
|
|
2938
|
+
] }) : (
|
|
2939
|
+
/* ── Step 2: Add context & submit ── */
|
|
2940
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
2941
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
2942
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Add context" }),
|
|
2943
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "Step 2 of 2. Describe the issue and submit. Metadata and network logs are attached automatically." })
|
|
2944
|
+
] }),
|
|
2945
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
|
|
2946
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5 sm:col-span-2", children: [
|
|
2947
|
+
/* @__PURE__ */ jsx("label", { className: "text-sm font-medium", htmlFor: "bug-title", children: "Title" }),
|
|
2948
|
+
/* @__PURE__ */ jsx(
|
|
2949
|
+
Input,
|
|
2950
|
+
{
|
|
2951
|
+
id: "bug-title",
|
|
2952
|
+
maxLength: 140,
|
|
2953
|
+
placeholder: "Short summary of the bug",
|
|
2954
|
+
value: title,
|
|
2955
|
+
onChange: (event) => setTitle(event.target.value)
|
|
2956
|
+
}
|
|
2957
|
+
)
|
|
2958
|
+
] }),
|
|
2959
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5 sm:col-span-2", children: [
|
|
2960
|
+
/* @__PURE__ */ jsx("label", { className: "text-sm font-medium", htmlFor: "bug-description", children: "Quick note" }),
|
|
2961
|
+
/* @__PURE__ */ jsx(
|
|
2962
|
+
Textarea,
|
|
2963
|
+
{
|
|
2964
|
+
id: "bug-description",
|
|
2965
|
+
maxLength: 4e3,
|
|
2966
|
+
placeholder: "What did you expect, what happened, and any quick repro steps.",
|
|
2967
|
+
value: description,
|
|
2968
|
+
onChange: (event) => setDescription(event.target.value)
|
|
2969
|
+
}
|
|
2970
|
+
)
|
|
2971
|
+
] }),
|
|
2972
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
|
|
2973
|
+
/* @__PURE__ */ jsx("label", { className: "text-sm font-medium", htmlFor: "bug-provider", children: "Submit to" }),
|
|
2974
|
+
/* @__PURE__ */ jsx(
|
|
2975
|
+
"select",
|
|
2976
|
+
{
|
|
2977
|
+
id: "bug-provider",
|
|
2978
|
+
className: "bg-gray-100/30 border-gray-300 focus-visible:border-indigo-500 focus-visible:ring-indigo-500/50 h-9 w-full rounded-4xl border px-3 text-sm text-gray-900 outline-none focus-visible:ring-[3px]",
|
|
2979
|
+
disabled: isSubmitting || isCapturingScreenshot || !hasIntegrations,
|
|
2980
|
+
value: selectedProvider ?? "",
|
|
2981
|
+
onChange: (event) => setSelectedProvider(event.target.value),
|
|
2982
|
+
children: availableProviders.map((provider) => /* @__PURE__ */ jsx("option", { value: provider, children: providerLabel(provider) }, provider))
|
|
2983
|
+
}
|
|
2984
|
+
)
|
|
2985
|
+
] }),
|
|
2986
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-100/20 px-3 py-2", children: [
|
|
2987
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-sm", children: [
|
|
2988
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium", children: "Capture" }),
|
|
2989
|
+
/* @__PURE__ */ jsx("span", { className: "text-gray-500", children: draftMode === "screenshot" ? "Screenshot" : draftMode === "video" ? "Video" : "None" })
|
|
2990
|
+
] }),
|
|
2991
|
+
/* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-gray-500", children: draftMode === "screenshot" ? `${screenshotHighlightCount} highlight${screenshotHighlightCount === 1 ? "" : "s"} added` : draftMode === "video" ? `Duration: ${elapsedLabel}` : "Missing capture" })
|
|
2992
|
+
] })
|
|
2993
|
+
] }),
|
|
2994
|
+
autoStopNotice ? /* @__PURE__ */ jsx("p", { className: "text-sm text-amber-700", children: autoStopNotice }) : null,
|
|
2995
|
+
error ? /* @__PURE__ */ jsx("p", { className: "text-sm text-red-600", children: error }) : null,
|
|
2996
|
+
success ? /* @__PURE__ */ jsx("p", { className: "text-sm text-emerald-600", children: success }) : null,
|
|
2997
|
+
/* @__PURE__ */ jsxs(DialogFooter, { children: [
|
|
2998
|
+
/* @__PURE__ */ jsxs(
|
|
2999
|
+
Button,
|
|
3000
|
+
{
|
|
3001
|
+
className: "gap-2",
|
|
3002
|
+
disabled: isSubmitting,
|
|
3003
|
+
type: "button",
|
|
3004
|
+
variant: "outline",
|
|
3005
|
+
onClick: () => {
|
|
3006
|
+
resetMessages();
|
|
3007
|
+
setStep("review");
|
|
3008
|
+
},
|
|
3009
|
+
children: [
|
|
3010
|
+
/* @__PURE__ */ jsx(ArrowLeft, { className: "size-4" }),
|
|
3011
|
+
"Back"
|
|
3012
|
+
]
|
|
3013
|
+
}
|
|
3014
|
+
),
|
|
3015
|
+
/* @__PURE__ */ jsx(Button, { disabled: !canSubmit, type: "submit", children: isSubmitting ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2", children: [
|
|
3016
|
+
/* @__PURE__ */ jsx("span", { className: "inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" }),
|
|
3017
|
+
submissionProgress || "Submitting\u2026"
|
|
3018
|
+
] }) : `Submit to ${selectedProvider ? providerLabel(selectedProvider) : "tracker"}` })
|
|
3019
|
+
] })
|
|
3020
|
+
] })
|
|
3021
|
+
) })
|
|
3022
|
+
}
|
|
3023
|
+
) });
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
export { BugReporter, BugReporterModal, BugReporterProvider, BugSession, DEFAULT_MAX_RECORDING_MS, FloatingBugButton, JiraIntegration, LinearIntegration, NetworkLogger, ScreenRecorder, ScreenshotCapturer, collectClientEnvironmentMetadata, formatNetworkLogs, toErrorMessage, useBugReporter };
|
|
3027
|
+
//# sourceMappingURL=index.js.map
|
|
3028
|
+
//# sourceMappingURL=index.js.map
|