@checkflow/sdk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +265 -0
- package/dist/analytics-tracker.d.ts +107 -0
- package/dist/annotation/editor.d.ts +72 -0
- package/dist/annotation/index.d.ts +9 -0
- package/dist/annotation/styles.d.ts +6 -0
- package/dist/annotation/toolbar.d.ts +32 -0
- package/dist/annotation/types.d.ts +85 -0
- package/dist/api-client.d.ts +78 -0
- package/dist/checkflow.css +1 -0
- package/dist/checkflow.d.ts +112 -0
- package/dist/context-capture.d.ts +42 -0
- package/dist/error-capture.d.ts +60 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.esm.js +4576 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +4613 -0
- package/dist/index.js.map +1 -0
- package/dist/privacy/detector.d.ts +56 -0
- package/dist/privacy/index.d.ts +8 -0
- package/dist/privacy/masker.d.ts +43 -0
- package/dist/privacy/types.d.ts +54 -0
- package/dist/react/index.d.ts +77 -0
- package/dist/session-recording.d.ts +74 -0
- package/dist/types.d.ts +299 -0
- package/dist/vue/index.d.ts +55 -0
- package/dist/widget/Widget.d.ts +98 -0
- package/dist/widget/index.d.ts +2 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4613 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
var react = require('react');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CheckFlow API Client
|
|
10
|
+
* Handles all communication with the CheckFlow backend
|
|
11
|
+
*/
|
|
12
|
+
class APIClient {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.apiUrl = options.apiUrl.replace(/\/$/, '');
|
|
15
|
+
this.apiKey = options.apiKey;
|
|
16
|
+
this.projectId = options.projectId;
|
|
17
|
+
this.timeout = options.timeout || 30000;
|
|
18
|
+
this.debug = options.debug || false;
|
|
19
|
+
}
|
|
20
|
+
getProjectId() {
|
|
21
|
+
return this.projectId;
|
|
22
|
+
}
|
|
23
|
+
getBaseUrl() {
|
|
24
|
+
return this.apiUrl;
|
|
25
|
+
}
|
|
26
|
+
log(...args) {
|
|
27
|
+
if (this.debug) {
|
|
28
|
+
console.log('[CheckFlow]', ...args);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async get(endpoint, headers) {
|
|
32
|
+
return this.request('GET', endpoint, undefined, headers);
|
|
33
|
+
}
|
|
34
|
+
async post(endpoint, data, headers) {
|
|
35
|
+
return this.request('POST', endpoint, data, headers);
|
|
36
|
+
}
|
|
37
|
+
async put(endpoint, data, headers) {
|
|
38
|
+
return this.request('PUT', endpoint, data, headers);
|
|
39
|
+
}
|
|
40
|
+
async request(method, endpoint, data, headers) {
|
|
41
|
+
const url = `${this.apiUrl}${endpoint}`;
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
44
|
+
try {
|
|
45
|
+
this.log(`${method} ${endpoint}`, data);
|
|
46
|
+
const response = await fetch(url, {
|
|
47
|
+
method,
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'X-API-Key': this.apiKey,
|
|
51
|
+
...headers,
|
|
52
|
+
},
|
|
53
|
+
body: data ? JSON.stringify(data) : undefined,
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
});
|
|
56
|
+
clearTimeout(timeoutId);
|
|
57
|
+
const responseData = await response.json();
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
this.log('API Error:', response.status, responseData);
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: responseData.detail || responseData.message || 'Request failed',
|
|
63
|
+
statusCode: response.status,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
this.log('API Response:', responseData);
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
data: responseData,
|
|
70
|
+
statusCode: response.status,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
clearTimeout(timeoutId);
|
|
75
|
+
if (error.name === 'AbortError') {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
error: 'Request timeout',
|
|
79
|
+
statusCode: 408,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
this.log('API Error:', error);
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: error.message || 'Network error',
|
|
86
|
+
statusCode: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Submit feedback to the backend
|
|
92
|
+
*/
|
|
93
|
+
async submitFeedback(feedback, capture, user, sessionRecording, annotations) {
|
|
94
|
+
const payload = {
|
|
95
|
+
title: feedback.title,
|
|
96
|
+
description: feedback.description || '',
|
|
97
|
+
type: feedback.type,
|
|
98
|
+
priority: feedback.priority || 'medium',
|
|
99
|
+
tags: feedback.tags || [],
|
|
100
|
+
url: capture?.context?.url || window.location.href,
|
|
101
|
+
// Context data
|
|
102
|
+
viewport_width: capture?.context?.viewport?.width,
|
|
103
|
+
viewport_height: capture?.context?.viewport?.height,
|
|
104
|
+
user_agent: capture?.context?.userAgent || navigator.userAgent,
|
|
105
|
+
browser: capture?.context?.browser?.name,
|
|
106
|
+
browser_version: capture?.context?.browser?.version,
|
|
107
|
+
os: capture?.context?.os?.name,
|
|
108
|
+
os_version: capture?.context?.os?.version,
|
|
109
|
+
locale: capture?.context?.language || navigator.language,
|
|
110
|
+
timezone: capture?.context?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
111
|
+
// Capture data
|
|
112
|
+
console_logs: capture?.consoleLogs || [],
|
|
113
|
+
network_logs: capture?.networkLogs || [],
|
|
114
|
+
performance_metrics: capture?.performance || {},
|
|
115
|
+
// Screenshot (base64) - included directly in payload
|
|
116
|
+
screenshot_base64: capture?.screenshot || null,
|
|
117
|
+
// Session recording events
|
|
118
|
+
session_recording_events: sessionRecording?.events || null,
|
|
119
|
+
session_id: sessionRecording?.sessionId || null,
|
|
120
|
+
// Annotations data
|
|
121
|
+
annotations: annotations || null,
|
|
122
|
+
// User data
|
|
123
|
+
reporter_email: user?.email,
|
|
124
|
+
reporter_name: user?.name,
|
|
125
|
+
// Custom metadata
|
|
126
|
+
custom_fields: {
|
|
127
|
+
...feedback.metadata,
|
|
128
|
+
sdk_version: '__SDK_VERSION__',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const response = await this.request('POST', '/api/v1/capture/sdk/feedback', payload);
|
|
132
|
+
if (response.success && response.data) {
|
|
133
|
+
return {
|
|
134
|
+
success: true,
|
|
135
|
+
feedbackId: response.data.id,
|
|
136
|
+
shortId: response.data.short_id,
|
|
137
|
+
screenshotUrl: response.data.screenshot_url,
|
|
138
|
+
videoUrl: response.data.video_url,
|
|
139
|
+
message: 'Feedback submitted successfully',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: response.error || 'Failed to submit feedback',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Upload screenshot as attachment
|
|
149
|
+
*/
|
|
150
|
+
async uploadScreenshot(feedbackId, base64Image) {
|
|
151
|
+
// Convert base64 to blob
|
|
152
|
+
const byteString = atob(base64Image.split(',')[1] || base64Image);
|
|
153
|
+
const mimeType = base64Image.match(/data:(.*?);/)?.[1] || 'image/png';
|
|
154
|
+
const ab = new ArrayBuffer(byteString.length);
|
|
155
|
+
const ia = new Uint8Array(ab);
|
|
156
|
+
for (let i = 0; i < byteString.length; i++) {
|
|
157
|
+
ia[i] = byteString.charCodeAt(i);
|
|
158
|
+
}
|
|
159
|
+
const blob = new Blob([ab], { type: mimeType });
|
|
160
|
+
const formData = new FormData();
|
|
161
|
+
formData.append('file', blob, `screenshot-${Date.now()}.png`);
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(`${this.apiUrl}/api/v1/comments/attachments/upload?feedback_id=${feedbackId}`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'X-API-Key': this.apiKey,
|
|
167
|
+
},
|
|
168
|
+
body: formData,
|
|
169
|
+
});
|
|
170
|
+
if (response.ok) {
|
|
171
|
+
return { success: true, data: await response.json() };
|
|
172
|
+
}
|
|
173
|
+
return { success: false, error: 'Failed to upload screenshot' };
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return { success: false, error: error.message };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Send SDK capture data to backend for processing
|
|
181
|
+
* Aligns with backend SDKCapturePayload schema
|
|
182
|
+
*/
|
|
183
|
+
async sendCapture(capture, sessionId) {
|
|
184
|
+
// Format payload to match backend SDKCapturePayload schema
|
|
185
|
+
const payload = {
|
|
186
|
+
url: capture.context.url,
|
|
187
|
+
page_title: capture.context.title,
|
|
188
|
+
referrer: capture.context.referrer,
|
|
189
|
+
screenshot_base64: capture.screenshot,
|
|
190
|
+
console_logs: capture.consoleLogs?.map(log => ({
|
|
191
|
+
level: log.level,
|
|
192
|
+
message: log.message,
|
|
193
|
+
timestamp: log.timestamp,
|
|
194
|
+
source: log.stack?.split('\n')[0],
|
|
195
|
+
})) || [],
|
|
196
|
+
network_requests: capture.networkLogs?.map(req => ({
|
|
197
|
+
url: req.url,
|
|
198
|
+
method: req.method,
|
|
199
|
+
status: req.status,
|
|
200
|
+
status_text: req.statusText,
|
|
201
|
+
duration_ms: req.duration,
|
|
202
|
+
size: req.size,
|
|
203
|
+
resource_type: req.type,
|
|
204
|
+
timestamp: req.timestamp,
|
|
205
|
+
is_failed: !!req.error,
|
|
206
|
+
failure_text: req.error,
|
|
207
|
+
})) || [],
|
|
208
|
+
performance_data: capture.performance ? {
|
|
209
|
+
navigation_start: 0,
|
|
210
|
+
dom_content_loaded: capture.performance.domContentLoaded,
|
|
211
|
+
load_event: capture.performance.loadTime,
|
|
212
|
+
first_contentful_paint: capture.performance.firstContentfulPaint,
|
|
213
|
+
largest_contentful_paint: capture.performance.largestContentfulPaint,
|
|
214
|
+
cumulative_layout_shift: capture.performance.cumulativeLayoutShift,
|
|
215
|
+
first_input_delay: capture.performance.firstInputDelay,
|
|
216
|
+
} : undefined,
|
|
217
|
+
javascript_errors: [],
|
|
218
|
+
viewport: capture.context.viewport,
|
|
219
|
+
user_agent: capture.context.userAgent,
|
|
220
|
+
language: capture.context.language,
|
|
221
|
+
timezone: capture.context.timezone,
|
|
222
|
+
session_id: sessionId,
|
|
223
|
+
custom_data: {
|
|
224
|
+
browser: capture.context.browser,
|
|
225
|
+
os: capture.context.os,
|
|
226
|
+
device_type: capture.context.deviceType,
|
|
227
|
+
screen_resolution: capture.context.screenResolution,
|
|
228
|
+
pixel_ratio: capture.context.pixelRatio,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
return this.request('POST', '/api/v1/capture/sdk/capture', payload);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Upload just a screenshot (simpler endpoint)
|
|
235
|
+
*/
|
|
236
|
+
async uploadScreenshotOnly(screenshotBase64, url) {
|
|
237
|
+
return this.request('POST', '/api/v1/capture/sdk/screenshot', {
|
|
238
|
+
screenshot_base64: screenshotBase64,
|
|
239
|
+
url: url,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Save session recording to backend
|
|
244
|
+
* Aligns with backend /sdk/recording endpoint
|
|
245
|
+
*/
|
|
246
|
+
async saveSessionRecording(recording) {
|
|
247
|
+
return this.request('POST', '/api/v1/capture/sdk/recording', {
|
|
248
|
+
events: recording.events,
|
|
249
|
+
session_id: recording.sessionId,
|
|
250
|
+
start_url: recording.startUrl,
|
|
251
|
+
end_url: recording.endUrl,
|
|
252
|
+
duration_seconds: recording.durationSeconds,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Report an error to the backend
|
|
257
|
+
*/
|
|
258
|
+
async reportError(error) {
|
|
259
|
+
return this.request('POST', '/api/v1/capture/sdk/error', {
|
|
260
|
+
...error,
|
|
261
|
+
timestamp: new Date().toISOString(),
|
|
262
|
+
url: window.location.href,
|
|
263
|
+
user_agent: navigator.userAgent,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Check API health
|
|
268
|
+
*/
|
|
269
|
+
async healthCheck() {
|
|
270
|
+
const response = await this.request('GET', '/health');
|
|
271
|
+
return response.success;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Update project ID
|
|
275
|
+
*/
|
|
276
|
+
setProjectId(projectId) {
|
|
277
|
+
this.projectId = projectId;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Context Capture Module
|
|
283
|
+
* Handles screenshot capture, console logs, network logs, and performance metrics
|
|
284
|
+
*/
|
|
285
|
+
// ==================== Console Capture ====================
|
|
286
|
+
class ConsoleCapture {
|
|
287
|
+
constructor(maxEntries = 100) {
|
|
288
|
+
this.entries = [];
|
|
289
|
+
this.originalMethods = {};
|
|
290
|
+
this.isCapturing = false;
|
|
291
|
+
this.maxEntries = maxEntries;
|
|
292
|
+
}
|
|
293
|
+
start() {
|
|
294
|
+
if (this.isCapturing)
|
|
295
|
+
return;
|
|
296
|
+
this.isCapturing = true;
|
|
297
|
+
const levels = [
|
|
298
|
+
'log', 'info', 'warn', 'error', 'debug'
|
|
299
|
+
];
|
|
300
|
+
levels.forEach((level) => {
|
|
301
|
+
const originalMethod = console[level];
|
|
302
|
+
if (typeof originalMethod === 'function') {
|
|
303
|
+
this.originalMethods[level] = originalMethod.bind(console);
|
|
304
|
+
console[level] = (...args) => {
|
|
305
|
+
this.addEntry(level, args);
|
|
306
|
+
if (this.originalMethods[level]) {
|
|
307
|
+
this.originalMethods[level](...args);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
stop() {
|
|
314
|
+
if (!this.isCapturing)
|
|
315
|
+
return;
|
|
316
|
+
this.isCapturing = false;
|
|
317
|
+
Object.entries(this.originalMethods).forEach(([level, method]) => {
|
|
318
|
+
console[level] = method;
|
|
319
|
+
});
|
|
320
|
+
this.originalMethods = {};
|
|
321
|
+
}
|
|
322
|
+
addEntry(level, args) {
|
|
323
|
+
const message = args
|
|
324
|
+
.map((arg) => {
|
|
325
|
+
if (typeof arg === 'object') {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.stringify(arg, null, 2);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return String(arg);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return String(arg);
|
|
334
|
+
})
|
|
335
|
+
.join(' ');
|
|
336
|
+
const entry = {
|
|
337
|
+
level,
|
|
338
|
+
message: message.substring(0, 5000), // Limit message length
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
};
|
|
341
|
+
// Capture stack for errors
|
|
342
|
+
if (level === 'error') {
|
|
343
|
+
const stack = new Error().stack;
|
|
344
|
+
if (stack) {
|
|
345
|
+
entry.stack = stack.split('\n').slice(3).join('\n');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.entries.push(entry);
|
|
349
|
+
// Keep only recent entries
|
|
350
|
+
if (this.entries.length > this.maxEntries) {
|
|
351
|
+
this.entries.shift();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
getEntries() {
|
|
355
|
+
return [...this.entries];
|
|
356
|
+
}
|
|
357
|
+
clear() {
|
|
358
|
+
this.entries = [];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// ==================== Network Capture ====================
|
|
362
|
+
class NetworkCapture {
|
|
363
|
+
constructor(maxEntries = 100) {
|
|
364
|
+
this.entries = [];
|
|
365
|
+
this.isCapturing = false;
|
|
366
|
+
this.maxEntries = maxEntries;
|
|
367
|
+
}
|
|
368
|
+
start() {
|
|
369
|
+
if (this.isCapturing)
|
|
370
|
+
return;
|
|
371
|
+
this.isCapturing = true;
|
|
372
|
+
// Intercept fetch - bind to window to avoid "Illegal invocation"
|
|
373
|
+
this.originalFetch = window.fetch.bind(window);
|
|
374
|
+
const self = this;
|
|
375
|
+
window.fetch = async function (input, init) {
|
|
376
|
+
const startTime = performance.now();
|
|
377
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
378
|
+
const method = init?.method || 'GET';
|
|
379
|
+
try {
|
|
380
|
+
const response = await self.originalFetch(input, init);
|
|
381
|
+
const duration = performance.now() - startTime;
|
|
382
|
+
self.addEntry({
|
|
383
|
+
url,
|
|
384
|
+
method,
|
|
385
|
+
status: response.status,
|
|
386
|
+
statusText: response.statusText,
|
|
387
|
+
duration: Math.round(duration),
|
|
388
|
+
type: response.headers.get('content-type') || undefined,
|
|
389
|
+
timestamp: new Date().toISOString(),
|
|
390
|
+
});
|
|
391
|
+
return response;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
const duration = performance.now() - startTime;
|
|
395
|
+
self.addEntry({
|
|
396
|
+
url,
|
|
397
|
+
method,
|
|
398
|
+
duration: Math.round(duration),
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
error: error.message,
|
|
401
|
+
});
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
// Intercept XMLHttpRequest
|
|
406
|
+
this.originalXHROpen = XMLHttpRequest.prototype.open;
|
|
407
|
+
this.originalXHRSend = XMLHttpRequest.prototype.send;
|
|
408
|
+
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
409
|
+
this._checkflow = {
|
|
410
|
+
method,
|
|
411
|
+
url: url.toString(),
|
|
412
|
+
startTime: 0,
|
|
413
|
+
};
|
|
414
|
+
return self.originalXHROpen.apply(this, [method, url, ...rest]);
|
|
415
|
+
};
|
|
416
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
417
|
+
const xhr = this;
|
|
418
|
+
const meta = xhr._checkflow;
|
|
419
|
+
if (meta) {
|
|
420
|
+
meta.startTime = performance.now();
|
|
421
|
+
xhr.addEventListener('loadend', () => {
|
|
422
|
+
const duration = performance.now() - meta.startTime;
|
|
423
|
+
self.addEntry({
|
|
424
|
+
url: meta.url,
|
|
425
|
+
method: meta.method,
|
|
426
|
+
status: xhr.status,
|
|
427
|
+
statusText: xhr.statusText,
|
|
428
|
+
duration: Math.round(duration),
|
|
429
|
+
type: xhr.getResponseHeader('content-type') || undefined,
|
|
430
|
+
timestamp: new Date().toISOString(),
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
xhr.addEventListener('error', () => {
|
|
434
|
+
const duration = performance.now() - meta.startTime;
|
|
435
|
+
self.addEntry({
|
|
436
|
+
url: meta.url,
|
|
437
|
+
method: meta.method,
|
|
438
|
+
duration: Math.round(duration),
|
|
439
|
+
timestamp: new Date().toISOString(),
|
|
440
|
+
error: 'Network error',
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return self.originalXHRSend.apply(this, [body]);
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
stop() {
|
|
448
|
+
if (!this.isCapturing)
|
|
449
|
+
return;
|
|
450
|
+
this.isCapturing = false;
|
|
451
|
+
if (this.originalFetch) {
|
|
452
|
+
window.fetch = this.originalFetch;
|
|
453
|
+
}
|
|
454
|
+
if (this.originalXHROpen) {
|
|
455
|
+
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
|
456
|
+
}
|
|
457
|
+
if (this.originalXHRSend) {
|
|
458
|
+
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
addEntry(entry) {
|
|
462
|
+
// Filter out CheckFlow API calls
|
|
463
|
+
if (entry.url.includes('checkflow') || entry.url.includes('/api/v1/capture')) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.entries.push(entry);
|
|
467
|
+
if (this.entries.length > this.maxEntries) {
|
|
468
|
+
this.entries.shift();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
getEntries() {
|
|
472
|
+
return [...this.entries];
|
|
473
|
+
}
|
|
474
|
+
clear() {
|
|
475
|
+
this.entries = [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// ==================== Performance Capture ====================
|
|
479
|
+
function capturePerformance() {
|
|
480
|
+
const metrics = {};
|
|
481
|
+
try {
|
|
482
|
+
// Navigation timing
|
|
483
|
+
const navTiming = performance.getEntriesByType('navigation')[0];
|
|
484
|
+
if (navTiming) {
|
|
485
|
+
metrics.loadTime = Math.round(navTiming.loadEventEnd - navTiming.fetchStart);
|
|
486
|
+
metrics.domContentLoaded = Math.round(navTiming.domContentLoadedEventEnd - navTiming.fetchStart);
|
|
487
|
+
}
|
|
488
|
+
// Paint timing
|
|
489
|
+
const paintEntries = performance.getEntriesByType('paint');
|
|
490
|
+
const fcp = paintEntries.find((e) => e.name === 'first-contentful-paint');
|
|
491
|
+
if (fcp) {
|
|
492
|
+
metrics.firstContentfulPaint = Math.round(fcp.startTime);
|
|
493
|
+
}
|
|
494
|
+
// LCP (if available via PerformanceObserver)
|
|
495
|
+
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
|
|
496
|
+
if (lcpEntries.length > 0) {
|
|
497
|
+
metrics.largestContentfulPaint = Math.round(lcpEntries[lcpEntries.length - 1].startTime);
|
|
498
|
+
}
|
|
499
|
+
// Memory (Chrome only)
|
|
500
|
+
if (performance.memory) {
|
|
501
|
+
metrics.memoryUsage = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
|
|
502
|
+
}
|
|
503
|
+
// CLS (approximation from layout shift entries)
|
|
504
|
+
const layoutShiftEntries = performance.getEntriesByType('layout-shift');
|
|
505
|
+
if (layoutShiftEntries.length > 0) {
|
|
506
|
+
metrics.cumulativeLayoutShift = layoutShiftEntries.reduce((sum, entry) => sum + (entry.hadRecentInput ? 0 : entry.value), 0);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
// Performance API not fully available
|
|
511
|
+
}
|
|
512
|
+
return metrics;
|
|
513
|
+
}
|
|
514
|
+
// ==================== Page Context ====================
|
|
515
|
+
function capturePageContext() {
|
|
516
|
+
const ua = navigator.userAgent;
|
|
517
|
+
// Parse browser info
|
|
518
|
+
let browserName = 'Unknown';
|
|
519
|
+
let browserVersion = '';
|
|
520
|
+
if (ua.includes('Firefox/')) {
|
|
521
|
+
browserName = 'Firefox';
|
|
522
|
+
browserVersion = ua.match(/Firefox\/(\d+\.\d+)/)?.[1] || '';
|
|
523
|
+
}
|
|
524
|
+
else if (ua.includes('Edg/')) {
|
|
525
|
+
browserName = 'Edge';
|
|
526
|
+
browserVersion = ua.match(/Edg\/(\d+\.\d+)/)?.[1] || '';
|
|
527
|
+
}
|
|
528
|
+
else if (ua.includes('Chrome/')) {
|
|
529
|
+
browserName = 'Chrome';
|
|
530
|
+
browserVersion = ua.match(/Chrome\/(\d+\.\d+)/)?.[1] || '';
|
|
531
|
+
}
|
|
532
|
+
else if (ua.includes('Safari/') && !ua.includes('Chrome')) {
|
|
533
|
+
browserName = 'Safari';
|
|
534
|
+
browserVersion = ua.match(/Version\/(\d+\.\d+)/)?.[1] || '';
|
|
535
|
+
}
|
|
536
|
+
// Parse OS info
|
|
537
|
+
let osName = 'Unknown';
|
|
538
|
+
let osVersion = '';
|
|
539
|
+
if (ua.includes('Windows NT')) {
|
|
540
|
+
osName = 'Windows';
|
|
541
|
+
const ntVersion = ua.match(/Windows NT (\d+\.\d+)/)?.[1];
|
|
542
|
+
if (ntVersion === '10.0')
|
|
543
|
+
osVersion = '10/11';
|
|
544
|
+
else if (ntVersion === '6.3')
|
|
545
|
+
osVersion = '8.1';
|
|
546
|
+
else if (ntVersion === '6.2')
|
|
547
|
+
osVersion = '8';
|
|
548
|
+
else if (ntVersion === '6.1')
|
|
549
|
+
osVersion = '7';
|
|
550
|
+
}
|
|
551
|
+
else if (ua.includes('Mac OS X')) {
|
|
552
|
+
osName = 'macOS';
|
|
553
|
+
osVersion = ua.match(/Mac OS X (\d+[._]\d+)/)?.[1]?.replace('_', '.') || '';
|
|
554
|
+
}
|
|
555
|
+
else if (ua.includes('Linux')) {
|
|
556
|
+
osName = 'Linux';
|
|
557
|
+
}
|
|
558
|
+
else if (ua.includes('Android')) {
|
|
559
|
+
osName = 'Android';
|
|
560
|
+
osVersion = ua.match(/Android (\d+\.\d+)/)?.[1] || '';
|
|
561
|
+
}
|
|
562
|
+
else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) {
|
|
563
|
+
osName = 'iOS';
|
|
564
|
+
osVersion = ua.match(/OS (\d+_\d+)/)?.[1]?.replace('_', '.') || '';
|
|
565
|
+
}
|
|
566
|
+
// Detect device type
|
|
567
|
+
let deviceType = 'desktop';
|
|
568
|
+
if (/Mobi|Android/i.test(ua)) {
|
|
569
|
+
deviceType = 'mobile';
|
|
570
|
+
}
|
|
571
|
+
else if (/Tablet|iPad/i.test(ua)) {
|
|
572
|
+
deviceType = 'tablet';
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
url: window.location.href,
|
|
576
|
+
title: document.title,
|
|
577
|
+
viewport: {
|
|
578
|
+
width: window.innerWidth,
|
|
579
|
+
height: window.innerHeight,
|
|
580
|
+
},
|
|
581
|
+
userAgent: ua,
|
|
582
|
+
browser: {
|
|
583
|
+
name: browserName,
|
|
584
|
+
version: browserVersion,
|
|
585
|
+
},
|
|
586
|
+
os: {
|
|
587
|
+
name: osName,
|
|
588
|
+
version: osVersion,
|
|
589
|
+
},
|
|
590
|
+
deviceType,
|
|
591
|
+
screenResolution: {
|
|
592
|
+
width: window.screen.width,
|
|
593
|
+
height: window.screen.height,
|
|
594
|
+
},
|
|
595
|
+
pixelRatio: window.devicePixelRatio || 1,
|
|
596
|
+
language: navigator.language,
|
|
597
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
598
|
+
referrer: document.referrer || undefined,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
// ==================== Screenshot Capture ====================
|
|
602
|
+
async function captureScreenshot(options = {}) {
|
|
603
|
+
try {
|
|
604
|
+
// Dynamic import of html2canvas
|
|
605
|
+
const html2canvas = (await import('html2canvas')).default;
|
|
606
|
+
// Hide elements if specified
|
|
607
|
+
const hiddenElements = [];
|
|
608
|
+
if (options.hideElements) {
|
|
609
|
+
options.hideElements.forEach((selector) => {
|
|
610
|
+
document.querySelectorAll(selector).forEach((el) => {
|
|
611
|
+
hiddenElements.push({ element: el, display: el.style.display });
|
|
612
|
+
el.style.display = 'none';
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
// Mask elements if specified
|
|
617
|
+
const maskedElements = [];
|
|
618
|
+
if (options.maskElements) {
|
|
619
|
+
options.maskElements.forEach((selector) => {
|
|
620
|
+
document.querySelectorAll(selector).forEach((el) => {
|
|
621
|
+
maskedElements.push({ element: el, innerHTML: el.innerHTML });
|
|
622
|
+
el.innerHTML = '••••••••';
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
// Wait for delay if specified
|
|
627
|
+
if (options.delay) {
|
|
628
|
+
await new Promise((resolve) => setTimeout(resolve, options.delay));
|
|
629
|
+
}
|
|
630
|
+
// Capture
|
|
631
|
+
const canvas = await html2canvas(document.body, {
|
|
632
|
+
useCORS: true,
|
|
633
|
+
allowTaint: true,
|
|
634
|
+
logging: false,
|
|
635
|
+
scale: Math.min(window.devicePixelRatio || 1, 2),
|
|
636
|
+
windowWidth: options.fullPage ? document.documentElement.scrollWidth : window.innerWidth,
|
|
637
|
+
windowHeight: options.fullPage ? document.documentElement.scrollHeight : window.innerHeight,
|
|
638
|
+
});
|
|
639
|
+
// Restore hidden elements
|
|
640
|
+
hiddenElements.forEach(({ element, display }) => {
|
|
641
|
+
element.style.display = display;
|
|
642
|
+
});
|
|
643
|
+
// Restore masked elements
|
|
644
|
+
maskedElements.forEach(({ element, innerHTML }) => {
|
|
645
|
+
element.innerHTML = innerHTML;
|
|
646
|
+
});
|
|
647
|
+
// Convert to base64
|
|
648
|
+
const quality = (options.quality || 80) / 100;
|
|
649
|
+
return canvas.toDataURL('image/png', quality);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
console.error('[CheckFlow] Screenshot capture failed:', error);
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// ==================== Main Capture Class ====================
|
|
657
|
+
class ContextCapture {
|
|
658
|
+
constructor(options = {}) {
|
|
659
|
+
this.isCapturing = false;
|
|
660
|
+
this.consoleCapture = new ConsoleCapture(options.maxConsoleEntries || 100);
|
|
661
|
+
this.networkCapture = new NetworkCapture(options.maxNetworkEntries || 100);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Start capturing console and network logs
|
|
665
|
+
*/
|
|
666
|
+
startCapture() {
|
|
667
|
+
if (this.isCapturing)
|
|
668
|
+
return;
|
|
669
|
+
this.isCapturing = true;
|
|
670
|
+
this.consoleCapture.start();
|
|
671
|
+
this.networkCapture.start();
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Stop capturing
|
|
675
|
+
*/
|
|
676
|
+
stopCapture() {
|
|
677
|
+
if (!this.isCapturing)
|
|
678
|
+
return;
|
|
679
|
+
this.isCapturing = false;
|
|
680
|
+
this.consoleCapture.stop();
|
|
681
|
+
this.networkCapture.stop();
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Capture current page context with optional screenshot
|
|
685
|
+
*/
|
|
686
|
+
async capture(options = {}) {
|
|
687
|
+
const result = {
|
|
688
|
+
context: capturePageContext(),
|
|
689
|
+
capturedAt: new Date().toISOString(),
|
|
690
|
+
};
|
|
691
|
+
// Capture screenshot
|
|
692
|
+
if (options.includeConsole !== false) {
|
|
693
|
+
const screenshot = await captureScreenshot(options);
|
|
694
|
+
if (screenshot) {
|
|
695
|
+
result.screenshot = screenshot;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// Include console logs
|
|
699
|
+
if (options.includeConsole !== false) {
|
|
700
|
+
result.consoleLogs = this.consoleCapture.getEntries();
|
|
701
|
+
}
|
|
702
|
+
// Include network logs
|
|
703
|
+
if (options.includeNetwork !== false) {
|
|
704
|
+
result.networkLogs = this.networkCapture.getEntries();
|
|
705
|
+
}
|
|
706
|
+
// Include performance metrics
|
|
707
|
+
if (options.includePerformance !== false) {
|
|
708
|
+
result.performance = capturePerformance();
|
|
709
|
+
}
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Get current console entries
|
|
714
|
+
*/
|
|
715
|
+
getConsoleLogs() {
|
|
716
|
+
return this.consoleCapture.getEntries();
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Get current network entries
|
|
720
|
+
*/
|
|
721
|
+
getNetworkLogs() {
|
|
722
|
+
return this.networkCapture.getEntries();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Clear all captured data
|
|
726
|
+
*/
|
|
727
|
+
clear() {
|
|
728
|
+
this.consoleCapture.clear();
|
|
729
|
+
this.networkCapture.clear();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Error Capture Module
|
|
735
|
+
* Automatic error capture and reporting
|
|
736
|
+
*/
|
|
737
|
+
class ErrorCapture {
|
|
738
|
+
constructor(handler) {
|
|
739
|
+
this.isCapturing = false;
|
|
740
|
+
this.capturedErrors = [];
|
|
741
|
+
this.maxErrors = 50;
|
|
742
|
+
this.handler = handler;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Start capturing errors
|
|
746
|
+
*/
|
|
747
|
+
start() {
|
|
748
|
+
if (this.isCapturing)
|
|
749
|
+
return;
|
|
750
|
+
this.isCapturing = true;
|
|
751
|
+
// Capture window.onerror
|
|
752
|
+
this.originalOnError = window.onerror;
|
|
753
|
+
window.onerror = (message, filename, lineno, colno, error) => {
|
|
754
|
+
this.captureError({
|
|
755
|
+
message: typeof message === 'string' ? message : message.type,
|
|
756
|
+
stack: error?.stack,
|
|
757
|
+
type: error?.name || 'Error',
|
|
758
|
+
filename,
|
|
759
|
+
lineno,
|
|
760
|
+
colno,
|
|
761
|
+
});
|
|
762
|
+
// Call original handler
|
|
763
|
+
if (this.originalOnError) {
|
|
764
|
+
return this.originalOnError(message, filename, lineno, colno, error);
|
|
765
|
+
}
|
|
766
|
+
return false;
|
|
767
|
+
};
|
|
768
|
+
// Capture unhandled promise rejections
|
|
769
|
+
this.originalOnUnhandledRejection = window.onunhandledrejection;
|
|
770
|
+
window.onunhandledrejection = (event) => {
|
|
771
|
+
const reason = event.reason;
|
|
772
|
+
this.captureError({
|
|
773
|
+
message: reason?.message || String(reason),
|
|
774
|
+
stack: reason?.stack,
|
|
775
|
+
type: 'UnhandledPromiseRejection',
|
|
776
|
+
});
|
|
777
|
+
// Call original handler
|
|
778
|
+
if (this.originalOnUnhandledRejection) {
|
|
779
|
+
return this.originalOnUnhandledRejection(event);
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Stop capturing errors
|
|
785
|
+
*/
|
|
786
|
+
stop() {
|
|
787
|
+
if (!this.isCapturing)
|
|
788
|
+
return;
|
|
789
|
+
this.isCapturing = false;
|
|
790
|
+
window.onerror = this.originalOnError || null;
|
|
791
|
+
window.onunhandledrejection = this.originalOnUnhandledRejection || null;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Set error handler
|
|
795
|
+
*/
|
|
796
|
+
setHandler(handler) {
|
|
797
|
+
this.handler = handler;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Manually capture an error
|
|
801
|
+
*/
|
|
802
|
+
captureError(error) {
|
|
803
|
+
const errorInfo = {
|
|
804
|
+
message: error.message || 'Unknown error',
|
|
805
|
+
stack: error.stack,
|
|
806
|
+
type: error.type || 'Error',
|
|
807
|
+
filename: error.filename,
|
|
808
|
+
lineno: error.lineno,
|
|
809
|
+
colno: error.colno,
|
|
810
|
+
timestamp: new Date().toISOString(),
|
|
811
|
+
context: capturePageContext(),
|
|
812
|
+
};
|
|
813
|
+
// Store error
|
|
814
|
+
this.capturedErrors.push(errorInfo);
|
|
815
|
+
if (this.capturedErrors.length > this.maxErrors) {
|
|
816
|
+
this.capturedErrors.shift();
|
|
817
|
+
}
|
|
818
|
+
// Call handler
|
|
819
|
+
if (this.handler) {
|
|
820
|
+
try {
|
|
821
|
+
this.handler(errorInfo);
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
console.error('[CheckFlow] Error handler failed:', e);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Capture an exception (try/catch style)
|
|
830
|
+
*/
|
|
831
|
+
captureException(error, context) {
|
|
832
|
+
this.captureError({
|
|
833
|
+
message: error.message,
|
|
834
|
+
stack: error.stack,
|
|
835
|
+
type: error.name,
|
|
836
|
+
context: context ? { ...capturePageContext(), ...context } : undefined,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Get captured errors
|
|
841
|
+
*/
|
|
842
|
+
getErrors() {
|
|
843
|
+
return [...this.capturedErrors];
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Clear captured errors
|
|
847
|
+
*/
|
|
848
|
+
clear() {
|
|
849
|
+
this.capturedErrors = [];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// Note: The actual React ErrorBoundary component is in react/ErrorBoundary.tsx
|
|
853
|
+
// This file provides the core error capture logic that can be used by any framework
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* CheckFlow SDK Types
|
|
857
|
+
*/
|
|
858
|
+
const DEFAULT_TRANSLATIONS = {
|
|
859
|
+
feedbackButton: 'Feedback',
|
|
860
|
+
titleLabel: 'Title',
|
|
861
|
+
titlePlaceholder: 'Brief summary of the issue or suggestion',
|
|
862
|
+
descriptionLabel: 'Description',
|
|
863
|
+
descriptionPlaceholder: 'Provide more details...',
|
|
864
|
+
typeLabel: 'Type',
|
|
865
|
+
priorityLabel: 'Priority',
|
|
866
|
+
typeBug: 'Bug',
|
|
867
|
+
typeFeature: 'Feature Request',
|
|
868
|
+
typeImprovement: 'Improvement',
|
|
869
|
+
typeQuestion: 'Question',
|
|
870
|
+
typeOther: 'Other',
|
|
871
|
+
priorityLow: 'Low',
|
|
872
|
+
priorityMedium: 'Medium',
|
|
873
|
+
priorityHigh: 'High',
|
|
874
|
+
priorityCritical: 'Critical',
|
|
875
|
+
submitButton: 'Submit Feedback',
|
|
876
|
+
cancelButton: 'Cancel',
|
|
877
|
+
captureButton: 'Capture Screenshot',
|
|
878
|
+
retakeButton: 'Retake',
|
|
879
|
+
submitting: 'Submitting...',
|
|
880
|
+
submitSuccess: 'Thank you for your feedback!',
|
|
881
|
+
submitError: 'Failed to submit. Please try again.',
|
|
882
|
+
captureSuccess: 'Screenshot captured',
|
|
883
|
+
captureError: 'Failed to capture screenshot',
|
|
884
|
+
screenshotLabel: 'Screenshot',
|
|
885
|
+
includeScreenshot: 'Include screenshot',
|
|
886
|
+
annotateScreenshot: 'Click to annotate',
|
|
887
|
+
includeConsole: 'Include console logs',
|
|
888
|
+
includeNetwork: 'Include network logs',
|
|
889
|
+
};
|
|
890
|
+
const TRANSLATIONS = {
|
|
891
|
+
en: DEFAULT_TRANSLATIONS,
|
|
892
|
+
fr: {
|
|
893
|
+
feedbackButton: 'Feedback',
|
|
894
|
+
titleLabel: 'Titre',
|
|
895
|
+
titlePlaceholder: 'Résumé bref du problème ou de la suggestion',
|
|
896
|
+
descriptionLabel: 'Description',
|
|
897
|
+
descriptionPlaceholder: 'Fournir plus de détails...',
|
|
898
|
+
typeLabel: 'Type',
|
|
899
|
+
priorityLabel: 'Priorité',
|
|
900
|
+
typeBug: 'Bug',
|
|
901
|
+
typeFeature: 'Demande de fonctionnalité',
|
|
902
|
+
typeImprovement: 'Amélioration',
|
|
903
|
+
typeQuestion: 'Question',
|
|
904
|
+
typeOther: 'Autre',
|
|
905
|
+
priorityLow: 'Faible',
|
|
906
|
+
priorityMedium: 'Moyenne',
|
|
907
|
+
priorityHigh: 'Haute',
|
|
908
|
+
priorityCritical: 'Critique',
|
|
909
|
+
submitButton: 'Envoyer',
|
|
910
|
+
cancelButton: 'Annuler',
|
|
911
|
+
captureButton: 'Capturer l\'écran',
|
|
912
|
+
retakeButton: 'Reprendre',
|
|
913
|
+
submitting: 'Envoi en cours...',
|
|
914
|
+
submitSuccess: 'Merci pour votre feedback!',
|
|
915
|
+
submitError: 'Échec de l\'envoi. Veuillez réessayer.',
|
|
916
|
+
captureSuccess: 'Capture d\'écran effectuée',
|
|
917
|
+
captureError: 'Échec de la capture d\'écran',
|
|
918
|
+
screenshotLabel: 'Capture d\'écran',
|
|
919
|
+
includeScreenshot: 'Inclure une capture d\'écran',
|
|
920
|
+
annotateScreenshot: 'Cliquez pour annoter',
|
|
921
|
+
includeConsole: 'Inclure les logs console',
|
|
922
|
+
includeNetwork: 'Inclure les logs réseau',
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* CheckFlow Annotation System Types
|
|
928
|
+
* Figma-like annotation tools for feedback screenshots
|
|
929
|
+
*/
|
|
930
|
+
const DEFAULT_STYLE = {
|
|
931
|
+
strokeColor: '#FF3B30',
|
|
932
|
+
strokeWidth: 3,
|
|
933
|
+
fillColor: 'transparent',
|
|
934
|
+
opacity: 1,
|
|
935
|
+
fontSize: 16,
|
|
936
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
937
|
+
};
|
|
938
|
+
const HIGHLIGHT_STYLE = {
|
|
939
|
+
strokeColor: 'transparent',
|
|
940
|
+
fillColor: '#FFEB3B',
|
|
941
|
+
opacity: 0.4,
|
|
942
|
+
};
|
|
943
|
+
const BLUR_STYLE = {
|
|
944
|
+
strokeColor: '#666666',
|
|
945
|
+
fillColor: '#666666',
|
|
946
|
+
opacity: 0.8,
|
|
947
|
+
};
|
|
948
|
+
const COLOR_PALETTE = [
|
|
949
|
+
'#FF3B30', // Red
|
|
950
|
+
'#FF9500', // Orange
|
|
951
|
+
'#FFCC00', // Yellow
|
|
952
|
+
'#34C759', // Green
|
|
953
|
+
'#007AFF', // Blue
|
|
954
|
+
'#5856D6', // Purple
|
|
955
|
+
'#AF52DE', // Pink
|
|
956
|
+
'#000000', // Black
|
|
957
|
+
'#FFFFFF', // White
|
|
958
|
+
];
|
|
959
|
+
const STROKE_WIDTHS = [1, 2, 3, 5, 8];
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* CheckFlow Annotation Toolbar
|
|
963
|
+
* Figma-like floating toolbar for annotation tools
|
|
964
|
+
*/
|
|
965
|
+
const TOOL_ICONS = {
|
|
966
|
+
select: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>`,
|
|
967
|
+
rectangle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
|
|
968
|
+
ellipse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="9" ry="6"/></svg>`,
|
|
969
|
+
arrow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`,
|
|
970
|
+
line: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/></svg>`,
|
|
971
|
+
highlight: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`,
|
|
972
|
+
blur: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 13c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-8c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm-3 .5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zM6 5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm15 5.5c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zM14 7c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm0-3.5c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zm-11 10c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm7 7c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm0-17c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zM10 7c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm0 5.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm8 .5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-8c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm3 8.5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zM14 17c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 3.5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm-4-12c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0 8.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm4-4.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-4c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z"/></svg>`,
|
|
973
|
+
text: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 4v3h5.5v12h3V7H19V4z"/></svg>`,
|
|
974
|
+
freehand: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>`,
|
|
975
|
+
};
|
|
976
|
+
const TOOL_LABELS = {
|
|
977
|
+
select: 'Sélection',
|
|
978
|
+
rectangle: 'Rectangle',
|
|
979
|
+
ellipse: 'Ellipse',
|
|
980
|
+
arrow: 'Flèche',
|
|
981
|
+
line: 'Ligne',
|
|
982
|
+
highlight: 'Surbrillance',
|
|
983
|
+
blur: 'Floutage',
|
|
984
|
+
text: 'Texte',
|
|
985
|
+
freehand: 'Dessin libre',
|
|
986
|
+
};
|
|
987
|
+
class AnnotationToolbar {
|
|
988
|
+
constructor(config) {
|
|
989
|
+
this.colorPicker = null;
|
|
990
|
+
this.strokePicker = null;
|
|
991
|
+
this.config = config;
|
|
992
|
+
this.element = this.createToolbar();
|
|
993
|
+
}
|
|
994
|
+
getElement() {
|
|
995
|
+
return this.element;
|
|
996
|
+
}
|
|
997
|
+
setActiveTool(tool) {
|
|
998
|
+
this.config.activeTool = tool;
|
|
999
|
+
this.updateActiveState();
|
|
1000
|
+
}
|
|
1001
|
+
setStyle(style) {
|
|
1002
|
+
this.config.style = style;
|
|
1003
|
+
this.updateStyleDisplay();
|
|
1004
|
+
}
|
|
1005
|
+
createToolbar() {
|
|
1006
|
+
const toolbar = document.createElement('div');
|
|
1007
|
+
toolbar.className = 'cf-toolbar';
|
|
1008
|
+
// Tools section
|
|
1009
|
+
const toolsSection = document.createElement('div');
|
|
1010
|
+
toolsSection.className = 'cf-toolbar-section cf-toolbar-tools';
|
|
1011
|
+
for (const tool of this.config.tools) {
|
|
1012
|
+
const button = this.createToolButton(tool);
|
|
1013
|
+
toolsSection.appendChild(button);
|
|
1014
|
+
}
|
|
1015
|
+
// Separator
|
|
1016
|
+
const separator1 = document.createElement('div');
|
|
1017
|
+
separator1.className = 'cf-toolbar-separator';
|
|
1018
|
+
// Style section
|
|
1019
|
+
const styleSection = document.createElement('div');
|
|
1020
|
+
styleSection.className = 'cf-toolbar-section cf-toolbar-style';
|
|
1021
|
+
// Color picker button
|
|
1022
|
+
const colorBtn = document.createElement('button');
|
|
1023
|
+
colorBtn.className = 'cf-toolbar-btn cf-color-btn';
|
|
1024
|
+
colorBtn.title = 'Couleur';
|
|
1025
|
+
colorBtn.innerHTML = `<span class="cf-color-preview" style="background: ${this.config.style.strokeColor}"></span>`;
|
|
1026
|
+
colorBtn.addEventListener('click', () => this.toggleColorPicker());
|
|
1027
|
+
styleSection.appendChild(colorBtn);
|
|
1028
|
+
// Stroke width button
|
|
1029
|
+
const strokeBtn = document.createElement('button');
|
|
1030
|
+
strokeBtn.className = 'cf-toolbar-btn cf-stroke-btn';
|
|
1031
|
+
strokeBtn.title = 'Épaisseur';
|
|
1032
|
+
strokeBtn.innerHTML = `<span class="cf-stroke-preview">${this.config.style.strokeWidth}px</span>`;
|
|
1033
|
+
strokeBtn.addEventListener('click', () => this.toggleStrokePicker());
|
|
1034
|
+
styleSection.appendChild(strokeBtn);
|
|
1035
|
+
// Separator
|
|
1036
|
+
const separator2 = document.createElement('div');
|
|
1037
|
+
separator2.className = 'cf-toolbar-separator';
|
|
1038
|
+
// Actions section
|
|
1039
|
+
const actionsSection = document.createElement('div');
|
|
1040
|
+
actionsSection.className = 'cf-toolbar-section cf-toolbar-actions';
|
|
1041
|
+
// Undo button
|
|
1042
|
+
const undoBtn = document.createElement('button');
|
|
1043
|
+
undoBtn.className = 'cf-toolbar-btn';
|
|
1044
|
+
undoBtn.title = 'Annuler (Ctrl+Z)';
|
|
1045
|
+
undoBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>`;
|
|
1046
|
+
undoBtn.addEventListener('click', () => this.config.onUndo());
|
|
1047
|
+
actionsSection.appendChild(undoBtn);
|
|
1048
|
+
// Clear button
|
|
1049
|
+
const clearBtn = document.createElement('button');
|
|
1050
|
+
clearBtn.className = 'cf-toolbar-btn';
|
|
1051
|
+
clearBtn.title = 'Tout effacer';
|
|
1052
|
+
clearBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`;
|
|
1053
|
+
clearBtn.addEventListener('click', () => this.config.onClear());
|
|
1054
|
+
actionsSection.appendChild(clearBtn);
|
|
1055
|
+
// Assemble toolbar
|
|
1056
|
+
toolbar.appendChild(toolsSection);
|
|
1057
|
+
toolbar.appendChild(separator1);
|
|
1058
|
+
toolbar.appendChild(styleSection);
|
|
1059
|
+
toolbar.appendChild(separator2);
|
|
1060
|
+
toolbar.appendChild(actionsSection);
|
|
1061
|
+
return toolbar;
|
|
1062
|
+
}
|
|
1063
|
+
createToolButton(tool) {
|
|
1064
|
+
const button = document.createElement('button');
|
|
1065
|
+
button.className = `cf-toolbar-btn cf-tool-btn ${tool === this.config.activeTool ? 'active' : ''}`;
|
|
1066
|
+
button.dataset.tool = tool;
|
|
1067
|
+
button.title = TOOL_LABELS[tool];
|
|
1068
|
+
button.innerHTML = TOOL_ICONS[tool];
|
|
1069
|
+
button.addEventListener('click', () => {
|
|
1070
|
+
this.config.onToolChange(tool);
|
|
1071
|
+
this.setActiveTool(tool);
|
|
1072
|
+
});
|
|
1073
|
+
return button;
|
|
1074
|
+
}
|
|
1075
|
+
updateActiveState() {
|
|
1076
|
+
const buttons = this.element.querySelectorAll('.cf-tool-btn');
|
|
1077
|
+
buttons.forEach((btn) => {
|
|
1078
|
+
const button = btn;
|
|
1079
|
+
button.classList.toggle('active', button.dataset.tool === this.config.activeTool);
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
updateStyleDisplay() {
|
|
1083
|
+
const colorPreview = this.element.querySelector('.cf-color-preview');
|
|
1084
|
+
if (colorPreview) {
|
|
1085
|
+
colorPreview.style.background = this.config.style.strokeColor;
|
|
1086
|
+
}
|
|
1087
|
+
const strokePreview = this.element.querySelector('.cf-stroke-preview');
|
|
1088
|
+
if (strokePreview) {
|
|
1089
|
+
strokePreview.textContent = `${this.config.style.strokeWidth}px`;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
toggleColorPicker() {
|
|
1093
|
+
if (this.colorPicker) {
|
|
1094
|
+
this.colorPicker.remove();
|
|
1095
|
+
this.colorPicker = null;
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
// Close stroke picker if open
|
|
1099
|
+
if (this.strokePicker) {
|
|
1100
|
+
this.strokePicker.remove();
|
|
1101
|
+
this.strokePicker = null;
|
|
1102
|
+
}
|
|
1103
|
+
this.colorPicker = document.createElement('div');
|
|
1104
|
+
this.colorPicker.className = 'cf-picker cf-color-picker';
|
|
1105
|
+
for (const color of COLOR_PALETTE) {
|
|
1106
|
+
const swatch = document.createElement('button');
|
|
1107
|
+
swatch.className = `cf-color-swatch ${color === this.config.style.strokeColor ? 'active' : ''}`;
|
|
1108
|
+
swatch.style.background = color;
|
|
1109
|
+
swatch.addEventListener('click', () => {
|
|
1110
|
+
this.config.onStyleChange({ strokeColor: color });
|
|
1111
|
+
this.setStyle({ ...this.config.style, strokeColor: color });
|
|
1112
|
+
this.colorPicker?.remove();
|
|
1113
|
+
this.colorPicker = null;
|
|
1114
|
+
});
|
|
1115
|
+
this.colorPicker.appendChild(swatch);
|
|
1116
|
+
}
|
|
1117
|
+
const colorBtn = this.element.querySelector('.cf-color-btn');
|
|
1118
|
+
colorBtn?.appendChild(this.colorPicker);
|
|
1119
|
+
}
|
|
1120
|
+
toggleStrokePicker() {
|
|
1121
|
+
if (this.strokePicker) {
|
|
1122
|
+
this.strokePicker.remove();
|
|
1123
|
+
this.strokePicker = null;
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
// Close color picker if open
|
|
1127
|
+
if (this.colorPicker) {
|
|
1128
|
+
this.colorPicker.remove();
|
|
1129
|
+
this.colorPicker = null;
|
|
1130
|
+
}
|
|
1131
|
+
this.strokePicker = document.createElement('div');
|
|
1132
|
+
this.strokePicker.className = 'cf-picker cf-stroke-picker';
|
|
1133
|
+
for (const width of STROKE_WIDTHS) {
|
|
1134
|
+
const option = document.createElement('button');
|
|
1135
|
+
option.className = `cf-stroke-option ${width === this.config.style.strokeWidth ? 'active' : ''}`;
|
|
1136
|
+
option.innerHTML = `<span style="height: ${width}px"></span> ${width}px`;
|
|
1137
|
+
option.addEventListener('click', () => {
|
|
1138
|
+
this.config.onStyleChange({ strokeWidth: width });
|
|
1139
|
+
this.setStyle({ ...this.config.style, strokeWidth: width });
|
|
1140
|
+
this.strokePicker?.remove();
|
|
1141
|
+
this.strokePicker = null;
|
|
1142
|
+
});
|
|
1143
|
+
this.strokePicker.appendChild(option);
|
|
1144
|
+
}
|
|
1145
|
+
const strokeBtn = this.element.querySelector('.cf-stroke-btn');
|
|
1146
|
+
strokeBtn?.appendChild(this.strokePicker);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* CheckFlow Annotation Styles
|
|
1152
|
+
* CSS-in-JS styles for the annotation editor
|
|
1153
|
+
*/
|
|
1154
|
+
const STYLES = `
|
|
1155
|
+
/* Annotation Overlay */
|
|
1156
|
+
.cf-annotation-overlay {
|
|
1157
|
+
position: fixed;
|
|
1158
|
+
top: 0;
|
|
1159
|
+
left: 0;
|
|
1160
|
+
right: 0;
|
|
1161
|
+
bottom: 0;
|
|
1162
|
+
z-index: 999999;
|
|
1163
|
+
background: rgba(0, 0, 0, 0.85);
|
|
1164
|
+
display: flex;
|
|
1165
|
+
align-items: center;
|
|
1166
|
+
justify-content: center;
|
|
1167
|
+
animation: cf-fade-in 0.2s ease-out;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
@keyframes cf-fade-in {
|
|
1171
|
+
from { opacity: 0; }
|
|
1172
|
+
to { opacity: 1; }
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/* Editor Wrapper */
|
|
1176
|
+
.cf-annotation-wrapper {
|
|
1177
|
+
display: flex;
|
|
1178
|
+
flex-direction: column;
|
|
1179
|
+
max-width: 95vw;
|
|
1180
|
+
max-height: 95vh;
|
|
1181
|
+
background: #1a1a1a;
|
|
1182
|
+
border-radius: 12px;
|
|
1183
|
+
overflow: hidden;
|
|
1184
|
+
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/* Canvas Container */
|
|
1188
|
+
.cf-annotation-canvas-container {
|
|
1189
|
+
flex: 1;
|
|
1190
|
+
display: flex;
|
|
1191
|
+
align-items: center;
|
|
1192
|
+
justify-content: center;
|
|
1193
|
+
overflow: auto;
|
|
1194
|
+
padding: 16px;
|
|
1195
|
+
background: #0d0d0d;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.cf-annotation-canvas {
|
|
1199
|
+
max-width: 100%;
|
|
1200
|
+
max-height: calc(95vh - 140px);
|
|
1201
|
+
border-radius: 4px;
|
|
1202
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
1203
|
+
cursor: crosshair;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/* Toolbar */
|
|
1207
|
+
.cf-toolbar {
|
|
1208
|
+
display: flex;
|
|
1209
|
+
align-items: center;
|
|
1210
|
+
gap: 8px;
|
|
1211
|
+
padding: 12px 16px;
|
|
1212
|
+
background: #2a2a2a;
|
|
1213
|
+
border-bottom: 1px solid #3a3a3a;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
.cf-toolbar-section {
|
|
1217
|
+
display: flex;
|
|
1218
|
+
align-items: center;
|
|
1219
|
+
gap: 4px;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
.cf-toolbar-separator {
|
|
1223
|
+
width: 1px;
|
|
1224
|
+
height: 24px;
|
|
1225
|
+
background: #4a4a4a;
|
|
1226
|
+
margin: 0 8px;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
.cf-toolbar-btn {
|
|
1230
|
+
display: flex;
|
|
1231
|
+
align-items: center;
|
|
1232
|
+
justify-content: center;
|
|
1233
|
+
width: 36px;
|
|
1234
|
+
height: 36px;
|
|
1235
|
+
padding: 0;
|
|
1236
|
+
border: none;
|
|
1237
|
+
border-radius: 8px;
|
|
1238
|
+
background: transparent;
|
|
1239
|
+
color: #999;
|
|
1240
|
+
cursor: pointer;
|
|
1241
|
+
transition: all 0.15s ease;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.cf-toolbar-btn:hover {
|
|
1245
|
+
background: #3a3a3a;
|
|
1246
|
+
color: #fff;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
.cf-toolbar-btn.active {
|
|
1250
|
+
background: #007AFF;
|
|
1251
|
+
color: #fff;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
.cf-toolbar-btn svg {
|
|
1255
|
+
width: 20px;
|
|
1256
|
+
height: 20px;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/* Color Button */
|
|
1260
|
+
.cf-color-btn {
|
|
1261
|
+
position: relative;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
.cf-color-preview {
|
|
1265
|
+
width: 20px;
|
|
1266
|
+
height: 20px;
|
|
1267
|
+
border-radius: 50%;
|
|
1268
|
+
border: 2px solid #fff;
|
|
1269
|
+
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/* Stroke Button */
|
|
1273
|
+
.cf-stroke-btn {
|
|
1274
|
+
width: auto;
|
|
1275
|
+
padding: 0 12px;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
.cf-stroke-preview {
|
|
1279
|
+
font-size: 12px;
|
|
1280
|
+
font-weight: 500;
|
|
1281
|
+
color: inherit;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/* Pickers */
|
|
1285
|
+
.cf-picker {
|
|
1286
|
+
position: absolute;
|
|
1287
|
+
top: 100%;
|
|
1288
|
+
left: 50%;
|
|
1289
|
+
transform: translateX(-50%);
|
|
1290
|
+
margin-top: 8px;
|
|
1291
|
+
padding: 8px;
|
|
1292
|
+
background: #2a2a2a;
|
|
1293
|
+
border-radius: 8px;
|
|
1294
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
1295
|
+
z-index: 10;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
.cf-color-picker {
|
|
1299
|
+
display: grid;
|
|
1300
|
+
grid-template-columns: repeat(5, 1fr);
|
|
1301
|
+
gap: 4px;
|
|
1302
|
+
width: 140px;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
.cf-color-swatch {
|
|
1306
|
+
width: 24px;
|
|
1307
|
+
height: 24px;
|
|
1308
|
+
border-radius: 50%;
|
|
1309
|
+
border: 2px solid transparent;
|
|
1310
|
+
cursor: pointer;
|
|
1311
|
+
transition: transform 0.15s ease;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.cf-color-swatch:hover {
|
|
1315
|
+
transform: scale(1.15);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
.cf-color-swatch.active {
|
|
1319
|
+
border-color: #fff;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
.cf-stroke-picker {
|
|
1323
|
+
display: flex;
|
|
1324
|
+
flex-direction: column;
|
|
1325
|
+
gap: 4px;
|
|
1326
|
+
min-width: 80px;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
.cf-stroke-option {
|
|
1330
|
+
display: flex;
|
|
1331
|
+
align-items: center;
|
|
1332
|
+
gap: 8px;
|
|
1333
|
+
padding: 6px 10px;
|
|
1334
|
+
border: none;
|
|
1335
|
+
border-radius: 4px;
|
|
1336
|
+
background: transparent;
|
|
1337
|
+
color: #999;
|
|
1338
|
+
font-size: 12px;
|
|
1339
|
+
cursor: pointer;
|
|
1340
|
+
transition: all 0.15s ease;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
.cf-stroke-option:hover {
|
|
1344
|
+
background: #3a3a3a;
|
|
1345
|
+
color: #fff;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
.cf-stroke-option.active {
|
|
1349
|
+
background: #007AFF;
|
|
1350
|
+
color: #fff;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
.cf-stroke-option span {
|
|
1354
|
+
width: 24px;
|
|
1355
|
+
background: currentColor;
|
|
1356
|
+
border-radius: 2px;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/* Action Buttons */
|
|
1360
|
+
.cf-annotation-actions {
|
|
1361
|
+
display: flex;
|
|
1362
|
+
justify-content: flex-end;
|
|
1363
|
+
gap: 12px;
|
|
1364
|
+
padding: 12px 16px;
|
|
1365
|
+
background: #2a2a2a;
|
|
1366
|
+
border-top: 1px solid #3a3a3a;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
.cf-btn {
|
|
1370
|
+
display: flex;
|
|
1371
|
+
align-items: center;
|
|
1372
|
+
justify-content: center;
|
|
1373
|
+
padding: 10px 20px;
|
|
1374
|
+
border: none;
|
|
1375
|
+
border-radius: 8px;
|
|
1376
|
+
font-size: 14px;
|
|
1377
|
+
font-weight: 500;
|
|
1378
|
+
cursor: pointer;
|
|
1379
|
+
transition: all 0.15s ease;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
.cf-btn-primary {
|
|
1383
|
+
background: #007AFF;
|
|
1384
|
+
color: #fff;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
.cf-btn-primary:hover {
|
|
1388
|
+
background: #0066DD;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.cf-btn-secondary {
|
|
1392
|
+
background: #3a3a3a;
|
|
1393
|
+
color: #fff;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
.cf-btn-secondary:hover {
|
|
1397
|
+
background: #4a4a4a;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/* Text Input */
|
|
1401
|
+
.cf-annotation-text-input {
|
|
1402
|
+
position: fixed;
|
|
1403
|
+
z-index: 1000000;
|
|
1404
|
+
padding: 4px 8px;
|
|
1405
|
+
border: 2px solid #007AFF;
|
|
1406
|
+
border-radius: 4px;
|
|
1407
|
+
background: rgba(255, 255, 255, 0.95);
|
|
1408
|
+
font-size: 16px;
|
|
1409
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
1410
|
+
outline: none;
|
|
1411
|
+
min-width: 150px;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/* Tool-specific cursors */
|
|
1415
|
+
.cf-annotation-canvas[data-tool="select"] { cursor: default; }
|
|
1416
|
+
.cf-annotation-canvas[data-tool="rectangle"] { cursor: crosshair; }
|
|
1417
|
+
.cf-annotation-canvas[data-tool="ellipse"] { cursor: crosshair; }
|
|
1418
|
+
.cf-annotation-canvas[data-tool="arrow"] { cursor: crosshair; }
|
|
1419
|
+
.cf-annotation-canvas[data-tool="line"] { cursor: crosshair; }
|
|
1420
|
+
.cf-annotation-canvas[data-tool="highlight"] { cursor: crosshair; }
|
|
1421
|
+
.cf-annotation-canvas[data-tool="blur"] { cursor: crosshair; }
|
|
1422
|
+
.cf-annotation-canvas[data-tool="text"] { cursor: text; }
|
|
1423
|
+
.cf-annotation-canvas[data-tool="freehand"] { cursor: crosshair; }
|
|
1424
|
+
|
|
1425
|
+
/* Responsive */
|
|
1426
|
+
@media (max-width: 768px) {
|
|
1427
|
+
.cf-toolbar {
|
|
1428
|
+
flex-wrap: wrap;
|
|
1429
|
+
justify-content: center;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
.cf-toolbar-separator {
|
|
1433
|
+
display: none;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
.cf-annotation-actions {
|
|
1437
|
+
justify-content: stretch;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
.cf-annotation-actions .cf-btn {
|
|
1441
|
+
flex: 1;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
`;
|
|
1445
|
+
let stylesInjected = false;
|
|
1446
|
+
function injectAnnotationStyles() {
|
|
1447
|
+
if (stylesInjected)
|
|
1448
|
+
return;
|
|
1449
|
+
const styleElement = document.createElement('style');
|
|
1450
|
+
styleElement.id = 'cf-annotation-styles';
|
|
1451
|
+
styleElement.textContent = STYLES;
|
|
1452
|
+
document.head.appendChild(styleElement);
|
|
1453
|
+
stylesInjected = true;
|
|
1454
|
+
}
|
|
1455
|
+
function removeAnnotationStyles() {
|
|
1456
|
+
const styleElement = document.getElementById('cf-annotation-styles');
|
|
1457
|
+
if (styleElement) {
|
|
1458
|
+
styleElement.remove();
|
|
1459
|
+
stylesInjected = false;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* CheckFlow Annotation Editor
|
|
1465
|
+
* Figma-like canvas editor for annotating screenshots
|
|
1466
|
+
*/
|
|
1467
|
+
class AnnotationEditor {
|
|
1468
|
+
constructor(config) {
|
|
1469
|
+
this.container = null;
|
|
1470
|
+
this.canvas = null;
|
|
1471
|
+
this.ctx = null;
|
|
1472
|
+
this.toolbar = null;
|
|
1473
|
+
this.annotations = [];
|
|
1474
|
+
this.backgroundImage = null;
|
|
1475
|
+
this.state = {
|
|
1476
|
+
activeTool: 'rectangle',
|
|
1477
|
+
style: { ...DEFAULT_STYLE },
|
|
1478
|
+
isDrawing: false,
|
|
1479
|
+
currentAnnotation: null,
|
|
1480
|
+
};
|
|
1481
|
+
this.startPoint = null;
|
|
1482
|
+
this.freehandPoints = [];
|
|
1483
|
+
this.textInput = null;
|
|
1484
|
+
this.config = {
|
|
1485
|
+
...config,
|
|
1486
|
+
tools: config.tools || ['select', 'rectangle', 'arrow', 'highlight', 'blur', 'text', 'freehand'],
|
|
1487
|
+
defaultStyle: config.defaultStyle || DEFAULT_STYLE,
|
|
1488
|
+
};
|
|
1489
|
+
this.state.style = { ...this.config.defaultStyle };
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Open the annotation editor with a screenshot
|
|
1493
|
+
*/
|
|
1494
|
+
async open(screenshotDataUrl) {
|
|
1495
|
+
injectAnnotationStyles();
|
|
1496
|
+
// Load the background image
|
|
1497
|
+
this.backgroundImage = await this.loadImage(screenshotDataUrl);
|
|
1498
|
+
// Create the editor UI
|
|
1499
|
+
this.createEditorUI();
|
|
1500
|
+
// Setup event listeners
|
|
1501
|
+
this.setupEventListeners();
|
|
1502
|
+
// Initial render
|
|
1503
|
+
this.render();
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Close the editor
|
|
1507
|
+
*/
|
|
1508
|
+
close() {
|
|
1509
|
+
if (this.container) {
|
|
1510
|
+
this.container.remove();
|
|
1511
|
+
this.container = null;
|
|
1512
|
+
}
|
|
1513
|
+
this.canvas = null;
|
|
1514
|
+
this.ctx = null;
|
|
1515
|
+
this.toolbar = null;
|
|
1516
|
+
this.annotations = [];
|
|
1517
|
+
this.backgroundImage = null;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Get the annotated image as data URL
|
|
1521
|
+
*/
|
|
1522
|
+
getAnnotatedImage() {
|
|
1523
|
+
if (!this.canvas || !this.ctx)
|
|
1524
|
+
return '';
|
|
1525
|
+
// Create a temporary canvas to flatten the image
|
|
1526
|
+
const tempCanvas = document.createElement('canvas');
|
|
1527
|
+
tempCanvas.width = this.canvas.width;
|
|
1528
|
+
tempCanvas.height = this.canvas.height;
|
|
1529
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
1530
|
+
// Draw background
|
|
1531
|
+
if (this.backgroundImage) {
|
|
1532
|
+
tempCtx.drawImage(this.backgroundImage, 0, 0);
|
|
1533
|
+
}
|
|
1534
|
+
// Draw all annotations
|
|
1535
|
+
this.drawAnnotations(tempCtx);
|
|
1536
|
+
return tempCanvas.toDataURL('image/png');
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Get annotations data
|
|
1540
|
+
*/
|
|
1541
|
+
getAnnotations() {
|
|
1542
|
+
return [...this.annotations];
|
|
1543
|
+
}
|
|
1544
|
+
// Private methods
|
|
1545
|
+
loadImage(src) {
|
|
1546
|
+
return new Promise((resolve, reject) => {
|
|
1547
|
+
const img = new Image();
|
|
1548
|
+
img.onload = () => resolve(img);
|
|
1549
|
+
img.onerror = reject;
|
|
1550
|
+
img.src = src;
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
createEditorUI() {
|
|
1554
|
+
// Create overlay container
|
|
1555
|
+
this.container = document.createElement('div');
|
|
1556
|
+
this.container.className = 'cf-annotation-overlay';
|
|
1557
|
+
// Create editor wrapper
|
|
1558
|
+
const wrapper = document.createElement('div');
|
|
1559
|
+
wrapper.className = 'cf-annotation-wrapper';
|
|
1560
|
+
// Create canvas container
|
|
1561
|
+
const canvasContainer = document.createElement('div');
|
|
1562
|
+
canvasContainer.className = 'cf-annotation-canvas-container';
|
|
1563
|
+
// Create canvas
|
|
1564
|
+
this.canvas = document.createElement('canvas');
|
|
1565
|
+
this.canvas.className = 'cf-annotation-canvas';
|
|
1566
|
+
this.canvas.width = this.backgroundImage?.width || 1920;
|
|
1567
|
+
this.canvas.height = this.backgroundImage?.height || 1080;
|
|
1568
|
+
this.ctx = this.canvas.getContext('2d');
|
|
1569
|
+
canvasContainer.appendChild(this.canvas);
|
|
1570
|
+
// Create toolbar
|
|
1571
|
+
this.toolbar = new AnnotationToolbar({
|
|
1572
|
+
tools: this.config.tools,
|
|
1573
|
+
activeTool: this.state.activeTool,
|
|
1574
|
+
style: this.state.style,
|
|
1575
|
+
onToolChange: (tool) => this.setActiveTool(tool),
|
|
1576
|
+
onStyleChange: (style) => this.setStyle(style),
|
|
1577
|
+
onUndo: () => this.undo(),
|
|
1578
|
+
onClear: () => this.clearAll(),
|
|
1579
|
+
onSave: () => this.save(),
|
|
1580
|
+
onCancel: () => this.cancel(),
|
|
1581
|
+
});
|
|
1582
|
+
// Create action buttons
|
|
1583
|
+
const actions = document.createElement('div');
|
|
1584
|
+
actions.className = 'cf-annotation-actions';
|
|
1585
|
+
actions.innerHTML = `
|
|
1586
|
+
<button class="cf-btn cf-btn-secondary" data-action="cancel">Annuler</button>
|
|
1587
|
+
<button class="cf-btn cf-btn-primary" data-action="save">Enregistrer</button>
|
|
1588
|
+
`;
|
|
1589
|
+
// Assemble UI
|
|
1590
|
+
wrapper.appendChild(this.toolbar.getElement());
|
|
1591
|
+
wrapper.appendChild(canvasContainer);
|
|
1592
|
+
wrapper.appendChild(actions);
|
|
1593
|
+
this.container.appendChild(wrapper);
|
|
1594
|
+
document.body.appendChild(this.container);
|
|
1595
|
+
// Bind action buttons
|
|
1596
|
+
actions.querySelector('[data-action="cancel"]')?.addEventListener('click', () => this.cancel());
|
|
1597
|
+
actions.querySelector('[data-action="save"]')?.addEventListener('click', () => this.save());
|
|
1598
|
+
}
|
|
1599
|
+
setupEventListeners() {
|
|
1600
|
+
if (!this.canvas)
|
|
1601
|
+
return;
|
|
1602
|
+
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
|
1603
|
+
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
1604
|
+
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
|
1605
|
+
this.canvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
|
1606
|
+
// Touch support
|
|
1607
|
+
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
|
1608
|
+
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
|
|
1609
|
+
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
|
1610
|
+
// Keyboard shortcuts
|
|
1611
|
+
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
|
1612
|
+
}
|
|
1613
|
+
handleMouseDown(e) {
|
|
1614
|
+
const point = this.getCanvasPoint(e);
|
|
1615
|
+
this.startDrawing(point);
|
|
1616
|
+
}
|
|
1617
|
+
handleMouseMove(e) {
|
|
1618
|
+
const point = this.getCanvasPoint(e);
|
|
1619
|
+
this.continueDrawing(point);
|
|
1620
|
+
}
|
|
1621
|
+
handleMouseUp(_e) {
|
|
1622
|
+
this.finishDrawing();
|
|
1623
|
+
}
|
|
1624
|
+
handleTouchStart(e) {
|
|
1625
|
+
e.preventDefault();
|
|
1626
|
+
const touch = e.touches[0];
|
|
1627
|
+
const point = this.getCanvasPointFromTouch(touch);
|
|
1628
|
+
this.startDrawing(point);
|
|
1629
|
+
}
|
|
1630
|
+
handleTouchMove(e) {
|
|
1631
|
+
e.preventDefault();
|
|
1632
|
+
const touch = e.touches[0];
|
|
1633
|
+
const point = this.getCanvasPointFromTouch(touch);
|
|
1634
|
+
this.continueDrawing(point);
|
|
1635
|
+
}
|
|
1636
|
+
handleTouchEnd(e) {
|
|
1637
|
+
e.preventDefault();
|
|
1638
|
+
this.finishDrawing();
|
|
1639
|
+
}
|
|
1640
|
+
handleKeyDown(e) {
|
|
1641
|
+
// Undo: Ctrl+Z / Cmd+Z
|
|
1642
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
1643
|
+
e.preventDefault();
|
|
1644
|
+
this.undo();
|
|
1645
|
+
}
|
|
1646
|
+
// Escape: Cancel
|
|
1647
|
+
if (e.key === 'Escape') {
|
|
1648
|
+
if (this.state.isDrawing) {
|
|
1649
|
+
this.state.isDrawing = false;
|
|
1650
|
+
this.state.currentAnnotation = null;
|
|
1651
|
+
this.render();
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
this.cancel();
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
// Enter: Save (when not drawing)
|
|
1658
|
+
if (e.key === 'Enter' && !this.state.isDrawing) {
|
|
1659
|
+
this.save();
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
getCanvasPoint(e) {
|
|
1663
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1664
|
+
const scaleX = this.canvas.width / rect.width;
|
|
1665
|
+
const scaleY = this.canvas.height / rect.height;
|
|
1666
|
+
return {
|
|
1667
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
1668
|
+
y: (e.clientY - rect.top) * scaleY,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
getCanvasPointFromTouch(touch) {
|
|
1672
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1673
|
+
const scaleX = this.canvas.width / rect.width;
|
|
1674
|
+
const scaleY = this.canvas.height / rect.height;
|
|
1675
|
+
return {
|
|
1676
|
+
x: (touch.clientX - rect.left) * scaleX,
|
|
1677
|
+
y: (touch.clientY - rect.top) * scaleY,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
startDrawing(point) {
|
|
1681
|
+
if (this.state.activeTool === 'select')
|
|
1682
|
+
return;
|
|
1683
|
+
this.state.isDrawing = true;
|
|
1684
|
+
this.startPoint = point;
|
|
1685
|
+
if (this.state.activeTool === 'text') {
|
|
1686
|
+
this.showTextInput(point);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (this.state.activeTool === 'freehand') {
|
|
1690
|
+
this.freehandPoints = [point];
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
continueDrawing(point) {
|
|
1694
|
+
if (!this.state.isDrawing || !this.startPoint)
|
|
1695
|
+
return;
|
|
1696
|
+
if (this.state.activeTool === 'freehand') {
|
|
1697
|
+
this.freehandPoints.push(point);
|
|
1698
|
+
}
|
|
1699
|
+
// Create preview annotation
|
|
1700
|
+
this.state.currentAnnotation = this.createAnnotation(this.startPoint, point);
|
|
1701
|
+
this.render();
|
|
1702
|
+
}
|
|
1703
|
+
finishDrawing() {
|
|
1704
|
+
if (!this.state.isDrawing || !this.state.currentAnnotation) {
|
|
1705
|
+
this.state.isDrawing = false;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
// Don't add empty annotations
|
|
1709
|
+
if (this.isValidAnnotation(this.state.currentAnnotation)) {
|
|
1710
|
+
this.annotations.push(this.state.currentAnnotation);
|
|
1711
|
+
}
|
|
1712
|
+
this.state.isDrawing = false;
|
|
1713
|
+
this.state.currentAnnotation = null;
|
|
1714
|
+
this.startPoint = null;
|
|
1715
|
+
this.freehandPoints = [];
|
|
1716
|
+
this.render();
|
|
1717
|
+
}
|
|
1718
|
+
isValidAnnotation(annotation) {
|
|
1719
|
+
switch (annotation.type) {
|
|
1720
|
+
case 'rectangle':
|
|
1721
|
+
case 'ellipse':
|
|
1722
|
+
case 'highlight':
|
|
1723
|
+
case 'blur':
|
|
1724
|
+
const bounds = annotation.bounds;
|
|
1725
|
+
return Math.abs(bounds.width) > 5 && Math.abs(bounds.height) > 5;
|
|
1726
|
+
case 'arrow':
|
|
1727
|
+
case 'line':
|
|
1728
|
+
const start = annotation.start;
|
|
1729
|
+
const end = annotation.end;
|
|
1730
|
+
const dist = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
|
1731
|
+
return dist > 10;
|
|
1732
|
+
case 'freehand':
|
|
1733
|
+
return annotation.points.length > 2;
|
|
1734
|
+
case 'text':
|
|
1735
|
+
return !!annotation.text?.trim();
|
|
1736
|
+
default:
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
createAnnotation(start, end) {
|
|
1741
|
+
const id = this.generateId();
|
|
1742
|
+
const baseStyle = { ...this.state.style };
|
|
1743
|
+
switch (this.state.activeTool) {
|
|
1744
|
+
case 'rectangle':
|
|
1745
|
+
return {
|
|
1746
|
+
id,
|
|
1747
|
+
type: 'rectangle',
|
|
1748
|
+
bounds: this.createBounds(start, end),
|
|
1749
|
+
style: baseStyle,
|
|
1750
|
+
timestamp: Date.now(),
|
|
1751
|
+
};
|
|
1752
|
+
case 'ellipse':
|
|
1753
|
+
return {
|
|
1754
|
+
id,
|
|
1755
|
+
type: 'ellipse',
|
|
1756
|
+
bounds: this.createBounds(start, end),
|
|
1757
|
+
style: baseStyle,
|
|
1758
|
+
timestamp: Date.now(),
|
|
1759
|
+
};
|
|
1760
|
+
case 'arrow':
|
|
1761
|
+
return {
|
|
1762
|
+
id,
|
|
1763
|
+
type: 'arrow',
|
|
1764
|
+
start: { ...start },
|
|
1765
|
+
end: { ...end },
|
|
1766
|
+
style: baseStyle,
|
|
1767
|
+
timestamp: Date.now(),
|
|
1768
|
+
};
|
|
1769
|
+
case 'line':
|
|
1770
|
+
return {
|
|
1771
|
+
id,
|
|
1772
|
+
type: 'line',
|
|
1773
|
+
start: { ...start },
|
|
1774
|
+
end: { ...end },
|
|
1775
|
+
style: baseStyle,
|
|
1776
|
+
timestamp: Date.now(),
|
|
1777
|
+
};
|
|
1778
|
+
case 'highlight':
|
|
1779
|
+
return {
|
|
1780
|
+
id,
|
|
1781
|
+
type: 'highlight',
|
|
1782
|
+
bounds: this.createBounds(start, end),
|
|
1783
|
+
style: { ...baseStyle, ...HIGHLIGHT_STYLE },
|
|
1784
|
+
timestamp: Date.now(),
|
|
1785
|
+
};
|
|
1786
|
+
case 'blur':
|
|
1787
|
+
return {
|
|
1788
|
+
id,
|
|
1789
|
+
type: 'blur',
|
|
1790
|
+
bounds: this.createBounds(start, end),
|
|
1791
|
+
style: { ...baseStyle, ...BLUR_STYLE },
|
|
1792
|
+
blurAmount: 10,
|
|
1793
|
+
timestamp: Date.now(),
|
|
1794
|
+
};
|
|
1795
|
+
case 'freehand':
|
|
1796
|
+
return {
|
|
1797
|
+
id,
|
|
1798
|
+
type: 'freehand',
|
|
1799
|
+
points: [...this.freehandPoints],
|
|
1800
|
+
style: baseStyle,
|
|
1801
|
+
timestamp: Date.now(),
|
|
1802
|
+
};
|
|
1803
|
+
default:
|
|
1804
|
+
return {
|
|
1805
|
+
id,
|
|
1806
|
+
type: 'rectangle',
|
|
1807
|
+
bounds: this.createBounds(start, end),
|
|
1808
|
+
style: baseStyle,
|
|
1809
|
+
timestamp: Date.now(),
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
createBounds(start, end) {
|
|
1814
|
+
return {
|
|
1815
|
+
x: Math.min(start.x, end.x),
|
|
1816
|
+
y: Math.min(start.y, end.y),
|
|
1817
|
+
width: Math.abs(end.x - start.x),
|
|
1818
|
+
height: Math.abs(end.y - start.y),
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
showTextInput(point) {
|
|
1822
|
+
if (this.textInput) {
|
|
1823
|
+
this.textInput.remove();
|
|
1824
|
+
}
|
|
1825
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1826
|
+
const scaleX = rect.width / this.canvas.width;
|
|
1827
|
+
const scaleY = rect.height / this.canvas.height;
|
|
1828
|
+
this.textInput = document.createElement('input');
|
|
1829
|
+
this.textInput.type = 'text';
|
|
1830
|
+
this.textInput.className = 'cf-annotation-text-input';
|
|
1831
|
+
this.textInput.style.left = `${rect.left + point.x * scaleX}px`;
|
|
1832
|
+
this.textInput.style.top = `${rect.top + point.y * scaleY}px`;
|
|
1833
|
+
this.textInput.style.color = this.state.style.strokeColor;
|
|
1834
|
+
this.textInput.style.fontSize = `${(this.state.style.fontSize || 16) * scaleY}px`;
|
|
1835
|
+
this.textInput.placeholder = 'Tapez votre texte...';
|
|
1836
|
+
document.body.appendChild(this.textInput);
|
|
1837
|
+
this.textInput.focus();
|
|
1838
|
+
const handleTextSubmit = () => {
|
|
1839
|
+
if (this.textInput && this.textInput.value.trim()) {
|
|
1840
|
+
const textAnnotation = {
|
|
1841
|
+
id: this.generateId(),
|
|
1842
|
+
type: 'text',
|
|
1843
|
+
position: point,
|
|
1844
|
+
text: this.textInput.value.trim(),
|
|
1845
|
+
style: { ...this.state.style },
|
|
1846
|
+
timestamp: Date.now(),
|
|
1847
|
+
};
|
|
1848
|
+
this.annotations.push(textAnnotation);
|
|
1849
|
+
this.render();
|
|
1850
|
+
}
|
|
1851
|
+
this.textInput?.remove();
|
|
1852
|
+
this.textInput = null;
|
|
1853
|
+
this.state.isDrawing = false;
|
|
1854
|
+
};
|
|
1855
|
+
this.textInput.addEventListener('blur', handleTextSubmit);
|
|
1856
|
+
this.textInput.addEventListener('keydown', (e) => {
|
|
1857
|
+
if (e.key === 'Enter') {
|
|
1858
|
+
handleTextSubmit();
|
|
1859
|
+
}
|
|
1860
|
+
else if (e.key === 'Escape') {
|
|
1861
|
+
this.textInput?.remove();
|
|
1862
|
+
this.textInput = null;
|
|
1863
|
+
this.state.isDrawing = false;
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
generateId() {
|
|
1868
|
+
return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
1869
|
+
}
|
|
1870
|
+
setActiveTool(tool) {
|
|
1871
|
+
this.state.activeTool = tool;
|
|
1872
|
+
this.toolbar?.setActiveTool(tool);
|
|
1873
|
+
// Update cursor
|
|
1874
|
+
if (this.canvas) {
|
|
1875
|
+
this.canvas.style.cursor = tool === 'select' ? 'default' : 'crosshair';
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
setStyle(style) {
|
|
1879
|
+
this.state.style = { ...this.state.style, ...style };
|
|
1880
|
+
this.toolbar?.setStyle(this.state.style);
|
|
1881
|
+
}
|
|
1882
|
+
undo() {
|
|
1883
|
+
if (this.annotations.length > 0) {
|
|
1884
|
+
this.annotations.pop();
|
|
1885
|
+
this.render();
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
clearAll() {
|
|
1889
|
+
this.annotations = [];
|
|
1890
|
+
this.render();
|
|
1891
|
+
}
|
|
1892
|
+
save() {
|
|
1893
|
+
const imageData = this.getAnnotatedImage();
|
|
1894
|
+
this.config.onSave?.(this.annotations, imageData);
|
|
1895
|
+
this.close();
|
|
1896
|
+
}
|
|
1897
|
+
cancel() {
|
|
1898
|
+
this.config.onCancel?.();
|
|
1899
|
+
this.close();
|
|
1900
|
+
}
|
|
1901
|
+
render() {
|
|
1902
|
+
if (!this.ctx || !this.canvas)
|
|
1903
|
+
return;
|
|
1904
|
+
// Clear canvas
|
|
1905
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
1906
|
+
// Draw background image
|
|
1907
|
+
if (this.backgroundImage) {
|
|
1908
|
+
this.ctx.drawImage(this.backgroundImage, 0, 0);
|
|
1909
|
+
}
|
|
1910
|
+
// Draw all saved annotations
|
|
1911
|
+
this.drawAnnotations(this.ctx);
|
|
1912
|
+
// Draw current annotation (preview)
|
|
1913
|
+
if (this.state.currentAnnotation) {
|
|
1914
|
+
this.drawAnnotation(this.ctx, this.state.currentAnnotation);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
drawAnnotations(ctx) {
|
|
1918
|
+
for (const annotation of this.annotations) {
|
|
1919
|
+
this.drawAnnotation(ctx, annotation);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
drawAnnotation(ctx, annotation) {
|
|
1923
|
+
ctx.save();
|
|
1924
|
+
ctx.globalAlpha = annotation.style.opacity;
|
|
1925
|
+
ctx.strokeStyle = annotation.style.strokeColor;
|
|
1926
|
+
ctx.fillStyle = annotation.style.fillColor;
|
|
1927
|
+
ctx.lineWidth = annotation.style.strokeWidth;
|
|
1928
|
+
ctx.lineCap = 'round';
|
|
1929
|
+
ctx.lineJoin = 'round';
|
|
1930
|
+
switch (annotation.type) {
|
|
1931
|
+
case 'rectangle':
|
|
1932
|
+
this.drawRectangle(ctx, annotation.bounds, annotation.style);
|
|
1933
|
+
break;
|
|
1934
|
+
case 'ellipse':
|
|
1935
|
+
this.drawEllipse(ctx, annotation.bounds, annotation.style);
|
|
1936
|
+
break;
|
|
1937
|
+
case 'arrow':
|
|
1938
|
+
this.drawArrow(ctx, annotation.start, annotation.end, annotation.style);
|
|
1939
|
+
break;
|
|
1940
|
+
case 'line':
|
|
1941
|
+
this.drawLine(ctx, annotation.start, annotation.end);
|
|
1942
|
+
break;
|
|
1943
|
+
case 'highlight':
|
|
1944
|
+
this.drawHighlight(ctx, annotation.bounds, annotation.style);
|
|
1945
|
+
break;
|
|
1946
|
+
case 'blur':
|
|
1947
|
+
this.drawBlur(ctx, annotation.bounds, annotation.blurAmount);
|
|
1948
|
+
break;
|
|
1949
|
+
case 'text':
|
|
1950
|
+
this.drawText(ctx, annotation.position, annotation.text, annotation.style);
|
|
1951
|
+
break;
|
|
1952
|
+
case 'freehand':
|
|
1953
|
+
this.drawFreehand(ctx, annotation.points);
|
|
1954
|
+
break;
|
|
1955
|
+
}
|
|
1956
|
+
ctx.restore();
|
|
1957
|
+
}
|
|
1958
|
+
drawRectangle(ctx, bounds, style) {
|
|
1959
|
+
if (style.fillColor !== 'transparent') {
|
|
1960
|
+
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
1961
|
+
}
|
|
1962
|
+
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
1963
|
+
}
|
|
1964
|
+
drawEllipse(ctx, bounds, style) {
|
|
1965
|
+
const centerX = bounds.x + bounds.width / 2;
|
|
1966
|
+
const centerY = bounds.y + bounds.height / 2;
|
|
1967
|
+
const radiusX = bounds.width / 2;
|
|
1968
|
+
const radiusY = bounds.height / 2;
|
|
1969
|
+
ctx.beginPath();
|
|
1970
|
+
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
|
|
1971
|
+
if (style.fillColor !== 'transparent') {
|
|
1972
|
+
ctx.fill();
|
|
1973
|
+
}
|
|
1974
|
+
ctx.stroke();
|
|
1975
|
+
}
|
|
1976
|
+
drawArrow(ctx, start, end, style) {
|
|
1977
|
+
const headLength = 15 + style.strokeWidth * 2;
|
|
1978
|
+
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
|
1979
|
+
// Draw line
|
|
1980
|
+
ctx.beginPath();
|
|
1981
|
+
ctx.moveTo(start.x, start.y);
|
|
1982
|
+
ctx.lineTo(end.x, end.y);
|
|
1983
|
+
ctx.stroke();
|
|
1984
|
+
// Draw arrowhead
|
|
1985
|
+
ctx.beginPath();
|
|
1986
|
+
ctx.moveTo(end.x, end.y);
|
|
1987
|
+
ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
|
|
1988
|
+
ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
|
|
1989
|
+
ctx.closePath();
|
|
1990
|
+
ctx.fillStyle = style.strokeColor;
|
|
1991
|
+
ctx.fill();
|
|
1992
|
+
}
|
|
1993
|
+
drawLine(ctx, start, end) {
|
|
1994
|
+
ctx.beginPath();
|
|
1995
|
+
ctx.moveTo(start.x, start.y);
|
|
1996
|
+
ctx.lineTo(end.x, end.y);
|
|
1997
|
+
ctx.stroke();
|
|
1998
|
+
}
|
|
1999
|
+
drawHighlight(ctx, bounds, style) {
|
|
2000
|
+
ctx.fillStyle = style.fillColor;
|
|
2001
|
+
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
2002
|
+
}
|
|
2003
|
+
drawBlur(ctx, bounds, blurAmount) {
|
|
2004
|
+
// For blur, we pixelate the area
|
|
2005
|
+
const pixelSize = Math.max(blurAmount, 5);
|
|
2006
|
+
// Get image data for the blur region
|
|
2007
|
+
if (this.backgroundImage) {
|
|
2008
|
+
const tempCanvas = document.createElement('canvas');
|
|
2009
|
+
tempCanvas.width = bounds.width;
|
|
2010
|
+
tempCanvas.height = bounds.height;
|
|
2011
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
2012
|
+
// Draw the region from background
|
|
2013
|
+
tempCtx.drawImage(this.backgroundImage, bounds.x, bounds.y, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
|
|
2014
|
+
// Pixelate
|
|
2015
|
+
const w = tempCanvas.width;
|
|
2016
|
+
const h = tempCanvas.height;
|
|
2017
|
+
// Scale down
|
|
2018
|
+
tempCtx.imageSmoothingEnabled = false;
|
|
2019
|
+
tempCtx.drawImage(tempCanvas, 0, 0, w, h, 0, 0, w / pixelSize, h / pixelSize);
|
|
2020
|
+
// Scale back up
|
|
2021
|
+
tempCtx.drawImage(tempCanvas, 0, 0, w / pixelSize, h / pixelSize, 0, 0, w, h);
|
|
2022
|
+
// Draw back to main canvas
|
|
2023
|
+
ctx.drawImage(tempCanvas, bounds.x, bounds.y);
|
|
2024
|
+
}
|
|
2025
|
+
// Draw border
|
|
2026
|
+
ctx.strokeStyle = '#999';
|
|
2027
|
+
ctx.lineWidth = 1;
|
|
2028
|
+
ctx.setLineDash([5, 5]);
|
|
2029
|
+
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
2030
|
+
ctx.setLineDash([]);
|
|
2031
|
+
}
|
|
2032
|
+
drawText(ctx, position, text, style) {
|
|
2033
|
+
ctx.font = `${style.fontSize || 16}px ${style.fontFamily || 'system-ui'}`;
|
|
2034
|
+
ctx.fillStyle = style.strokeColor;
|
|
2035
|
+
ctx.textBaseline = 'top';
|
|
2036
|
+
// Draw text background for readability
|
|
2037
|
+
const metrics = ctx.measureText(text);
|
|
2038
|
+
const padding = 4;
|
|
2039
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
|
2040
|
+
ctx.fillRect(position.x - padding, position.y - padding, metrics.width + padding * 2, (style.fontSize || 16) + padding * 2);
|
|
2041
|
+
// Draw text
|
|
2042
|
+
ctx.fillStyle = style.strokeColor;
|
|
2043
|
+
ctx.fillText(text, position.x, position.y);
|
|
2044
|
+
}
|
|
2045
|
+
drawFreehand(ctx, points) {
|
|
2046
|
+
if (points.length < 2)
|
|
2047
|
+
return;
|
|
2048
|
+
ctx.beginPath();
|
|
2049
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
2050
|
+
for (let i = 1; i < points.length; i++) {
|
|
2051
|
+
ctx.lineTo(points[i].x, points[i].y);
|
|
2052
|
+
}
|
|
2053
|
+
ctx.stroke();
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* CheckFlow Feedback Widget
|
|
2059
|
+
* Pure JavaScript widget that works without frameworks
|
|
2060
|
+
*/
|
|
2061
|
+
const ICONS = {
|
|
2062
|
+
feedback: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
|
|
2063
|
+
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
|
2064
|
+
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
|
2065
|
+
camera: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>`,
|
|
2066
|
+
annotate: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/></svg>`,
|
|
2067
|
+
// Material Design inspired SVG icons
|
|
2068
|
+
bug: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M20 8h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></svg>`,
|
|
2069
|
+
feature: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z"/></svg>`,
|
|
2070
|
+
improvement: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6A4.997 4.997 0 0 1 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"/></svg>`,
|
|
2071
|
+
question: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>`,
|
|
2072
|
+
other: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>`,
|
|
2073
|
+
};
|
|
2074
|
+
class FeedbackWidget {
|
|
2075
|
+
constructor(options = {}) {
|
|
2076
|
+
this.container = null;
|
|
2077
|
+
this.state = {
|
|
2078
|
+
isOpen: false,
|
|
2079
|
+
isMinimized: false,
|
|
2080
|
+
isCapturing: false,
|
|
2081
|
+
isSubmitting: false,
|
|
2082
|
+
};
|
|
2083
|
+
this.annotationEditor = null;
|
|
2084
|
+
this.currentAnnotations = [];
|
|
2085
|
+
this.options = {
|
|
2086
|
+
position: 'bottom-right',
|
|
2087
|
+
buttonText: 'Feedback',
|
|
2088
|
+
zIndex: 999999,
|
|
2089
|
+
...options,
|
|
2090
|
+
};
|
|
2091
|
+
// Setup translations
|
|
2092
|
+
const localeTranslations = TRANSLATIONS[options.locale || 'en'] || DEFAULT_TRANSLATIONS;
|
|
2093
|
+
this.translations = { ...localeTranslations, ...options.translations };
|
|
2094
|
+
this.capture = new ContextCapture();
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Mount the widget to the DOM
|
|
2098
|
+
*/
|
|
2099
|
+
mount() {
|
|
2100
|
+
if (this.container)
|
|
2101
|
+
return;
|
|
2102
|
+
// Create container
|
|
2103
|
+
this.container = document.createElement('div');
|
|
2104
|
+
this.container.className = 'checkflow-widget';
|
|
2105
|
+
this.container.innerHTML = this.renderTrigger();
|
|
2106
|
+
document.body.appendChild(this.container);
|
|
2107
|
+
// Bind events
|
|
2108
|
+
this.bindEvents();
|
|
2109
|
+
// Start capturing context
|
|
2110
|
+
this.capture.startCapture();
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Unmount the widget
|
|
2114
|
+
*/
|
|
2115
|
+
unmount() {
|
|
2116
|
+
if (this.container) {
|
|
2117
|
+
this.container.remove();
|
|
2118
|
+
this.container = null;
|
|
2119
|
+
}
|
|
2120
|
+
this.capture.stopCapture();
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Open the feedback modal
|
|
2124
|
+
*/
|
|
2125
|
+
open() {
|
|
2126
|
+
this.state.isOpen = true;
|
|
2127
|
+
this.render();
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Close the feedback modal
|
|
2131
|
+
*/
|
|
2132
|
+
close() {
|
|
2133
|
+
this.state.isOpen = false;
|
|
2134
|
+
this.state.captureResult = undefined;
|
|
2135
|
+
this.state.error = undefined;
|
|
2136
|
+
this.render();
|
|
2137
|
+
this.options.onClose?.();
|
|
2138
|
+
}
|
|
2139
|
+
renderTrigger() {
|
|
2140
|
+
const { position, buttonText, primaryColor } = this.options;
|
|
2141
|
+
const displayText = buttonText || 'Signaler un bug';
|
|
2142
|
+
// Apply custom color if provided
|
|
2143
|
+
const customStyle = primaryColor ? `style="background-color: ${primaryColor}; color: ${this.getContrastColor(primaryColor)};"` : '';
|
|
2144
|
+
return `
|
|
2145
|
+
<button class="checkflow-trigger checkflow-trigger-pill ${position}" id="cf-trigger" ${customStyle}>
|
|
2146
|
+
<span class="checkflow-trigger-icon">${ICONS.feedback}</span>
|
|
2147
|
+
<span class="checkflow-trigger-text">${displayText}</span>
|
|
2148
|
+
</button>
|
|
2149
|
+
`;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Calculate contrast color (white or black) based on background color
|
|
2153
|
+
*/
|
|
2154
|
+
getContrastColor(hexColor) {
|
|
2155
|
+
// Remove # if present
|
|
2156
|
+
const hex = hexColor.replace('#', '');
|
|
2157
|
+
// Convert to RGB
|
|
2158
|
+
const r = parseInt(hex.substr(0, 2), 16);
|
|
2159
|
+
const g = parseInt(hex.substr(2, 2), 16);
|
|
2160
|
+
const b = parseInt(hex.substr(4, 2), 16);
|
|
2161
|
+
// Calculate luminance using relative luminance formula
|
|
2162
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
2163
|
+
// Return black for light colors, white for dark colors
|
|
2164
|
+
return luminance > 0.5 ? '#000000' : '#ffffff';
|
|
2165
|
+
}
|
|
2166
|
+
renderModal() {
|
|
2167
|
+
const t = this.translations;
|
|
2168
|
+
const { captureResult, isSubmitting, error } = this.state;
|
|
2169
|
+
const { position } = this.options;
|
|
2170
|
+
const hasScreenshot = !!captureResult?.screenshot;
|
|
2171
|
+
// Mode expanded avec screenshot: fullscreen avec 2 colonnes au centre
|
|
2172
|
+
if (hasScreenshot) {
|
|
2173
|
+
return this.renderExpandedModal();
|
|
2174
|
+
}
|
|
2175
|
+
// Mode compact: panneau en bas à droite (pas centré)
|
|
2176
|
+
const panelPosition = position?.includes('left') ? 'bottom-left' : '';
|
|
2177
|
+
return `
|
|
2178
|
+
<div class="checkflow-panel ${panelPosition}" id="cf-panel">
|
|
2179
|
+
<div class="checkflow-header">
|
|
2180
|
+
<h3>Envoyer le rapport</h3>
|
|
2181
|
+
<button class="checkflow-close" id="cf-close">${ICONS.close}</button>
|
|
2182
|
+
</div>
|
|
2183
|
+
|
|
2184
|
+
<div class="checkflow-body">
|
|
2185
|
+
${error ? `<div class="checkflow-error">${error}</div>` : ''}
|
|
2186
|
+
|
|
2187
|
+
<form class="checkflow-form" id="cf-form">
|
|
2188
|
+
<!-- Type Selection -->
|
|
2189
|
+
<div class="checkflow-field">
|
|
2190
|
+
<label class="checkflow-label">Type de retour</label>
|
|
2191
|
+
<div class="checkflow-type-grid">
|
|
2192
|
+
${this.renderTypeOptions()}
|
|
2193
|
+
</div>
|
|
2194
|
+
</div>
|
|
2195
|
+
|
|
2196
|
+
<!-- Name -->
|
|
2197
|
+
<div class="checkflow-field">
|
|
2198
|
+
<label class="checkflow-label">Nom <span class="cf-required">(obligatoire)</span></label>
|
|
2199
|
+
<input type="text" class="checkflow-input" id="cf-name"
|
|
2200
|
+
placeholder="Votre nom" required>
|
|
2201
|
+
</div>
|
|
2202
|
+
|
|
2203
|
+
<!-- Email -->
|
|
2204
|
+
<div class="checkflow-field">
|
|
2205
|
+
<label class="checkflow-label">Email <span class="cf-required">(obligatoire)</span></label>
|
|
2206
|
+
<input type="email" class="checkflow-input" id="cf-email"
|
|
2207
|
+
placeholder="votre.email@exemple.com" required>
|
|
2208
|
+
</div>
|
|
2209
|
+
|
|
2210
|
+
<!-- Description -->
|
|
2211
|
+
<div class="checkflow-field">
|
|
2212
|
+
<label class="checkflow-label">Description <span class="cf-required">(obligatoire)</span></label>
|
|
2213
|
+
<textarea class="checkflow-textarea" id="cf-description"
|
|
2214
|
+
placeholder="Quel est le problème ? Que vous attendiez-vous à voir ?" required></textarea>
|
|
2215
|
+
</div>
|
|
2216
|
+
|
|
2217
|
+
<!-- Screenshot Button -->
|
|
2218
|
+
<div class="checkflow-field">
|
|
2219
|
+
${this.renderCaptureButton()}
|
|
2220
|
+
</div>
|
|
2221
|
+
|
|
2222
|
+
<!-- Options -->
|
|
2223
|
+
<div class="checkflow-options">
|
|
2224
|
+
<label class="checkflow-checkbox">
|
|
2225
|
+
<input type="checkbox" id="cf-console" checked>
|
|
2226
|
+
<span class="checkflow-checkbox-label">${t.includeConsole}</span>
|
|
2227
|
+
</label>
|
|
2228
|
+
<label class="checkflow-checkbox">
|
|
2229
|
+
<input type="checkbox" id="cf-network" checked>
|
|
2230
|
+
<span class="checkflow-checkbox-label">${t.includeNetwork}</span>
|
|
2231
|
+
</label>
|
|
2232
|
+
</div>
|
|
2233
|
+
</form>
|
|
2234
|
+
</div>
|
|
2235
|
+
|
|
2236
|
+
<div class="checkflow-footer">
|
|
2237
|
+
<button type="submit" form="cf-form" class="checkflow-btn checkflow-btn-primary"
|
|
2238
|
+
id="cf-submit" ${isSubmitting ? 'disabled' : ''} ${this.options.primaryColor ? `style="background-color: ${this.options.primaryColor}; color: ${this.getContrastColor(this.options.primaryColor)};"` : ''}>
|
|
2239
|
+
${isSubmitting ? 'Envoi en cours...' : 'Envoyer le rapport'}
|
|
2240
|
+
</button>
|
|
2241
|
+
<button type="button" class="checkflow-btn checkflow-btn-secondary" id="cf-cancel">
|
|
2242
|
+
Annuler
|
|
2243
|
+
</button>
|
|
2244
|
+
</div>
|
|
2245
|
+
<div class="checkflow-powered">Powered by CheckFlow</div>
|
|
2246
|
+
</div>
|
|
2247
|
+
`;
|
|
2248
|
+
}
|
|
2249
|
+
renderExpandedModal() {
|
|
2250
|
+
this.translations;
|
|
2251
|
+
const { captureResult, isSubmitting, error } = this.state;
|
|
2252
|
+
return `
|
|
2253
|
+
<div class="checkflow-overlay checkflow-expanded" id="cf-overlay">
|
|
2254
|
+
<div class="checkflow-modal-expanded">
|
|
2255
|
+
<div class="checkflow-header">
|
|
2256
|
+
<h3>Envoyer le rapport</h3>
|
|
2257
|
+
<button class="checkflow-close" id="cf-close">${ICONS.close}</button>
|
|
2258
|
+
</div>
|
|
2259
|
+
|
|
2260
|
+
<div class="checkflow-content-split">
|
|
2261
|
+
<!-- Left: Screenshot with annotation tools -->
|
|
2262
|
+
<div class="checkflow-screenshot-area">
|
|
2263
|
+
<div class="checkflow-screenshot-container">
|
|
2264
|
+
<img src="${captureResult?.screenshot}" alt="Capture d'écran" class="checkflow-screenshot-large">
|
|
2265
|
+
</div>
|
|
2266
|
+
<div class="checkflow-annotation-toolbar">
|
|
2267
|
+
<button type="button" class="checkflow-tool-btn checkflow-tool-highlight" id="cf-annotate">
|
|
2268
|
+
${ICONS.annotate}
|
|
2269
|
+
<span>Surligner</span>
|
|
2270
|
+
</button>
|
|
2271
|
+
<button type="button" class="checkflow-tool-btn" id="cf-retake">
|
|
2272
|
+
${ICONS.camera}
|
|
2273
|
+
<span>Reprendre</span>
|
|
2274
|
+
</button>
|
|
2275
|
+
</div>
|
|
2276
|
+
</div>
|
|
2277
|
+
|
|
2278
|
+
<!-- Right: Form -->
|
|
2279
|
+
<div class="checkflow-form-area">
|
|
2280
|
+
${error ? `<div class="checkflow-error">${error}</div>` : ''}
|
|
2281
|
+
|
|
2282
|
+
<form class="checkflow-form" id="cf-form">
|
|
2283
|
+
<!-- Type Selection -->
|
|
2284
|
+
<div class="checkflow-field">
|
|
2285
|
+
<label class="checkflow-label">Type de retour</label>
|
|
2286
|
+
<div class="checkflow-type-grid">
|
|
2287
|
+
${this.renderTypeOptions()}
|
|
2288
|
+
</div>
|
|
2289
|
+
</div>
|
|
2290
|
+
|
|
2291
|
+
<!-- Name -->
|
|
2292
|
+
<div class="checkflow-field">
|
|
2293
|
+
<label class="checkflow-label">Nom <span class="cf-required">(obligatoire)</span></label>
|
|
2294
|
+
<input type="text" class="checkflow-input" id="cf-name"
|
|
2295
|
+
placeholder="Votre nom" required>
|
|
2296
|
+
</div>
|
|
2297
|
+
|
|
2298
|
+
<!-- Email -->
|
|
2299
|
+
<div class="checkflow-field">
|
|
2300
|
+
<label class="checkflow-label">Email <span class="cf-required">(obligatoire)</span></label>
|
|
2301
|
+
<input type="email" class="checkflow-input" id="cf-email"
|
|
2302
|
+
placeholder="votre.email@exemple.com" required>
|
|
2303
|
+
</div>
|
|
2304
|
+
|
|
2305
|
+
<!-- Description -->
|
|
2306
|
+
<div class="checkflow-field">
|
|
2307
|
+
<label class="checkflow-label">Description <span class="cf-required">(obligatoire)</span></label>
|
|
2308
|
+
<textarea class="checkflow-textarea" id="cf-description"
|
|
2309
|
+
placeholder="Quel est le problème ? Que vous attendiez-vous à voir ?" required></textarea>
|
|
2310
|
+
</div>
|
|
2311
|
+
|
|
2312
|
+
<!-- Delete screenshot -->
|
|
2313
|
+
<button type="button" class="checkflow-btn-link" id="cf-delete-screenshot">
|
|
2314
|
+
Supprimer la capture d'écran
|
|
2315
|
+
</button>
|
|
2316
|
+
</form>
|
|
2317
|
+
|
|
2318
|
+
<div class="checkflow-footer-expanded">
|
|
2319
|
+
<button type="submit" form="cf-form" class="checkflow-btn checkflow-btn-primary"
|
|
2320
|
+
id="cf-submit" ${isSubmitting ? 'disabled' : ''} ${this.options.primaryColor ? `style="background-color: ${this.options.primaryColor}; color: ${this.getContrastColor(this.options.primaryColor)};"` : ''}>
|
|
2321
|
+
${isSubmitting ? 'Envoi en cours...' : 'Envoyer le rapport'}
|
|
2322
|
+
</button>
|
|
2323
|
+
<button type="button" class="checkflow-btn checkflow-btn-secondary" id="cf-cancel">
|
|
2324
|
+
Annuler
|
|
2325
|
+
</button>
|
|
2326
|
+
</div>
|
|
2327
|
+
</div>
|
|
2328
|
+
</div>
|
|
2329
|
+
</div>
|
|
2330
|
+
</div>
|
|
2331
|
+
`;
|
|
2332
|
+
}
|
|
2333
|
+
renderTypeOptions() {
|
|
2334
|
+
const t = this.translations;
|
|
2335
|
+
const types = [
|
|
2336
|
+
{ value: 'bug', icon: ICONS.bug, label: t.typeBug },
|
|
2337
|
+
{ value: 'feature_request', icon: ICONS.feature, label: t.typeFeature },
|
|
2338
|
+
{ value: 'improvement', icon: ICONS.improvement, label: t.typeImprovement },
|
|
2339
|
+
{ value: 'question', icon: ICONS.question, label: t.typeQuestion },
|
|
2340
|
+
{ value: 'other', icon: ICONS.other, label: t.typeOther },
|
|
2341
|
+
];
|
|
2342
|
+
return types.map((type, i) => `
|
|
2343
|
+
<label class="checkflow-type-option ${i === 0 ? 'selected' : ''}">
|
|
2344
|
+
<input type="radio" name="cf-type" value="${type.value}" ${i === 0 ? 'checked' : ''} hidden>
|
|
2345
|
+
<span class="checkflow-type-icon">${type.icon}</span>
|
|
2346
|
+
<span class="checkflow-type-label">${type.label}</span>
|
|
2347
|
+
</label>
|
|
2348
|
+
`).join('');
|
|
2349
|
+
}
|
|
2350
|
+
renderCaptureButton() {
|
|
2351
|
+
return `
|
|
2352
|
+
<button type="button" class="checkflow-capture-btn" id="cf-capture">
|
|
2353
|
+
${ICONS.camera}
|
|
2354
|
+
<span>${this.translations.captureButton}</span>
|
|
2355
|
+
</button>
|
|
2356
|
+
`;
|
|
2357
|
+
}
|
|
2358
|
+
renderScreenshotPreview(screenshot) {
|
|
2359
|
+
return `
|
|
2360
|
+
<div class="checkflow-screenshot">
|
|
2361
|
+
<img src="${screenshot}" alt="Screenshot">
|
|
2362
|
+
<div class="checkflow-screenshot-actions">
|
|
2363
|
+
<button type="button" class="checkflow-screenshot-btn checkflow-annotate-btn" id="cf-annotate">
|
|
2364
|
+
${ICONS.annotate}
|
|
2365
|
+
<span>Annoter</span>
|
|
2366
|
+
</button>
|
|
2367
|
+
<button type="button" class="checkflow-screenshot-btn" id="cf-retake">
|
|
2368
|
+
${this.translations.retakeButton}
|
|
2369
|
+
</button>
|
|
2370
|
+
</div>
|
|
2371
|
+
</div>
|
|
2372
|
+
`;
|
|
2373
|
+
}
|
|
2374
|
+
renderSuccess() {
|
|
2375
|
+
const t = this.translations;
|
|
2376
|
+
const { position } = this.options;
|
|
2377
|
+
const panelPosition = position?.includes('left') ? 'bottom-left' : '';
|
|
2378
|
+
return `
|
|
2379
|
+
<div class="checkflow-panel ${panelPosition}" id="cf-panel">
|
|
2380
|
+
<div class="checkflow-body">
|
|
2381
|
+
<div class="checkflow-success">
|
|
2382
|
+
<div class="checkflow-success-icon">${ICONS.check}</div>
|
|
2383
|
+
<h3 class="checkflow-success-title">${t.submitSuccess}</h3>
|
|
2384
|
+
<p class="checkflow-success-message">Merci pour votre retour ! Notre équipe va l'examiner.</p>
|
|
2385
|
+
</div>
|
|
2386
|
+
</div>
|
|
2387
|
+
<div class="checkflow-footer">
|
|
2388
|
+
<button class="checkflow-btn checkflow-btn-primary" id="cf-done">
|
|
2389
|
+
Fermer
|
|
2390
|
+
</button>
|
|
2391
|
+
</div>
|
|
2392
|
+
</div>
|
|
2393
|
+
`;
|
|
2394
|
+
}
|
|
2395
|
+
render() {
|
|
2396
|
+
if (!this.container)
|
|
2397
|
+
return;
|
|
2398
|
+
if (!this.state.isOpen) {
|
|
2399
|
+
this.container.innerHTML = this.renderTrigger();
|
|
2400
|
+
}
|
|
2401
|
+
else {
|
|
2402
|
+
this.container.innerHTML = this.renderTrigger() + this.renderModal();
|
|
2403
|
+
}
|
|
2404
|
+
this.bindEvents();
|
|
2405
|
+
}
|
|
2406
|
+
bindEvents() {
|
|
2407
|
+
if (!this.container)
|
|
2408
|
+
return;
|
|
2409
|
+
// Trigger button
|
|
2410
|
+
const trigger = this.container.querySelector('#cf-trigger');
|
|
2411
|
+
trigger?.addEventListener('click', () => this.open());
|
|
2412
|
+
// Close button
|
|
2413
|
+
const close = this.container.querySelector('#cf-close');
|
|
2414
|
+
close?.addEventListener('click', () => this.close());
|
|
2415
|
+
// Cancel button
|
|
2416
|
+
const cancel = this.container.querySelector('#cf-cancel');
|
|
2417
|
+
cancel?.addEventListener('click', () => this.close());
|
|
2418
|
+
// Done button (success state)
|
|
2419
|
+
const done = this.container.querySelector('#cf-done');
|
|
2420
|
+
done?.addEventListener('click', () => this.close());
|
|
2421
|
+
// Capture button
|
|
2422
|
+
const captureBtn = this.container.querySelector('#cf-capture');
|
|
2423
|
+
captureBtn?.addEventListener('click', () => this.captureScreenshot());
|
|
2424
|
+
// Retake button
|
|
2425
|
+
const retakeBtn = this.container.querySelector('#cf-retake');
|
|
2426
|
+
retakeBtn?.addEventListener('click', () => this.captureScreenshot());
|
|
2427
|
+
// Annotate button
|
|
2428
|
+
const annotateBtn = this.container.querySelector('#cf-annotate');
|
|
2429
|
+
annotateBtn?.addEventListener('click', () => this.openAnnotationEditor());
|
|
2430
|
+
// Delete screenshot button
|
|
2431
|
+
const deleteScreenshotBtn = this.container.querySelector('#cf-delete-screenshot');
|
|
2432
|
+
deleteScreenshotBtn?.addEventListener('click', () => {
|
|
2433
|
+
this.state.captureResult = undefined;
|
|
2434
|
+
this.currentAnnotations = [];
|
|
2435
|
+
this.render();
|
|
2436
|
+
});
|
|
2437
|
+
// Overlay click to close
|
|
2438
|
+
const overlay = this.container.querySelector('#cf-overlay');
|
|
2439
|
+
overlay?.addEventListener('click', (e) => {
|
|
2440
|
+
if (e.target === overlay)
|
|
2441
|
+
this.close();
|
|
2442
|
+
});
|
|
2443
|
+
// Type selection
|
|
2444
|
+
const typeOptions = this.container.querySelectorAll('.checkflow-type-option');
|
|
2445
|
+
typeOptions.forEach((option) => {
|
|
2446
|
+
option.addEventListener('click', () => {
|
|
2447
|
+
typeOptions.forEach((o) => o.classList.remove('selected'));
|
|
2448
|
+
option.classList.add('selected');
|
|
2449
|
+
});
|
|
2450
|
+
});
|
|
2451
|
+
// Form submission
|
|
2452
|
+
const form = this.container.querySelector('#cf-form');
|
|
2453
|
+
form?.addEventListener('submit', (e) => {
|
|
2454
|
+
e.preventDefault();
|
|
2455
|
+
this.handleSubmit();
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
async captureScreenshot() {
|
|
2459
|
+
this.state.isCapturing = true;
|
|
2460
|
+
// Temporarily hide widget for clean screenshot
|
|
2461
|
+
if (this.container) {
|
|
2462
|
+
this.container.style.display = 'none';
|
|
2463
|
+
}
|
|
2464
|
+
try {
|
|
2465
|
+
// Use native browser screen capture API (fast, with permission dialog)
|
|
2466
|
+
const screenshot = await this.captureWithDisplayMedia();
|
|
2467
|
+
if (screenshot) {
|
|
2468
|
+
const captureResult = await this.capture.capture({
|
|
2469
|
+
includeConsole: true,
|
|
2470
|
+
includeNetwork: true,
|
|
2471
|
+
includePerformance: true,
|
|
2472
|
+
});
|
|
2473
|
+
captureResult.screenshot = screenshot;
|
|
2474
|
+
this.state.captureResult = captureResult;
|
|
2475
|
+
// Reset annotations when taking new screenshot
|
|
2476
|
+
this.currentAnnotations = [];
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
catch (error) {
|
|
2480
|
+
console.error('[CheckFlow] Screenshot failed:', error);
|
|
2481
|
+
// Check if user denied permission
|
|
2482
|
+
if (error.name === 'NotAllowedError') {
|
|
2483
|
+
this.state.error = 'Permission refusée par l\'utilisateur';
|
|
2484
|
+
}
|
|
2485
|
+
else {
|
|
2486
|
+
this.state.error = this.translations.captureError;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
// Restore widget
|
|
2490
|
+
if (this.container) {
|
|
2491
|
+
this.container.style.display = '';
|
|
2492
|
+
}
|
|
2493
|
+
this.state.isCapturing = false;
|
|
2494
|
+
this.render();
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Capture screen using native browser API (getDisplayMedia)
|
|
2498
|
+
* Optimized for instant capture after user allows
|
|
2499
|
+
*/
|
|
2500
|
+
async captureWithDisplayMedia() {
|
|
2501
|
+
try {
|
|
2502
|
+
// Request screen capture permission
|
|
2503
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
2504
|
+
video: {
|
|
2505
|
+
displaySurface: 'browser',
|
|
2506
|
+
},
|
|
2507
|
+
audio: false,
|
|
2508
|
+
// @ts-ignore - preferCurrentTab is a newer API
|
|
2509
|
+
preferCurrentTab: true,
|
|
2510
|
+
});
|
|
2511
|
+
const track = stream.getVideoTracks()[0];
|
|
2512
|
+
// Use ImageCapture API for instant capture (no video element needed)
|
|
2513
|
+
// @ts-ignore - ImageCapture may not be in all TS definitions
|
|
2514
|
+
if (typeof ImageCapture !== 'undefined') {
|
|
2515
|
+
try {
|
|
2516
|
+
// @ts-ignore
|
|
2517
|
+
const imageCapture = new ImageCapture(track);
|
|
2518
|
+
const bitmap = await imageCapture.grabFrame();
|
|
2519
|
+
// Draw bitmap to canvas
|
|
2520
|
+
const canvas = document.createElement('canvas');
|
|
2521
|
+
canvas.width = bitmap.width;
|
|
2522
|
+
canvas.height = bitmap.height;
|
|
2523
|
+
const ctx = canvas.getContext('2d');
|
|
2524
|
+
if (ctx) {
|
|
2525
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
2526
|
+
track.stop();
|
|
2527
|
+
stream.getTracks().forEach(t => t.stop());
|
|
2528
|
+
return canvas.toDataURL('image/png', 0.9);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
catch (e) {
|
|
2532
|
+
// Fall back to video method
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
// Fallback: Use video element with minimal delay
|
|
2536
|
+
const video = document.createElement('video');
|
|
2537
|
+
video.srcObject = stream;
|
|
2538
|
+
video.muted = true;
|
|
2539
|
+
video.playsInline = true;
|
|
2540
|
+
await video.play();
|
|
2541
|
+
// Capture immediately after play starts
|
|
2542
|
+
const canvas = document.createElement('canvas');
|
|
2543
|
+
canvas.width = video.videoWidth || 1920;
|
|
2544
|
+
canvas.height = video.videoHeight || 1080;
|
|
2545
|
+
const ctx = canvas.getContext('2d');
|
|
2546
|
+
if (!ctx) {
|
|
2547
|
+
track.stop();
|
|
2548
|
+
throw new Error('Could not get canvas context');
|
|
2549
|
+
}
|
|
2550
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
2551
|
+
// Stop immediately
|
|
2552
|
+
track.stop();
|
|
2553
|
+
stream.getTracks().forEach(t => t.stop());
|
|
2554
|
+
return canvas.toDataURL('image/png', 0.9);
|
|
2555
|
+
}
|
|
2556
|
+
catch (error) {
|
|
2557
|
+
console.error('[CheckFlow] getDisplayMedia failed:', error);
|
|
2558
|
+
throw error;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Open the annotation editor for the current screenshot
|
|
2563
|
+
*/
|
|
2564
|
+
async openAnnotationEditor() {
|
|
2565
|
+
const screenshot = this.state.captureResult?.screenshot;
|
|
2566
|
+
if (!screenshot)
|
|
2567
|
+
return;
|
|
2568
|
+
// Hide the widget modal while annotating
|
|
2569
|
+
if (this.container) {
|
|
2570
|
+
this.container.style.display = 'none';
|
|
2571
|
+
}
|
|
2572
|
+
this.annotationEditor = new AnnotationEditor({
|
|
2573
|
+
tools: ['rectangle', 'ellipse', 'arrow', 'highlight', 'blur', 'text', 'freehand'],
|
|
2574
|
+
defaultStyle: {
|
|
2575
|
+
strokeColor: '#FF3B30',
|
|
2576
|
+
strokeWidth: 3,
|
|
2577
|
+
fillColor: 'transparent',
|
|
2578
|
+
opacity: 1,
|
|
2579
|
+
fontSize: 16,
|
|
2580
|
+
},
|
|
2581
|
+
onSave: (annotations, annotatedImage) => {
|
|
2582
|
+
// Update screenshot with annotated version
|
|
2583
|
+
if (this.state.captureResult) {
|
|
2584
|
+
this.state.captureResult.screenshot = annotatedImage;
|
|
2585
|
+
}
|
|
2586
|
+
this.currentAnnotations = annotations;
|
|
2587
|
+
// Restore and re-render widget
|
|
2588
|
+
if (this.container) {
|
|
2589
|
+
this.container.style.display = '';
|
|
2590
|
+
}
|
|
2591
|
+
this.render();
|
|
2592
|
+
this.annotationEditor = null;
|
|
2593
|
+
},
|
|
2594
|
+
onCancel: () => {
|
|
2595
|
+
// Restore widget without changes
|
|
2596
|
+
if (this.container) {
|
|
2597
|
+
this.container.style.display = '';
|
|
2598
|
+
}
|
|
2599
|
+
this.annotationEditor = null;
|
|
2600
|
+
},
|
|
2601
|
+
});
|
|
2602
|
+
await this.annotationEditor.open(screenshot);
|
|
2603
|
+
}
|
|
2604
|
+
async handleSubmit() {
|
|
2605
|
+
if (!this.container)
|
|
2606
|
+
return;
|
|
2607
|
+
const nameInput = this.container.querySelector('#cf-name');
|
|
2608
|
+
const emailInput = this.container.querySelector('#cf-email');
|
|
2609
|
+
const descInput = this.container.querySelector('#cf-description');
|
|
2610
|
+
const typeInput = this.container.querySelector('input[name="cf-type"]:checked');
|
|
2611
|
+
const name = nameInput?.value.trim();
|
|
2612
|
+
const email = emailInput?.value.trim();
|
|
2613
|
+
const description = descInput?.value.trim();
|
|
2614
|
+
const feedbackType = (typeInput?.value || 'bug');
|
|
2615
|
+
// Validation
|
|
2616
|
+
if (!name) {
|
|
2617
|
+
this.state.error = 'Veuillez entrer votre nom';
|
|
2618
|
+
this.render();
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (!email) {
|
|
2622
|
+
this.state.error = 'Veuillez entrer votre email';
|
|
2623
|
+
this.render();
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
if (!description) {
|
|
2627
|
+
this.state.error = 'Veuillez décrire le problème';
|
|
2628
|
+
this.render();
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
this.state.isSubmitting = true;
|
|
2632
|
+
this.state.error = undefined;
|
|
2633
|
+
this.render();
|
|
2634
|
+
try {
|
|
2635
|
+
const submitData = {
|
|
2636
|
+
name,
|
|
2637
|
+
email,
|
|
2638
|
+
description,
|
|
2639
|
+
type: feedbackType,
|
|
2640
|
+
priority: 'medium',
|
|
2641
|
+
screenshot: this.state.captureResult?.screenshot,
|
|
2642
|
+
annotations: this.currentAnnotations.length > 0 ? this.currentAnnotations : undefined,
|
|
2643
|
+
includeConsole: true,
|
|
2644
|
+
includeNetwork: true,
|
|
2645
|
+
};
|
|
2646
|
+
if (this.options.onSubmit) {
|
|
2647
|
+
await this.options.onSubmit(submitData);
|
|
2648
|
+
}
|
|
2649
|
+
// Show success
|
|
2650
|
+
this.state.isSubmitting = false;
|
|
2651
|
+
this.container.innerHTML = this.renderTrigger() + this.renderSuccess();
|
|
2652
|
+
this.bindEvents();
|
|
2653
|
+
// Auto-close after delay
|
|
2654
|
+
setTimeout(() => this.close(), 3000);
|
|
2655
|
+
}
|
|
2656
|
+
catch (error) {
|
|
2657
|
+
this.state.isSubmitting = false;
|
|
2658
|
+
this.state.error = error.message || this.translations.submitError;
|
|
2659
|
+
this.render();
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
/**
|
|
2663
|
+
* Update widget options
|
|
2664
|
+
*/
|
|
2665
|
+
setOptions(options) {
|
|
2666
|
+
this.options = { ...this.options, ...options };
|
|
2667
|
+
if (options.translations) {
|
|
2668
|
+
this.translations = { ...this.translations, ...options.translations };
|
|
2669
|
+
}
|
|
2670
|
+
this.render();
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Get capture context
|
|
2674
|
+
*/
|
|
2675
|
+
getCapture() {
|
|
2676
|
+
return this.capture;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
/**
|
|
2681
|
+
* Session Recording Module
|
|
2682
|
+
* Captures user interactions for replay
|
|
2683
|
+
*/
|
|
2684
|
+
const DEFAULT_CONFIG = {
|
|
2685
|
+
enabled: true,
|
|
2686
|
+
recordMouse: true,
|
|
2687
|
+
recordScroll: true,
|
|
2688
|
+
recordInput: true,
|
|
2689
|
+
recordMutations: true,
|
|
2690
|
+
maskInputs: true,
|
|
2691
|
+
maskSelectors: ['[type="password"]', '[data-sensitive]', '.sensitive'],
|
|
2692
|
+
maxDurationSeconds: 300,
|
|
2693
|
+
sampleRate: 1.0,
|
|
2694
|
+
};
|
|
2695
|
+
class SessionRecording {
|
|
2696
|
+
constructor(config = {}) {
|
|
2697
|
+
this.events = [];
|
|
2698
|
+
this.startTime = 0;
|
|
2699
|
+
this.isRecording = false;
|
|
2700
|
+
this.mutationObserver = null;
|
|
2701
|
+
this.listeners = [];
|
|
2702
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
2703
|
+
this.sessionId = this.generateSessionId();
|
|
2704
|
+
}
|
|
2705
|
+
generateSessionId() {
|
|
2706
|
+
return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* Start recording the session
|
|
2710
|
+
*/
|
|
2711
|
+
start() {
|
|
2712
|
+
if (this.isRecording || !this.config.enabled)
|
|
2713
|
+
return;
|
|
2714
|
+
this.isRecording = true;
|
|
2715
|
+
this.startTime = Date.now();
|
|
2716
|
+
this.events = [];
|
|
2717
|
+
// Record initial DOM snapshot
|
|
2718
|
+
this.recordSnapshot();
|
|
2719
|
+
// Setup event listeners
|
|
2720
|
+
if (this.config.recordMouse) {
|
|
2721
|
+
this.setupMouseTracking();
|
|
2722
|
+
}
|
|
2723
|
+
if (this.config.recordScroll) {
|
|
2724
|
+
this.setupScrollTracking();
|
|
2725
|
+
}
|
|
2726
|
+
if (this.config.recordInput) {
|
|
2727
|
+
this.setupInputTracking();
|
|
2728
|
+
}
|
|
2729
|
+
if (this.config.recordMutations) {
|
|
2730
|
+
this.setupMutationObserver();
|
|
2731
|
+
}
|
|
2732
|
+
// Track navigation
|
|
2733
|
+
this.setupNavigationTracking();
|
|
2734
|
+
// Track resize
|
|
2735
|
+
this.setupResizeTracking();
|
|
2736
|
+
// Auto-stop after max duration
|
|
2737
|
+
setTimeout(() => {
|
|
2738
|
+
if (this.isRecording) {
|
|
2739
|
+
this.stop();
|
|
2740
|
+
}
|
|
2741
|
+
}, this.config.maxDurationSeconds * 1000);
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Stop recording
|
|
2745
|
+
*/
|
|
2746
|
+
stop() {
|
|
2747
|
+
if (!this.isRecording)
|
|
2748
|
+
return;
|
|
2749
|
+
this.isRecording = false;
|
|
2750
|
+
// Remove all event listeners
|
|
2751
|
+
this.listeners.forEach(({ element, event, handler }) => {
|
|
2752
|
+
element.removeEventListener(event, handler);
|
|
2753
|
+
});
|
|
2754
|
+
this.listeners = [];
|
|
2755
|
+
// Disconnect mutation observer
|
|
2756
|
+
if (this.mutationObserver) {
|
|
2757
|
+
this.mutationObserver.disconnect();
|
|
2758
|
+
this.mutationObserver = null;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Get session ID
|
|
2763
|
+
*/
|
|
2764
|
+
getSessionId() {
|
|
2765
|
+
return this.sessionId;
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Get recorded events
|
|
2769
|
+
*/
|
|
2770
|
+
getEvents() {
|
|
2771
|
+
return [...this.events];
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Get recording duration in seconds
|
|
2775
|
+
*/
|
|
2776
|
+
getDuration() {
|
|
2777
|
+
if (!this.startTime)
|
|
2778
|
+
return 0;
|
|
2779
|
+
return Math.round((Date.now() - this.startTime) / 1000);
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Clear events (for chunked uploads)
|
|
2783
|
+
*/
|
|
2784
|
+
clearEvents() {
|
|
2785
|
+
this.events = [];
|
|
2786
|
+
}
|
|
2787
|
+
/**
|
|
2788
|
+
* Get recording data for submission
|
|
2789
|
+
*/
|
|
2790
|
+
getRecordingData() {
|
|
2791
|
+
if (this.events.length === 0)
|
|
2792
|
+
return null;
|
|
2793
|
+
return {
|
|
2794
|
+
events: [...this.events],
|
|
2795
|
+
sessionId: this.sessionId,
|
|
2796
|
+
duration: this.getDuration(),
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
addEvent(type, data) {
|
|
2800
|
+
// Sample rate filtering
|
|
2801
|
+
if (Math.random() > this.config.sampleRate)
|
|
2802
|
+
return;
|
|
2803
|
+
this.events.push({
|
|
2804
|
+
type,
|
|
2805
|
+
timestamp: Date.now() - this.startTime,
|
|
2806
|
+
data,
|
|
2807
|
+
});
|
|
2808
|
+
// Limit event buffer size
|
|
2809
|
+
if (this.events.length > 10000) {
|
|
2810
|
+
this.events.shift();
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
recordSnapshot() {
|
|
2814
|
+
// Record initial page state
|
|
2815
|
+
this.addEvent('mutation', {
|
|
2816
|
+
action: 'snapshot',
|
|
2817
|
+
html: this.sanitizeHTML(document.documentElement.outerHTML),
|
|
2818
|
+
url: window.location.href,
|
|
2819
|
+
title: document.title,
|
|
2820
|
+
viewport: {
|
|
2821
|
+
width: window.innerWidth,
|
|
2822
|
+
height: window.innerHeight,
|
|
2823
|
+
},
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
setupMouseTracking() {
|
|
2827
|
+
let lastX = 0, lastY = 0;
|
|
2828
|
+
let throttleTimer = null;
|
|
2829
|
+
const handler = (e) => {
|
|
2830
|
+
// Throttle to ~30fps
|
|
2831
|
+
if (throttleTimer)
|
|
2832
|
+
return;
|
|
2833
|
+
throttleTimer = window.setTimeout(() => {
|
|
2834
|
+
throttleTimer = null;
|
|
2835
|
+
}, 33);
|
|
2836
|
+
// Only record if position changed significantly
|
|
2837
|
+
if (Math.abs(e.clientX - lastX) > 5 || Math.abs(e.clientY - lastY) > 5) {
|
|
2838
|
+
lastX = e.clientX;
|
|
2839
|
+
lastY = e.clientY;
|
|
2840
|
+
this.addEvent('mouse', {
|
|
2841
|
+
action: 'move',
|
|
2842
|
+
x: e.clientX,
|
|
2843
|
+
y: e.clientY,
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
};
|
|
2847
|
+
const clickHandler = (e) => {
|
|
2848
|
+
this.addEvent('mouse', {
|
|
2849
|
+
action: 'click',
|
|
2850
|
+
x: e.clientX,
|
|
2851
|
+
y: e.clientY,
|
|
2852
|
+
target: this.getElementSelector(e.target),
|
|
2853
|
+
});
|
|
2854
|
+
};
|
|
2855
|
+
document.addEventListener('mousemove', handler, { passive: true });
|
|
2856
|
+
document.addEventListener('click', clickHandler, { passive: true });
|
|
2857
|
+
this.listeners.push({ element: document, event: 'mousemove', handler: handler }, { element: document, event: 'click', handler: clickHandler });
|
|
2858
|
+
}
|
|
2859
|
+
setupScrollTracking() {
|
|
2860
|
+
let throttleTimer = null;
|
|
2861
|
+
const handler = () => {
|
|
2862
|
+
if (throttleTimer)
|
|
2863
|
+
return;
|
|
2864
|
+
throttleTimer = window.setTimeout(() => {
|
|
2865
|
+
throttleTimer = null;
|
|
2866
|
+
}, 100);
|
|
2867
|
+
this.addEvent('scroll', {
|
|
2868
|
+
x: window.scrollX,
|
|
2869
|
+
y: window.scrollY,
|
|
2870
|
+
});
|
|
2871
|
+
};
|
|
2872
|
+
window.addEventListener('scroll', handler, { passive: true });
|
|
2873
|
+
this.listeners.push({ element: window, event: 'scroll', handler });
|
|
2874
|
+
}
|
|
2875
|
+
setupInputTracking() {
|
|
2876
|
+
const handler = (e) => {
|
|
2877
|
+
const target = e.target;
|
|
2878
|
+
if (!target.tagName)
|
|
2879
|
+
return;
|
|
2880
|
+
const isSensitive = this.isSensitiveElement(target);
|
|
2881
|
+
this.addEvent('input', {
|
|
2882
|
+
selector: this.getElementSelector(target),
|
|
2883
|
+
value: isSensitive ? '••••••••' : target.value?.substring(0, 100),
|
|
2884
|
+
type: target.type || 'text',
|
|
2885
|
+
});
|
|
2886
|
+
};
|
|
2887
|
+
document.addEventListener('input', handler, { passive: true });
|
|
2888
|
+
this.listeners.push({ element: document, event: 'input', handler });
|
|
2889
|
+
}
|
|
2890
|
+
setupMutationObserver() {
|
|
2891
|
+
this.mutationObserver = new MutationObserver((mutations) => {
|
|
2892
|
+
const changes = [];
|
|
2893
|
+
mutations.forEach((mutation) => {
|
|
2894
|
+
if (mutation.type === 'childList') {
|
|
2895
|
+
mutation.addedNodes.forEach((node) => {
|
|
2896
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
2897
|
+
changes.push({
|
|
2898
|
+
action: 'add',
|
|
2899
|
+
target: this.getElementSelector(mutation.target),
|
|
2900
|
+
html: this.sanitizeHTML(node.outerHTML || ''),
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
mutation.removedNodes.forEach((node) => {
|
|
2905
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
2906
|
+
changes.push({
|
|
2907
|
+
action: 'remove',
|
|
2908
|
+
target: this.getElementSelector(mutation.target),
|
|
2909
|
+
selector: this.getElementSelector(node),
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
else if (mutation.type === 'attributes') {
|
|
2915
|
+
changes.push({
|
|
2916
|
+
action: 'attribute',
|
|
2917
|
+
target: this.getElementSelector(mutation.target),
|
|
2918
|
+
attribute: mutation.attributeName,
|
|
2919
|
+
value: mutation.target.getAttribute(mutation.attributeName),
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
if (changes.length > 0) {
|
|
2924
|
+
this.addEvent('mutation', { changes });
|
|
2925
|
+
}
|
|
2926
|
+
});
|
|
2927
|
+
this.mutationObserver.observe(document.body, {
|
|
2928
|
+
childList: true,
|
|
2929
|
+
subtree: true,
|
|
2930
|
+
attributes: true,
|
|
2931
|
+
attributeFilter: ['class', 'style', 'src', 'href'],
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
setupNavigationTracking() {
|
|
2935
|
+
const handler = () => {
|
|
2936
|
+
this.addEvent('navigation', {
|
|
2937
|
+
url: window.location.href,
|
|
2938
|
+
title: document.title,
|
|
2939
|
+
});
|
|
2940
|
+
};
|
|
2941
|
+
window.addEventListener('popstate', handler);
|
|
2942
|
+
this.listeners.push({ element: window, event: 'popstate', handler });
|
|
2943
|
+
}
|
|
2944
|
+
setupResizeTracking() {
|
|
2945
|
+
let throttleTimer = null;
|
|
2946
|
+
const handler = () => {
|
|
2947
|
+
if (throttleTimer)
|
|
2948
|
+
return;
|
|
2949
|
+
throttleTimer = window.setTimeout(() => {
|
|
2950
|
+
throttleTimer = null;
|
|
2951
|
+
}, 200);
|
|
2952
|
+
this.addEvent('resize', {
|
|
2953
|
+
width: window.innerWidth,
|
|
2954
|
+
height: window.innerHeight,
|
|
2955
|
+
});
|
|
2956
|
+
};
|
|
2957
|
+
window.addEventListener('resize', handler, { passive: true });
|
|
2958
|
+
this.listeners.push({ element: window, event: 'resize', handler });
|
|
2959
|
+
}
|
|
2960
|
+
getElementSelector(element) {
|
|
2961
|
+
if (!element || !element.tagName)
|
|
2962
|
+
return '';
|
|
2963
|
+
const parts = [];
|
|
2964
|
+
let current = element;
|
|
2965
|
+
while (current && current.tagName !== 'HTML') {
|
|
2966
|
+
let selector = current.tagName.toLowerCase();
|
|
2967
|
+
if (current.id) {
|
|
2968
|
+
selector += `#${current.id}`;
|
|
2969
|
+
parts.unshift(selector);
|
|
2970
|
+
break;
|
|
2971
|
+
}
|
|
2972
|
+
else if (current.className && typeof current.className === 'string') {
|
|
2973
|
+
const classes = current.className.trim().split(/\s+/).slice(0, 2);
|
|
2974
|
+
if (classes.length > 0 && classes[0]) {
|
|
2975
|
+
selector += `.${classes.join('.')}`;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
parts.unshift(selector);
|
|
2979
|
+
current = current.parentElement;
|
|
2980
|
+
}
|
|
2981
|
+
return parts.slice(-4).join(' > ');
|
|
2982
|
+
}
|
|
2983
|
+
isSensitiveElement(element) {
|
|
2984
|
+
if (this.config.maskInputs) {
|
|
2985
|
+
const type = element.getAttribute('type');
|
|
2986
|
+
if (type === 'password' || type === 'credit-card')
|
|
2987
|
+
return true;
|
|
2988
|
+
}
|
|
2989
|
+
return this.config.maskSelectors.some((selector) => {
|
|
2990
|
+
try {
|
|
2991
|
+
return element.matches(selector);
|
|
2992
|
+
}
|
|
2993
|
+
catch {
|
|
2994
|
+
return false;
|
|
2995
|
+
}
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
sanitizeHTML(html) {
|
|
2999
|
+
// Remove script contents
|
|
3000
|
+
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '<script></script>');
|
|
3001
|
+
// Mask sensitive inputs
|
|
3002
|
+
if (this.config.maskInputs) {
|
|
3003
|
+
html = html.replace(/(<input[^>]*type=["']password["'][^>]*value=["'])[^"']*["']/gi, '$1••••••••"');
|
|
3004
|
+
}
|
|
3005
|
+
// Truncate if too large
|
|
3006
|
+
if (html.length > 500000) {
|
|
3007
|
+
html = html.substring(0, 500000) + '<!-- truncated -->';
|
|
3008
|
+
}
|
|
3009
|
+
return html;
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
/**
|
|
3014
|
+
* CheckFlow Privacy Module Types
|
|
3015
|
+
* Configuration and types for automatic PII masking
|
|
3016
|
+
*/
|
|
3017
|
+
const DEFAULT_PRIVACY_CONFIG = {
|
|
3018
|
+
enabled: true,
|
|
3019
|
+
autoMask: {
|
|
3020
|
+
emails: true,
|
|
3021
|
+
creditCards: true,
|
|
3022
|
+
phoneNumbers: true,
|
|
3023
|
+
passwords: true,
|
|
3024
|
+
socialSecurity: true,
|
|
3025
|
+
ipAddresses: false,
|
|
3026
|
+
customPatterns: [],
|
|
3027
|
+
},
|
|
3028
|
+
excludeSelectors: [],
|
|
3029
|
+
includeSelectors: [],
|
|
3030
|
+
maskChar: '•',
|
|
3031
|
+
maskLength: 'preserve',
|
|
3032
|
+
fixedMaskLength: 8,
|
|
3033
|
+
};
|
|
3034
|
+
const SENSITIVE_INPUT_TYPES = [
|
|
3035
|
+
'password',
|
|
3036
|
+
'email',
|
|
3037
|
+
'tel',
|
|
3038
|
+
'credit-card',
|
|
3039
|
+
'cc-number',
|
|
3040
|
+
'cc-csc',
|
|
3041
|
+
'cc-exp',
|
|
3042
|
+
];
|
|
3043
|
+
const SENSITIVE_AUTOCOMPLETE_VALUES = [
|
|
3044
|
+
'cc-number',
|
|
3045
|
+
'cc-csc',
|
|
3046
|
+
'cc-exp',
|
|
3047
|
+
'cc-exp-month',
|
|
3048
|
+
'cc-exp-year',
|
|
3049
|
+
'cc-name',
|
|
3050
|
+
'cc-type',
|
|
3051
|
+
'new-password',
|
|
3052
|
+
'current-password',
|
|
3053
|
+
];
|
|
3054
|
+
|
|
3055
|
+
/**
|
|
3056
|
+
* CheckFlow Privacy Detector
|
|
3057
|
+
* Regex patterns for detecting PII (Personally Identifiable Information)
|
|
3058
|
+
*/
|
|
3059
|
+
const PATTERNS = {
|
|
3060
|
+
email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
3061
|
+
creditCard: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g,
|
|
3062
|
+
creditCardSpaced: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
|
3063
|
+
phone: /(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
|
|
3064
|
+
phoneFR: /(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}/g,
|
|
3065
|
+
ssn: /\b\d{3}[-]?\d{2}[-]?\d{4}\b/g,
|
|
3066
|
+
ipv4: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
|
|
3067
|
+
ipv6: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
|
|
3068
|
+
};
|
|
3069
|
+
class PrivacyDetector {
|
|
3070
|
+
constructor(customPatterns = []) {
|
|
3071
|
+
this.customPatterns = [];
|
|
3072
|
+
this.customPatterns = customPatterns;
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Add a custom pattern
|
|
3076
|
+
*/
|
|
3077
|
+
addPattern(pattern) {
|
|
3078
|
+
this.customPatterns.push(pattern);
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Detect all PII in a text string
|
|
3082
|
+
*/
|
|
3083
|
+
detectAll(text, options = {}) {
|
|
3084
|
+
const detections = [];
|
|
3085
|
+
const { emails = true, creditCards = true, phoneNumbers = true, ssn = true, ipAddresses = false, } = options;
|
|
3086
|
+
if (emails) {
|
|
3087
|
+
detections.push(...this.detectEmails(text));
|
|
3088
|
+
}
|
|
3089
|
+
if (creditCards) {
|
|
3090
|
+
detections.push(...this.detectCreditCards(text));
|
|
3091
|
+
}
|
|
3092
|
+
if (phoneNumbers) {
|
|
3093
|
+
detections.push(...this.detectPhoneNumbers(text));
|
|
3094
|
+
}
|
|
3095
|
+
if (ssn) {
|
|
3096
|
+
detections.push(...this.detectSSN(text));
|
|
3097
|
+
}
|
|
3098
|
+
if (ipAddresses) {
|
|
3099
|
+
detections.push(...this.detectIPAddresses(text));
|
|
3100
|
+
}
|
|
3101
|
+
// Custom patterns
|
|
3102
|
+
for (const custom of this.customPatterns) {
|
|
3103
|
+
detections.push(...this.detectCustom(text, custom));
|
|
3104
|
+
}
|
|
3105
|
+
// Sort by start index and remove overlaps
|
|
3106
|
+
return this.removeOverlaps(detections);
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Detect email addresses
|
|
3110
|
+
*/
|
|
3111
|
+
detectEmails(text) {
|
|
3112
|
+
return this.findMatches(text, PATTERNS.email, 'email');
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* Detect credit card numbers
|
|
3116
|
+
*/
|
|
3117
|
+
detectCreditCards(text) {
|
|
3118
|
+
const detections = [];
|
|
3119
|
+
// Standard format
|
|
3120
|
+
detections.push(...this.findMatches(text, PATTERNS.creditCard, 'credit_card'));
|
|
3121
|
+
// Spaced/dashed format
|
|
3122
|
+
detections.push(...this.findMatches(text, PATTERNS.creditCardSpaced, 'credit_card'));
|
|
3123
|
+
return detections;
|
|
3124
|
+
}
|
|
3125
|
+
/**
|
|
3126
|
+
* Detect phone numbers
|
|
3127
|
+
*/
|
|
3128
|
+
detectPhoneNumbers(text) {
|
|
3129
|
+
const detections = [];
|
|
3130
|
+
// US format
|
|
3131
|
+
detections.push(...this.findMatches(text, PATTERNS.phone, 'phone'));
|
|
3132
|
+
// French format
|
|
3133
|
+
detections.push(...this.findMatches(text, PATTERNS.phoneFR, 'phone'));
|
|
3134
|
+
return detections;
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Detect Social Security Numbers
|
|
3138
|
+
*/
|
|
3139
|
+
detectSSN(text) {
|
|
3140
|
+
return this.findMatches(text, PATTERNS.ssn, 'ssn');
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Detect IP addresses
|
|
3144
|
+
*/
|
|
3145
|
+
detectIPAddresses(text) {
|
|
3146
|
+
const detections = [];
|
|
3147
|
+
detections.push(...this.findMatches(text, PATTERNS.ipv4, 'ip_address'));
|
|
3148
|
+
detections.push(...this.findMatches(text, PATTERNS.ipv6, 'ip_address'));
|
|
3149
|
+
return detections;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Detect custom pattern
|
|
3153
|
+
*/
|
|
3154
|
+
detectCustom(text, pattern) {
|
|
3155
|
+
return this.findMatches(text, pattern.pattern, 'custom');
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Find all matches of a pattern in text
|
|
3159
|
+
*/
|
|
3160
|
+
findMatches(text, pattern, type) {
|
|
3161
|
+
const detections = [];
|
|
3162
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
3163
|
+
let match;
|
|
3164
|
+
while ((match = regex.exec(text)) !== null) {
|
|
3165
|
+
detections.push({
|
|
3166
|
+
type,
|
|
3167
|
+
value: match[0],
|
|
3168
|
+
masked: '', // Will be filled by masker
|
|
3169
|
+
startIndex: match.index,
|
|
3170
|
+
endIndex: match.index + match[0].length,
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
return detections;
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* Remove overlapping detections (keep the longer one)
|
|
3177
|
+
*/
|
|
3178
|
+
removeOverlaps(detections) {
|
|
3179
|
+
if (detections.length <= 1)
|
|
3180
|
+
return detections;
|
|
3181
|
+
// Sort by start index
|
|
3182
|
+
const sorted = [...detections].sort((a, b) => a.startIndex - b.startIndex);
|
|
3183
|
+
const result = [sorted[0]];
|
|
3184
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
3185
|
+
const current = sorted[i];
|
|
3186
|
+
const last = result[result.length - 1];
|
|
3187
|
+
// Check for overlap
|
|
3188
|
+
if (current.startIndex < last.endIndex) {
|
|
3189
|
+
// Keep the longer one
|
|
3190
|
+
if (current.endIndex - current.startIndex > last.endIndex - last.startIndex) {
|
|
3191
|
+
result[result.length - 1] = current;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
else {
|
|
3195
|
+
result.push(current);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
return result;
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
/**
|
|
3203
|
+
* CheckFlow Privacy Masker
|
|
3204
|
+
* Applies masking to detected PII in text and DOM elements
|
|
3205
|
+
*/
|
|
3206
|
+
class PrivacyMasker {
|
|
3207
|
+
constructor(config = {}) {
|
|
3208
|
+
this.config = { ...DEFAULT_PRIVACY_CONFIG, ...config };
|
|
3209
|
+
this.detector = new PrivacyDetector(this.config.autoMask.customPatterns);
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Update configuration
|
|
3213
|
+
*/
|
|
3214
|
+
configure(config) {
|
|
3215
|
+
this.config = { ...this.config, ...config };
|
|
3216
|
+
if (config.autoMask?.customPatterns) {
|
|
3217
|
+
this.detector = new PrivacyDetector(config.autoMask.customPatterns);
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Mask PII in a text string
|
|
3222
|
+
*/
|
|
3223
|
+
maskText(text) {
|
|
3224
|
+
if (!this.config.enabled || !text) {
|
|
3225
|
+
return { original: text, masked: text, detections: [] };
|
|
3226
|
+
}
|
|
3227
|
+
const detections = this.detector.detectAll(text, {
|
|
3228
|
+
emails: this.config.autoMask.emails,
|
|
3229
|
+
creditCards: this.config.autoMask.creditCards,
|
|
3230
|
+
phoneNumbers: this.config.autoMask.phoneNumbers,
|
|
3231
|
+
ssn: this.config.autoMask.socialSecurity,
|
|
3232
|
+
ipAddresses: this.config.autoMask.ipAddresses,
|
|
3233
|
+
});
|
|
3234
|
+
if (detections.length === 0) {
|
|
3235
|
+
return { original: text, masked: text, detections: [] };
|
|
3236
|
+
}
|
|
3237
|
+
// Apply masking
|
|
3238
|
+
let masked = text;
|
|
3239
|
+
let offset = 0;
|
|
3240
|
+
for (const detection of detections) {
|
|
3241
|
+
const maskValue = this.getMaskValue(detection.value);
|
|
3242
|
+
detection.masked = maskValue;
|
|
3243
|
+
const start = detection.startIndex + offset;
|
|
3244
|
+
const end = detection.endIndex + offset;
|
|
3245
|
+
masked = masked.substring(0, start) + maskValue + masked.substring(end);
|
|
3246
|
+
offset += maskValue.length - detection.value.length;
|
|
3247
|
+
}
|
|
3248
|
+
return { original: text, masked, detections };
|
|
3249
|
+
}
|
|
3250
|
+
/**
|
|
3251
|
+
* Generate mask value for a detected PII
|
|
3252
|
+
*/
|
|
3253
|
+
getMaskValue(value) {
|
|
3254
|
+
const { maskChar, maskLength, fixedMaskLength } = this.config;
|
|
3255
|
+
if (maskLength === 'fixed') {
|
|
3256
|
+
return maskChar.repeat(fixedMaskLength);
|
|
3257
|
+
}
|
|
3258
|
+
// Preserve length - show first and last chars for context
|
|
3259
|
+
if (value.length <= 4) {
|
|
3260
|
+
return maskChar.repeat(value.length);
|
|
3261
|
+
}
|
|
3262
|
+
// For emails, preserve domain
|
|
3263
|
+
if (value.includes('@')) {
|
|
3264
|
+
const [local, domain] = value.split('@');
|
|
3265
|
+
const maskedLocal = local[0] + maskChar.repeat(Math.max(local.length - 2, 1)) + (local.length > 1 ? local[local.length - 1] : '');
|
|
3266
|
+
return `${maskedLocal}@${domain}`;
|
|
3267
|
+
}
|
|
3268
|
+
// For credit cards, show last 4 digits
|
|
3269
|
+
if (value.replace(/[\s-]/g, '').length >= 13) {
|
|
3270
|
+
const digits = value.replace(/[\s-]/g, '');
|
|
3271
|
+
return maskChar.repeat(digits.length - 4) + digits.slice(-4);
|
|
3272
|
+
}
|
|
3273
|
+
// Default: mask middle, show first and last
|
|
3274
|
+
return value[0] + maskChar.repeat(value.length - 2) + value[value.length - 1];
|
|
3275
|
+
}
|
|
3276
|
+
/**
|
|
3277
|
+
* Process DOM elements for privacy masking
|
|
3278
|
+
* Returns a cloned document with masked content
|
|
3279
|
+
*/
|
|
3280
|
+
processDOM(doc) {
|
|
3281
|
+
const result = {
|
|
3282
|
+
elementsProcessed: 0,
|
|
3283
|
+
elementsMasked: 0,
|
|
3284
|
+
detections: [],
|
|
3285
|
+
};
|
|
3286
|
+
if (!this.config.enabled) {
|
|
3287
|
+
return result;
|
|
3288
|
+
}
|
|
3289
|
+
// Process text nodes
|
|
3290
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null);
|
|
3291
|
+
const textNodes = [];
|
|
3292
|
+
let node;
|
|
3293
|
+
while ((node = walker.nextNode())) {
|
|
3294
|
+
textNodes.push(node);
|
|
3295
|
+
}
|
|
3296
|
+
for (const textNode of textNodes) {
|
|
3297
|
+
result.elementsProcessed++;
|
|
3298
|
+
const text = textNode.textContent || '';
|
|
3299
|
+
if (!text.trim())
|
|
3300
|
+
continue;
|
|
3301
|
+
// Skip if in excluded selector
|
|
3302
|
+
if (this.isExcluded(textNode.parentElement))
|
|
3303
|
+
continue;
|
|
3304
|
+
const maskResult = this.maskText(text);
|
|
3305
|
+
if (maskResult.detections.length > 0) {
|
|
3306
|
+
textNode.textContent = maskResult.masked;
|
|
3307
|
+
result.elementsMasked++;
|
|
3308
|
+
for (const detection of maskResult.detections) {
|
|
3309
|
+
result.detections.push({
|
|
3310
|
+
selector: this.getSelector(textNode.parentElement),
|
|
3311
|
+
type: detection.type,
|
|
3312
|
+
originalText: detection.value,
|
|
3313
|
+
maskedText: detection.masked,
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
// Process input values
|
|
3319
|
+
const inputs = doc.querySelectorAll('input, textarea');
|
|
3320
|
+
inputs.forEach((input) => {
|
|
3321
|
+
result.elementsProcessed++;
|
|
3322
|
+
const el = input;
|
|
3323
|
+
// Check if sensitive input type
|
|
3324
|
+
if (this.isSensitiveInput(el)) {
|
|
3325
|
+
const value = el.value;
|
|
3326
|
+
if (value) {
|
|
3327
|
+
el.value = this.config.maskChar.repeat(Math.min(value.length, 12));
|
|
3328
|
+
result.elementsMasked++;
|
|
3329
|
+
result.detections.push({
|
|
3330
|
+
selector: this.getSelector(el),
|
|
3331
|
+
type: 'password',
|
|
3332
|
+
originalText: '[REDACTED]',
|
|
3333
|
+
maskedText: el.value,
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
else if (!this.isExcluded(el)) {
|
|
3338
|
+
// Mask PII in regular inputs
|
|
3339
|
+
const maskResult = this.maskText(el.value);
|
|
3340
|
+
if (maskResult.detections.length > 0) {
|
|
3341
|
+
el.value = maskResult.masked;
|
|
3342
|
+
result.elementsMasked++;
|
|
3343
|
+
for (const detection of maskResult.detections) {
|
|
3344
|
+
result.detections.push({
|
|
3345
|
+
selector: this.getSelector(el),
|
|
3346
|
+
type: detection.type,
|
|
3347
|
+
originalText: detection.value,
|
|
3348
|
+
maskedText: detection.masked,
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
});
|
|
3354
|
+
// Process data attributes that might contain PII
|
|
3355
|
+
const elementsWithData = doc.querySelectorAll('[data-email], [data-phone], [data-user]');
|
|
3356
|
+
elementsWithData.forEach((el) => {
|
|
3357
|
+
result.elementsProcessed++;
|
|
3358
|
+
if (this.isExcluded(el))
|
|
3359
|
+
return;
|
|
3360
|
+
const attrs = ['data-email', 'data-phone', 'data-user'];
|
|
3361
|
+
for (const attr of attrs) {
|
|
3362
|
+
const value = el.getAttribute(attr);
|
|
3363
|
+
if (value) {
|
|
3364
|
+
const maskResult = this.maskText(value);
|
|
3365
|
+
if (maskResult.detections.length > 0) {
|
|
3366
|
+
el.setAttribute(attr, maskResult.masked);
|
|
3367
|
+
result.elementsMasked++;
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
});
|
|
3372
|
+
return result;
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Check if element should be excluded from masking
|
|
3376
|
+
*/
|
|
3377
|
+
isExcluded(element) {
|
|
3378
|
+
if (!element)
|
|
3379
|
+
return false;
|
|
3380
|
+
// Check exclude selectors
|
|
3381
|
+
for (const selector of this.config.excludeSelectors) {
|
|
3382
|
+
if (element.matches(selector) || element.closest(selector)) {
|
|
3383
|
+
return true;
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
// Check data-checkflow-ignore attribute
|
|
3387
|
+
if (element.hasAttribute('data-checkflow-ignore') ||
|
|
3388
|
+
element.closest('[data-checkflow-ignore]')) {
|
|
3389
|
+
return true;
|
|
3390
|
+
}
|
|
3391
|
+
return false;
|
|
3392
|
+
}
|
|
3393
|
+
/**
|
|
3394
|
+
* Check if input is a sensitive type
|
|
3395
|
+
*/
|
|
3396
|
+
isSensitiveInput(input) {
|
|
3397
|
+
const type = input.getAttribute('type') || 'text';
|
|
3398
|
+
const autocomplete = input.getAttribute('autocomplete') || '';
|
|
3399
|
+
const name = input.getAttribute('name') || '';
|
|
3400
|
+
// Check type
|
|
3401
|
+
if (SENSITIVE_INPUT_TYPES.includes(type.toLowerCase())) {
|
|
3402
|
+
return true;
|
|
3403
|
+
}
|
|
3404
|
+
// Check autocomplete
|
|
3405
|
+
if (SENSITIVE_AUTOCOMPLETE_VALUES.some(val => autocomplete.toLowerCase().includes(val))) {
|
|
3406
|
+
return true;
|
|
3407
|
+
}
|
|
3408
|
+
// Check name patterns
|
|
3409
|
+
const sensitiveNames = ['password', 'pwd', 'pass', 'secret', 'credit', 'card', 'cvv', 'cvc', 'ssn'];
|
|
3410
|
+
if (sensitiveNames.some(n => name.toLowerCase().includes(n))) {
|
|
3411
|
+
return true;
|
|
3412
|
+
}
|
|
3413
|
+
return false;
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* Generate CSS selector for element
|
|
3417
|
+
*/
|
|
3418
|
+
getSelector(element) {
|
|
3419
|
+
if (!element)
|
|
3420
|
+
return '';
|
|
3421
|
+
if (element.id) {
|
|
3422
|
+
return `#${element.id}`;
|
|
3423
|
+
}
|
|
3424
|
+
const path = [];
|
|
3425
|
+
let current = element;
|
|
3426
|
+
while (current && current !== document.body) {
|
|
3427
|
+
let selector = current.tagName.toLowerCase();
|
|
3428
|
+
if (current.className) {
|
|
3429
|
+
const classes = current.className.toString().trim().split(/\s+/).slice(0, 2);
|
|
3430
|
+
if (classes.length) {
|
|
3431
|
+
selector += '.' + classes.join('.');
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
path.unshift(selector);
|
|
3435
|
+
current = current.parentElement;
|
|
3436
|
+
if (path.length >= 3)
|
|
3437
|
+
break;
|
|
3438
|
+
}
|
|
3439
|
+
return path.join(' > ');
|
|
3440
|
+
}
|
|
3441
|
+
/**
|
|
3442
|
+
* Get current configuration
|
|
3443
|
+
*/
|
|
3444
|
+
getConfig() {
|
|
3445
|
+
return { ...this.config };
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
/**
|
|
3450
|
+
* Analytics Tracker for CheckFlow SDK
|
|
3451
|
+
* Automatically tracks user sessions and interactions for analytics
|
|
3452
|
+
*/
|
|
3453
|
+
// ==================== Analytics Tracker ====================
|
|
3454
|
+
class AnalyticsTracker {
|
|
3455
|
+
constructor(apiClient, options = {}) {
|
|
3456
|
+
this.isActive = false;
|
|
3457
|
+
this.currentPageUrl = '';
|
|
3458
|
+
this.pageLoadStart = 0;
|
|
3459
|
+
// Batching
|
|
3460
|
+
this.interactionBuffer = [];
|
|
3461
|
+
this.batchTimer = null;
|
|
3462
|
+
// Event tracking state
|
|
3463
|
+
this.scrollEvents = 0;
|
|
3464
|
+
this.lastScrollTime = 0;
|
|
3465
|
+
this.isFirstPageView = true;
|
|
3466
|
+
// ==================== Private Methods - Event Handlers ====================
|
|
3467
|
+
this.handleClick = (event) => {
|
|
3468
|
+
const target = event.target;
|
|
3469
|
+
if (!target)
|
|
3470
|
+
return;
|
|
3471
|
+
const interaction = {
|
|
3472
|
+
pageUrl: window.location.href,
|
|
3473
|
+
pageTitle: document.title,
|
|
3474
|
+
eventType: 'click',
|
|
3475
|
+
elementSelector: this.getElementSelector(target),
|
|
3476
|
+
elementText: this.getElementText(target),
|
|
3477
|
+
mouseX: event.clientX,
|
|
3478
|
+
mouseY: event.clientY
|
|
3479
|
+
};
|
|
3480
|
+
this.queueInteraction(interaction);
|
|
3481
|
+
};
|
|
3482
|
+
this.handleScroll = () => {
|
|
3483
|
+
const now = Date.now();
|
|
3484
|
+
// Throttle scroll events
|
|
3485
|
+
if (now - this.lastScrollTime < this.options.throttleScrollMs)
|
|
3486
|
+
return;
|
|
3487
|
+
if (this.scrollEvents >= this.options.maxScrollEvents)
|
|
3488
|
+
return;
|
|
3489
|
+
this.lastScrollTime = now;
|
|
3490
|
+
this.scrollEvents++;
|
|
3491
|
+
const scrollDepth = this.calculateScrollDepth();
|
|
3492
|
+
const interaction = {
|
|
3493
|
+
pageUrl: window.location.href,
|
|
3494
|
+
pageTitle: document.title,
|
|
3495
|
+
eventType: 'scroll',
|
|
3496
|
+
scrollDepth: scrollDepth
|
|
3497
|
+
};
|
|
3498
|
+
this.queueInteraction(interaction);
|
|
3499
|
+
};
|
|
3500
|
+
this.handleFormFocus = (event) => {
|
|
3501
|
+
const target = event.target;
|
|
3502
|
+
if (!this.isFormElement(target))
|
|
3503
|
+
return;
|
|
3504
|
+
const interaction = {
|
|
3505
|
+
pageUrl: window.location.href,
|
|
3506
|
+
pageTitle: document.title,
|
|
3507
|
+
eventType: 'form_interaction',
|
|
3508
|
+
elementSelector: this.getElementSelector(target),
|
|
3509
|
+
elementText: this.getElementText(target)
|
|
3510
|
+
};
|
|
3511
|
+
this.queueInteraction(interaction);
|
|
3512
|
+
};
|
|
3513
|
+
this.handleFormChange = (event) => {
|
|
3514
|
+
const target = event.target;
|
|
3515
|
+
if (!this.isFormElement(target))
|
|
3516
|
+
return;
|
|
3517
|
+
// Don't capture actual form values for privacy
|
|
3518
|
+
const interaction = {
|
|
3519
|
+
pageUrl: window.location.href,
|
|
3520
|
+
pageTitle: document.title,
|
|
3521
|
+
eventType: 'form_interaction',
|
|
3522
|
+
elementSelector: this.getElementSelector(target)
|
|
3523
|
+
};
|
|
3524
|
+
this.queueInteraction(interaction);
|
|
3525
|
+
};
|
|
3526
|
+
this.handleError = (event) => {
|
|
3527
|
+
const interaction = {
|
|
3528
|
+
pageUrl: window.location.href,
|
|
3529
|
+
pageTitle: document.title,
|
|
3530
|
+
eventType: 'error',
|
|
3531
|
+
errorMessage: event.message,
|
|
3532
|
+
errorStack: event.error?.stack
|
|
3533
|
+
};
|
|
3534
|
+
this.queueInteraction(interaction);
|
|
3535
|
+
};
|
|
3536
|
+
this.handleUnhandledRejection = (event) => {
|
|
3537
|
+
const interaction = {
|
|
3538
|
+
pageUrl: window.location.href,
|
|
3539
|
+
pageTitle: document.title,
|
|
3540
|
+
eventType: 'error',
|
|
3541
|
+
errorMessage: `Unhandled Promise Rejection: ${event.reason}`,
|
|
3542
|
+
errorStack: event.reason?.stack
|
|
3543
|
+
};
|
|
3544
|
+
this.queueInteraction(interaction);
|
|
3545
|
+
};
|
|
3546
|
+
this.handlePageUnload = () => {
|
|
3547
|
+
// Send any pending interactions synchronously
|
|
3548
|
+
if (this.interactionBuffer.length > 0) {
|
|
3549
|
+
// Use sendBeacon for reliable delivery
|
|
3550
|
+
const data = JSON.stringify({ interactions: this.interactionBuffer });
|
|
3551
|
+
const url = `${this.apiClient.getBaseUrl()}/api/v1/analytics/sessions/${this.sessionId}/interactions/batch`;
|
|
3552
|
+
if (navigator.sendBeacon) {
|
|
3553
|
+
navigator.sendBeacon(url, data);
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
};
|
|
3557
|
+
this.handleVisibilityChange = () => {
|
|
3558
|
+
if (document.hidden) {
|
|
3559
|
+
// Page became hidden - flush interactions
|
|
3560
|
+
this.flushInteractions();
|
|
3561
|
+
}
|
|
3562
|
+
};
|
|
3563
|
+
this.apiClient = apiClient;
|
|
3564
|
+
this.options = {
|
|
3565
|
+
batchSize: 10,
|
|
3566
|
+
batchTimeout: 5000,
|
|
3567
|
+
trackClicks: true,
|
|
3568
|
+
trackScrolling: true,
|
|
3569
|
+
trackFormInteractions: true,
|
|
3570
|
+
trackErrors: true,
|
|
3571
|
+
trackPerformance: true,
|
|
3572
|
+
throttleScrollMs: 1000,
|
|
3573
|
+
maxScrollEvents: 50,
|
|
3574
|
+
debug: false,
|
|
3575
|
+
...options
|
|
3576
|
+
};
|
|
3577
|
+
this.sessionId = this.generateSessionId();
|
|
3578
|
+
this.projectId = apiClient.getProjectId() || '';
|
|
3579
|
+
this.userFingerprint = this.generateUserFingerprint();
|
|
3580
|
+
this.sessionStartTime = Date.now();
|
|
3581
|
+
}
|
|
3582
|
+
// ==================== Public Methods ====================
|
|
3583
|
+
async startTracking() {
|
|
3584
|
+
if (this.isActive) {
|
|
3585
|
+
this.log('Analytics tracking already active');
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
try {
|
|
3589
|
+
// Create session with backend
|
|
3590
|
+
await this.createSession();
|
|
3591
|
+
// Setup event listeners
|
|
3592
|
+
this.setupEventListeners();
|
|
3593
|
+
// Track initial page view
|
|
3594
|
+
this.trackPageView();
|
|
3595
|
+
this.isActive = true;
|
|
3596
|
+
this.log('Analytics tracking started', { sessionId: this.sessionId });
|
|
3597
|
+
}
|
|
3598
|
+
catch (error) {
|
|
3599
|
+
console.error('Failed to start analytics tracking:', error);
|
|
3600
|
+
throw error;
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
async stopTracking() {
|
|
3604
|
+
if (!this.isActive)
|
|
3605
|
+
return;
|
|
3606
|
+
try {
|
|
3607
|
+
// Flush any remaining interactions
|
|
3608
|
+
await this.flushInteractions();
|
|
3609
|
+
// Update session with end time
|
|
3610
|
+
await this.updateSessionEnd();
|
|
3611
|
+
// Remove event listeners
|
|
3612
|
+
this.removeEventListeners();
|
|
3613
|
+
// Clear timers
|
|
3614
|
+
if (this.batchTimer) {
|
|
3615
|
+
clearTimeout(this.batchTimer);
|
|
3616
|
+
this.batchTimer = null;
|
|
3617
|
+
}
|
|
3618
|
+
// Stop performance observer
|
|
3619
|
+
if (this.performanceObserver) {
|
|
3620
|
+
this.performanceObserver.disconnect();
|
|
3621
|
+
}
|
|
3622
|
+
this.isActive = false;
|
|
3623
|
+
this.log('Analytics tracking stopped');
|
|
3624
|
+
}
|
|
3625
|
+
catch (error) {
|
|
3626
|
+
console.error('Error stopping analytics tracking:', error);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
// ==================== Private Methods - Session Management ====================
|
|
3630
|
+
async createSession() {
|
|
3631
|
+
const sessionData = {
|
|
3632
|
+
sessionId: this.sessionId,
|
|
3633
|
+
projectId: this.projectId,
|
|
3634
|
+
userFingerprint: this.userFingerprint,
|
|
3635
|
+
// Technical context
|
|
3636
|
+
userAgent: navigator.userAgent,
|
|
3637
|
+
viewport: this.getViewportInfo(),
|
|
3638
|
+
browser: this.getBrowserInfo(),
|
|
3639
|
+
os: this.getOSInfo(),
|
|
3640
|
+
locale: navigator.language,
|
|
3641
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
3642
|
+
// Session data
|
|
3643
|
+
entryUrl: window.location.href,
|
|
3644
|
+
referrer: document.referrer || undefined
|
|
3645
|
+
};
|
|
3646
|
+
// Try to get geolocation (with permission)
|
|
3647
|
+
try {
|
|
3648
|
+
const geoData = await this.getGeolocation();
|
|
3649
|
+
if (geoData) {
|
|
3650
|
+
sessionData.latitude = geoData.latitude;
|
|
3651
|
+
sessionData.longitude = geoData.longitude;
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
catch (error) {
|
|
3655
|
+
// Geolocation failed or denied - continue without it
|
|
3656
|
+
this.log('Geolocation not available:', error);
|
|
3657
|
+
}
|
|
3658
|
+
const response = await this.apiClient.post('/api/v1/analytics/sessions', sessionData);
|
|
3659
|
+
this.log('Session created:', response);
|
|
3660
|
+
}
|
|
3661
|
+
async updateSessionEnd() {
|
|
3662
|
+
const sessionDuration = Math.floor((Date.now() - this.sessionStartTime) / 1000);
|
|
3663
|
+
const updateData = {
|
|
3664
|
+
sessionEnd: new Date().toISOString(),
|
|
3665
|
+
durationSeconds: sessionDuration,
|
|
3666
|
+
pageViews: this.getPageViewCount(),
|
|
3667
|
+
hasFeedback: false, // Will be updated when feedback is created
|
|
3668
|
+
hasErrors: this.scrollEvents > 0 // Simple heuristic for now
|
|
3669
|
+
};
|
|
3670
|
+
try {
|
|
3671
|
+
await this.apiClient.put(`/api/v1/analytics/sessions/${this.sessionId}`, updateData);
|
|
3672
|
+
this.log('Session updated:', updateData);
|
|
3673
|
+
}
|
|
3674
|
+
catch (error) {
|
|
3675
|
+
console.error('Failed to update session:', error);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
// ==================== Private Methods - Event Setup ====================
|
|
3679
|
+
setupEventListeners() {
|
|
3680
|
+
// Page navigation
|
|
3681
|
+
this.currentPageUrl = window.location.href;
|
|
3682
|
+
window.addEventListener('beforeunload', this.handlePageUnload);
|
|
3683
|
+
window.addEventListener('pagehide', this.handlePageUnload);
|
|
3684
|
+
// Page visibility changes
|
|
3685
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
3686
|
+
if (this.options.trackClicks) {
|
|
3687
|
+
document.addEventListener('click', this.handleClick, { passive: true });
|
|
3688
|
+
}
|
|
3689
|
+
if (this.options.trackScrolling) {
|
|
3690
|
+
window.addEventListener('scroll', this.handleScroll, { passive: true });
|
|
3691
|
+
}
|
|
3692
|
+
if (this.options.trackFormInteractions) {
|
|
3693
|
+
document.addEventListener('focusin', this.handleFormFocus, { passive: true });
|
|
3694
|
+
document.addEventListener('change', this.handleFormChange, { passive: true });
|
|
3695
|
+
}
|
|
3696
|
+
if (this.options.trackErrors) {
|
|
3697
|
+
window.addEventListener('error', this.handleError);
|
|
3698
|
+
window.addEventListener('unhandledrejection', this.handleUnhandledRejection);
|
|
3699
|
+
}
|
|
3700
|
+
if (this.options.trackPerformance && 'PerformanceObserver' in window) {
|
|
3701
|
+
this.setupPerformanceTracking();
|
|
3702
|
+
}
|
|
3703
|
+
// Track SPA navigation
|
|
3704
|
+
this.setupSPATracking();
|
|
3705
|
+
}
|
|
3706
|
+
removeEventListeners() {
|
|
3707
|
+
window.removeEventListener('beforeunload', this.handlePageUnload);
|
|
3708
|
+
window.removeEventListener('pagehide', this.handlePageUnload);
|
|
3709
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
3710
|
+
document.removeEventListener('click', this.handleClick);
|
|
3711
|
+
window.removeEventListener('scroll', this.handleScroll);
|
|
3712
|
+
document.removeEventListener('focusin', this.handleFormFocus);
|
|
3713
|
+
document.removeEventListener('change', this.handleFormChange);
|
|
3714
|
+
window.removeEventListener('error', this.handleError);
|
|
3715
|
+
window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
|
|
3716
|
+
}
|
|
3717
|
+
// ==================== Private Methods - SPA & Performance ====================
|
|
3718
|
+
setupSPATracking() {
|
|
3719
|
+
// Track URL changes for SPAs
|
|
3720
|
+
let currentUrl = window.location.href;
|
|
3721
|
+
const checkUrlChange = () => {
|
|
3722
|
+
const newUrl = window.location.href;
|
|
3723
|
+
if (newUrl !== currentUrl) {
|
|
3724
|
+
currentUrl = newUrl;
|
|
3725
|
+
this.trackPageView();
|
|
3726
|
+
}
|
|
3727
|
+
};
|
|
3728
|
+
// Listen for pushState/replaceState
|
|
3729
|
+
const originalPushState = history.pushState;
|
|
3730
|
+
const originalReplaceState = history.replaceState;
|
|
3731
|
+
history.pushState = function (...args) {
|
|
3732
|
+
originalPushState.apply(this, args);
|
|
3733
|
+
setTimeout(checkUrlChange, 0);
|
|
3734
|
+
};
|
|
3735
|
+
history.replaceState = function (...args) {
|
|
3736
|
+
originalReplaceState.apply(this, args);
|
|
3737
|
+
setTimeout(checkUrlChange, 0);
|
|
3738
|
+
};
|
|
3739
|
+
// Listen for popstate
|
|
3740
|
+
window.addEventListener('popstate', checkUrlChange);
|
|
3741
|
+
}
|
|
3742
|
+
setupPerformanceTracking() {
|
|
3743
|
+
try {
|
|
3744
|
+
this.performanceObserver = new PerformanceObserver((list) => {
|
|
3745
|
+
const entries = list.getEntries();
|
|
3746
|
+
for (const entry of entries) {
|
|
3747
|
+
if (entry.entryType === 'navigation') {
|
|
3748
|
+
this.trackPageLoadPerformance(entry);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
});
|
|
3752
|
+
this.performanceObserver.observe({
|
|
3753
|
+
entryTypes: ['navigation', 'paint', 'largest-contentful-paint']
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
catch (error) {
|
|
3757
|
+
this.log('Performance tracking setup failed:', error);
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
trackPageView() {
|
|
3761
|
+
const loadTime = this.isFirstPageView ? this.getPageLoadTime() : undefined;
|
|
3762
|
+
const domReadyTime = this.isFirstPageView ? this.getDOMReadyTime() : undefined;
|
|
3763
|
+
const interaction = {
|
|
3764
|
+
pageUrl: window.location.href,
|
|
3765
|
+
pageTitle: document.title,
|
|
3766
|
+
eventType: 'page_view',
|
|
3767
|
+
loadTime,
|
|
3768
|
+
domReadyTime
|
|
3769
|
+
};
|
|
3770
|
+
this.queueInteraction(interaction);
|
|
3771
|
+
this.isFirstPageView = false;
|
|
3772
|
+
// Reset scroll tracking for new page
|
|
3773
|
+
this.scrollEvents = 0;
|
|
3774
|
+
}
|
|
3775
|
+
trackPageLoadPerformance(timing) {
|
|
3776
|
+
const loadTime = Math.round(timing.loadEventEnd - timing.loadEventStart);
|
|
3777
|
+
const domReadyTime = Math.round(timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart);
|
|
3778
|
+
// Update the most recent page_view interaction with performance data
|
|
3779
|
+
const lastInteraction = this.interactionBuffer[this.interactionBuffer.length - 1];
|
|
3780
|
+
if (lastInteraction && lastInteraction.eventType === 'page_view') {
|
|
3781
|
+
lastInteraction.loadTime = loadTime;
|
|
3782
|
+
lastInteraction.domReadyTime = domReadyTime;
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
// ==================== Private Methods - Batching ====================
|
|
3786
|
+
queueInteraction(interaction) {
|
|
3787
|
+
this.interactionBuffer.push(interaction);
|
|
3788
|
+
this.log('Interaction queued:', interaction);
|
|
3789
|
+
// Flush if buffer is full
|
|
3790
|
+
if (this.interactionBuffer.length >= this.options.batchSize) {
|
|
3791
|
+
this.flushInteractions();
|
|
3792
|
+
}
|
|
3793
|
+
else {
|
|
3794
|
+
// Set timeout for batch sending
|
|
3795
|
+
if (this.batchTimer) {
|
|
3796
|
+
clearTimeout(this.batchTimer);
|
|
3797
|
+
}
|
|
3798
|
+
this.batchTimer = window.setTimeout(() => {
|
|
3799
|
+
this.flushInteractions();
|
|
3800
|
+
}, this.options.batchTimeout);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
async flushInteractions() {
|
|
3804
|
+
if (this.interactionBuffer.length === 0)
|
|
3805
|
+
return;
|
|
3806
|
+
const interactions = [...this.interactionBuffer];
|
|
3807
|
+
this.interactionBuffer = [];
|
|
3808
|
+
if (this.batchTimer) {
|
|
3809
|
+
clearTimeout(this.batchTimer);
|
|
3810
|
+
this.batchTimer = null;
|
|
3811
|
+
}
|
|
3812
|
+
try {
|
|
3813
|
+
await this.apiClient.post(`/api/v1/analytics/sessions/${this.sessionId}/interactions/batch`, { interactions });
|
|
3814
|
+
this.log(`Flushed ${interactions.length} interactions`);
|
|
3815
|
+
}
|
|
3816
|
+
catch (error) {
|
|
3817
|
+
console.error('Failed to send interactions:', error);
|
|
3818
|
+
// Re-queue interactions on failure (with limit to avoid infinite growth)
|
|
3819
|
+
if (this.interactionBuffer.length < 50) {
|
|
3820
|
+
this.interactionBuffer.unshift(...interactions);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
// ==================== Private Methods - Utilities ====================
|
|
3825
|
+
generateSessionId() {
|
|
3826
|
+
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
3827
|
+
}
|
|
3828
|
+
generateUserFingerprint() {
|
|
3829
|
+
// Create anonymous fingerprint from browser characteristics
|
|
3830
|
+
const canvas = document.createElement('canvas');
|
|
3831
|
+
const ctx = canvas.getContext('2d');
|
|
3832
|
+
ctx?.fillText('CheckFlow', 2, 2);
|
|
3833
|
+
const fingerprint = [
|
|
3834
|
+
navigator.userAgent,
|
|
3835
|
+
navigator.language,
|
|
3836
|
+
screen.width + 'x' + screen.height,
|
|
3837
|
+
screen.colorDepth,
|
|
3838
|
+
new Date().getTimezoneOffset(),
|
|
3839
|
+
canvas.toDataURL()
|
|
3840
|
+
].join('|');
|
|
3841
|
+
// Simple hash function
|
|
3842
|
+
let hash = 0;
|
|
3843
|
+
for (let i = 0; i < fingerprint.length; i++) {
|
|
3844
|
+
const char = fingerprint.charCodeAt(i);
|
|
3845
|
+
hash = ((hash << 5) - hash) + char;
|
|
3846
|
+
hash = hash & hash;
|
|
3847
|
+
}
|
|
3848
|
+
return Math.abs(hash).toString(36);
|
|
3849
|
+
}
|
|
3850
|
+
async getGeolocation() {
|
|
3851
|
+
return new Promise((resolve) => {
|
|
3852
|
+
if (!navigator.geolocation) {
|
|
3853
|
+
resolve(null);
|
|
3854
|
+
return;
|
|
3855
|
+
}
|
|
3856
|
+
navigator.geolocation.getCurrentPosition((position) => {
|
|
3857
|
+
resolve({
|
|
3858
|
+
latitude: position.coords.latitude,
|
|
3859
|
+
longitude: position.coords.longitude
|
|
3860
|
+
});
|
|
3861
|
+
}, () => resolve(null), { timeout: 5000, enableHighAccuracy: false });
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3864
|
+
getViewportInfo() {
|
|
3865
|
+
const width = window.innerWidth;
|
|
3866
|
+
const height = window.innerHeight;
|
|
3867
|
+
let device = 'desktop';
|
|
3868
|
+
if (width <= 768) {
|
|
3869
|
+
device = 'mobile';
|
|
3870
|
+
}
|
|
3871
|
+
else if (width <= 1024) {
|
|
3872
|
+
device = 'tablet';
|
|
3873
|
+
}
|
|
3874
|
+
return { width, height, device };
|
|
3875
|
+
}
|
|
3876
|
+
getBrowserInfo() {
|
|
3877
|
+
const userAgent = navigator.userAgent;
|
|
3878
|
+
if (userAgent.includes('Chrome'))
|
|
3879
|
+
return 'Chrome';
|
|
3880
|
+
if (userAgent.includes('Firefox'))
|
|
3881
|
+
return 'Firefox';
|
|
3882
|
+
if (userAgent.includes('Safari'))
|
|
3883
|
+
return 'Safari';
|
|
3884
|
+
if (userAgent.includes('Edge'))
|
|
3885
|
+
return 'Edge';
|
|
3886
|
+
return 'Unknown';
|
|
3887
|
+
}
|
|
3888
|
+
getOSInfo() {
|
|
3889
|
+
const platform = navigator.platform;
|
|
3890
|
+
if (platform.includes('Win'))
|
|
3891
|
+
return 'Windows';
|
|
3892
|
+
if (platform.includes('Mac'))
|
|
3893
|
+
return 'macOS';
|
|
3894
|
+
if (platform.includes('Linux'))
|
|
3895
|
+
return 'Linux';
|
|
3896
|
+
if (/iPhone|iPad|iPod/.test(navigator.userAgent))
|
|
3897
|
+
return 'iOS';
|
|
3898
|
+
if (/Android/.test(navigator.userAgent))
|
|
3899
|
+
return 'Android';
|
|
3900
|
+
return 'Unknown';
|
|
3901
|
+
}
|
|
3902
|
+
getElementSelector(element) {
|
|
3903
|
+
// Generate a CSS selector for the element
|
|
3904
|
+
if (element.id) {
|
|
3905
|
+
return `#${element.id}`;
|
|
3906
|
+
}
|
|
3907
|
+
if (element.className) {
|
|
3908
|
+
const classes = element.className.trim().split(/\s+/);
|
|
3909
|
+
return `${element.tagName.toLowerCase()}.${classes.join('.')}`;
|
|
3910
|
+
}
|
|
3911
|
+
return element.tagName.toLowerCase();
|
|
3912
|
+
}
|
|
3913
|
+
getElementText(element) {
|
|
3914
|
+
// Get text content, but limit length for privacy/storage
|
|
3915
|
+
const text = element.textContent?.trim() || '';
|
|
3916
|
+
return text.substring(0, 100);
|
|
3917
|
+
}
|
|
3918
|
+
isFormElement(element) {
|
|
3919
|
+
const tagName = element.tagName.toLowerCase();
|
|
3920
|
+
return ['input', 'textarea', 'select'].includes(tagName);
|
|
3921
|
+
}
|
|
3922
|
+
calculateScrollDepth() {
|
|
3923
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
3924
|
+
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
|
3925
|
+
const windowHeight = window.innerHeight;
|
|
3926
|
+
const scrollable = documentHeight - windowHeight;
|
|
3927
|
+
if (scrollable <= 0)
|
|
3928
|
+
return 100;
|
|
3929
|
+
return Math.round((scrollTop / scrollable) * 100);
|
|
3930
|
+
}
|
|
3931
|
+
getPageLoadTime() {
|
|
3932
|
+
if (performance.timing) {
|
|
3933
|
+
return performance.timing.loadEventEnd - performance.timing.navigationStart;
|
|
3934
|
+
}
|
|
3935
|
+
return undefined;
|
|
3936
|
+
}
|
|
3937
|
+
getDOMReadyTime() {
|
|
3938
|
+
if (performance.timing) {
|
|
3939
|
+
return performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;
|
|
3940
|
+
}
|
|
3941
|
+
return undefined;
|
|
3942
|
+
}
|
|
3943
|
+
getPageViewCount() {
|
|
3944
|
+
// Simple estimation based on interactions
|
|
3945
|
+
return this.interactionBuffer.filter(i => i.eventType === 'page_view').length + 1;
|
|
3946
|
+
}
|
|
3947
|
+
log(message, data) {
|
|
3948
|
+
if (this.options.debug) {
|
|
3949
|
+
console.log(`[CheckFlow Analytics] ${message}`, data);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
/**
|
|
3955
|
+
* CheckFlow SDK Main Class
|
|
3956
|
+
* Entry point for the CheckFlow feedback SDK
|
|
3957
|
+
*/
|
|
3958
|
+
const DEFAULT_OPTIONS = {
|
|
3959
|
+
apiUrl: 'https://api.checkflow.space',
|
|
3960
|
+
showWidget: true,
|
|
3961
|
+
widgetPosition: 'bottom-right',
|
|
3962
|
+
widgetButtonText: 'Feedback',
|
|
3963
|
+
captureErrors: true,
|
|
3964
|
+
captureConsole: true,
|
|
3965
|
+
captureNetwork: true,
|
|
3966
|
+
capturePerformance: true,
|
|
3967
|
+
maxConsoleEntries: 100,
|
|
3968
|
+
maxNetworkEntries: 100,
|
|
3969
|
+
debug: false,
|
|
3970
|
+
locale: 'en',
|
|
3971
|
+
privacy: {
|
|
3972
|
+
enabled: true,
|
|
3973
|
+
autoMask: {
|
|
3974
|
+
emails: true,
|
|
3975
|
+
creditCards: true,
|
|
3976
|
+
phoneNumbers: true,
|
|
3977
|
+
passwords: true,
|
|
3978
|
+
},
|
|
3979
|
+
excludeSelectors: [],
|
|
3980
|
+
},
|
|
3981
|
+
// Analytics options
|
|
3982
|
+
enableAnalytics: true,
|
|
3983
|
+
analytics: {
|
|
3984
|
+
trackClicks: true,
|
|
3985
|
+
trackScrolling: true,
|
|
3986
|
+
trackFormInteractions: true,
|
|
3987
|
+
trackErrors: true,
|
|
3988
|
+
trackPerformance: true,
|
|
3989
|
+
batchSize: 10,
|
|
3990
|
+
batchTimeout: 5000,
|
|
3991
|
+
debug: false,
|
|
3992
|
+
},
|
|
3993
|
+
};
|
|
3994
|
+
class CheckFlow {
|
|
3995
|
+
constructor(apiKey, options = {}) {
|
|
3996
|
+
this.sessionRecording = null;
|
|
3997
|
+
this.widget = null;
|
|
3998
|
+
this.analyticsTracker = null;
|
|
3999
|
+
this.isInitialized = false;
|
|
4000
|
+
this.apiKey = apiKey;
|
|
4001
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
4002
|
+
// Initialize API client
|
|
4003
|
+
this.apiClient = new APIClient({
|
|
4004
|
+
apiUrl: this.options.apiUrl,
|
|
4005
|
+
apiKey: this.apiKey,
|
|
4006
|
+
projectId: this.options.projectId,
|
|
4007
|
+
debug: this.options.debug,
|
|
4008
|
+
});
|
|
4009
|
+
// Initialize context capture
|
|
4010
|
+
this.contextCapture = new ContextCapture({
|
|
4011
|
+
maxConsoleEntries: this.options.maxConsoleEntries,
|
|
4012
|
+
maxNetworkEntries: this.options.maxNetworkEntries,
|
|
4013
|
+
});
|
|
4014
|
+
// Initialize error capture
|
|
4015
|
+
this.errorCapture = new ErrorCapture(this.handleError.bind(this));
|
|
4016
|
+
// Initialize privacy masker
|
|
4017
|
+
this.privacyMasker = new PrivacyMasker({
|
|
4018
|
+
enabled: this.options.privacy?.enabled ?? true,
|
|
4019
|
+
autoMask: {
|
|
4020
|
+
emails: this.options.privacy?.autoMask?.emails ?? true,
|
|
4021
|
+
creditCards: this.options.privacy?.autoMask?.creditCards ?? true,
|
|
4022
|
+
phoneNumbers: this.options.privacy?.autoMask?.phoneNumbers ?? true,
|
|
4023
|
+
passwords: this.options.privacy?.autoMask?.passwords ?? true,
|
|
4024
|
+
socialSecurity: true,
|
|
4025
|
+
ipAddresses: false,
|
|
4026
|
+
customPatterns: [],
|
|
4027
|
+
},
|
|
4028
|
+
excludeSelectors: this.options.privacy?.excludeSelectors || [],
|
|
4029
|
+
includeSelectors: [],
|
|
4030
|
+
maskChar: '•',
|
|
4031
|
+
maskLength: 'preserve',
|
|
4032
|
+
fixedMaskLength: 8,
|
|
4033
|
+
});
|
|
4034
|
+
// Initialize analytics tracker
|
|
4035
|
+
if (this.options.enableAnalytics) {
|
|
4036
|
+
this.analyticsTracker = new AnalyticsTracker(this.apiClient, {
|
|
4037
|
+
batchSize: this.options.analytics?.batchSize || 10,
|
|
4038
|
+
batchTimeout: this.options.analytics?.batchTimeout || 5000,
|
|
4039
|
+
trackClicks: this.options.analytics?.trackClicks ?? true,
|
|
4040
|
+
trackScrolling: this.options.analytics?.trackScrolling ?? true,
|
|
4041
|
+
trackFormInteractions: this.options.analytics?.trackFormInteractions ?? true,
|
|
4042
|
+
trackErrors: this.options.analytics?.trackErrors ?? true,
|
|
4043
|
+
trackPerformance: this.options.analytics?.trackPerformance ?? true,
|
|
4044
|
+
throttleScrollMs: 1000,
|
|
4045
|
+
maxScrollEvents: 50,
|
|
4046
|
+
debug: this.options.analytics?.debug || this.options.debug || false,
|
|
4047
|
+
});
|
|
4048
|
+
}
|
|
4049
|
+
// Store as singleton
|
|
4050
|
+
CheckFlow.instance = this;
|
|
4051
|
+
}
|
|
4052
|
+
/**
|
|
4053
|
+
* Initialize the SDK
|
|
4054
|
+
*/
|
|
4055
|
+
init() {
|
|
4056
|
+
if (this.isInitialized) {
|
|
4057
|
+
this.log('SDK already initialized');
|
|
4058
|
+
return this;
|
|
4059
|
+
}
|
|
4060
|
+
this.log('Initializing CheckFlow SDK...');
|
|
4061
|
+
// Start context capture
|
|
4062
|
+
if (this.options.captureConsole || this.options.captureNetwork) {
|
|
4063
|
+
this.contextCapture.startCapture();
|
|
4064
|
+
}
|
|
4065
|
+
// Start error capture
|
|
4066
|
+
if (this.options.captureErrors) {
|
|
4067
|
+
this.errorCapture.start();
|
|
4068
|
+
}
|
|
4069
|
+
// Start session recording
|
|
4070
|
+
if (this.options.captureSessionRecording !== false) {
|
|
4071
|
+
this.sessionRecording = new SessionRecording();
|
|
4072
|
+
this.sessionRecording.start();
|
|
4073
|
+
this.log('Session recording started');
|
|
4074
|
+
}
|
|
4075
|
+
// Start analytics tracking
|
|
4076
|
+
if (this.analyticsTracker) {
|
|
4077
|
+
try {
|
|
4078
|
+
this.analyticsTracker.startTracking();
|
|
4079
|
+
this.log('Analytics tracking started');
|
|
4080
|
+
}
|
|
4081
|
+
catch (error) {
|
|
4082
|
+
this.log('Failed to start analytics tracking:', error);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
// Show widget
|
|
4086
|
+
if (this.options.showWidget) {
|
|
4087
|
+
this.showWidget();
|
|
4088
|
+
}
|
|
4089
|
+
this.isInitialized = true;
|
|
4090
|
+
this.log('SDK initialized successfully');
|
|
4091
|
+
return this;
|
|
4092
|
+
}
|
|
4093
|
+
/**
|
|
4094
|
+
* Show the feedback widget
|
|
4095
|
+
*/
|
|
4096
|
+
showWidget() {
|
|
4097
|
+
if (this.widget) {
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
this.widget = new FeedbackWidget({
|
|
4101
|
+
position: this.options.widgetPosition,
|
|
4102
|
+
buttonText: this.options.widgetButtonText,
|
|
4103
|
+
locale: this.options.locale,
|
|
4104
|
+
translations: this.options.translations,
|
|
4105
|
+
onSubmit: this.handleWidgetSubmit.bind(this),
|
|
4106
|
+
onClose: () => this.log('Widget closed'),
|
|
4107
|
+
});
|
|
4108
|
+
this.widget.mount();
|
|
4109
|
+
this.log('Widget mounted');
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* Hide the feedback widget
|
|
4113
|
+
*/
|
|
4114
|
+
hideWidget() {
|
|
4115
|
+
if (this.widget) {
|
|
4116
|
+
this.widget.unmount();
|
|
4117
|
+
this.widget = null;
|
|
4118
|
+
this.log('Widget unmounted');
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
/**
|
|
4122
|
+
* Open the feedback form
|
|
4123
|
+
*/
|
|
4124
|
+
open() {
|
|
4125
|
+
if (!this.widget) {
|
|
4126
|
+
this.showWidget();
|
|
4127
|
+
}
|
|
4128
|
+
this.widget?.open();
|
|
4129
|
+
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Close the feedback form
|
|
4132
|
+
*/
|
|
4133
|
+
close() {
|
|
4134
|
+
this.widget?.close();
|
|
4135
|
+
}
|
|
4136
|
+
/**
|
|
4137
|
+
* Capture current page context
|
|
4138
|
+
*/
|
|
4139
|
+
async capture(options = {}) {
|
|
4140
|
+
this.log('Capturing context...');
|
|
4141
|
+
const result = await this.contextCapture.capture({
|
|
4142
|
+
includeConsole: options.includeConsole ?? this.options.captureConsole,
|
|
4143
|
+
includeNetwork: options.includeNetwork ?? this.options.captureNetwork,
|
|
4144
|
+
includePerformance: options.includePerformance ?? this.options.capturePerformance,
|
|
4145
|
+
...options,
|
|
4146
|
+
});
|
|
4147
|
+
this.log('Context captured', result);
|
|
4148
|
+
return result;
|
|
4149
|
+
}
|
|
4150
|
+
/**
|
|
4151
|
+
* Submit feedback
|
|
4152
|
+
*/
|
|
4153
|
+
async submitFeedback(feedback) {
|
|
4154
|
+
this.log('Submitting feedback...', feedback);
|
|
4155
|
+
// Call beforeSubmit hook
|
|
4156
|
+
if (this.options.beforeSubmit) {
|
|
4157
|
+
const result = this.options.beforeSubmit(feedback);
|
|
4158
|
+
if (result === false) {
|
|
4159
|
+
this.log('Submission cancelled by beforeSubmit hook');
|
|
4160
|
+
return { success: false, error: 'Submission cancelled' };
|
|
4161
|
+
}
|
|
4162
|
+
feedback = result;
|
|
4163
|
+
}
|
|
4164
|
+
// Capture context if requested
|
|
4165
|
+
let captureResult;
|
|
4166
|
+
if (feedback.includeScreenshot || feedback.includeConsoleLogs || feedback.includeNetworkLogs) {
|
|
4167
|
+
captureResult = await this.capture({
|
|
4168
|
+
includeConsole: feedback.includeConsoleLogs,
|
|
4169
|
+
includeNetwork: feedback.includeNetworkLogs,
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
// Merge user info
|
|
4173
|
+
const user = { ...this.options.user, ...feedback.user };
|
|
4174
|
+
// Get session recording if available
|
|
4175
|
+
let sessionRecording;
|
|
4176
|
+
if (this.sessionRecording && feedback.includeSessionRecording !== false) {
|
|
4177
|
+
const recordingData = this.sessionRecording.getRecordingData();
|
|
4178
|
+
if (recordingData && recordingData.events.length > 0) {
|
|
4179
|
+
sessionRecording = {
|
|
4180
|
+
events: recordingData.events,
|
|
4181
|
+
sessionId: recordingData.sessionId,
|
|
4182
|
+
};
|
|
4183
|
+
this.log('Including session recording', { eventCount: recordingData.events.length });
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
// Submit to API
|
|
4187
|
+
const result = await this.apiClient.submitFeedback(feedback, captureResult, user, sessionRecording);
|
|
4188
|
+
// Call onSubmit hook
|
|
4189
|
+
if (this.options.onSubmit) {
|
|
4190
|
+
this.options.onSubmit(result);
|
|
4191
|
+
}
|
|
4192
|
+
this.log('Feedback submitted', result);
|
|
4193
|
+
return result;
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* Capture and report an error
|
|
4197
|
+
*/
|
|
4198
|
+
captureException(error, context) {
|
|
4199
|
+
this.errorCapture.captureException(error, context);
|
|
4200
|
+
}
|
|
4201
|
+
/**
|
|
4202
|
+
* Set user information
|
|
4203
|
+
*/
|
|
4204
|
+
setUser(user) {
|
|
4205
|
+
this.options.user = { ...this.options.user, ...user };
|
|
4206
|
+
this.log('User set', user);
|
|
4207
|
+
}
|
|
4208
|
+
/**
|
|
4209
|
+
* Clear user information
|
|
4210
|
+
*/
|
|
4211
|
+
clearUser() {
|
|
4212
|
+
this.options.user = undefined;
|
|
4213
|
+
this.log('User cleared');
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* Add custom metadata
|
|
4217
|
+
*/
|
|
4218
|
+
setMetadata(metadata) {
|
|
4219
|
+
this.options.metadata = { ...this.options.metadata, ...metadata };
|
|
4220
|
+
}
|
|
4221
|
+
/**
|
|
4222
|
+
* Get console logs
|
|
4223
|
+
*/
|
|
4224
|
+
getConsoleLogs() {
|
|
4225
|
+
return this.contextCapture.getConsoleLogs();
|
|
4226
|
+
}
|
|
4227
|
+
/**
|
|
4228
|
+
* Get network logs
|
|
4229
|
+
*/
|
|
4230
|
+
getNetworkLogs() {
|
|
4231
|
+
return this.contextCapture.getNetworkLogs();
|
|
4232
|
+
}
|
|
4233
|
+
/**
|
|
4234
|
+
* Get captured errors
|
|
4235
|
+
*/
|
|
4236
|
+
getErrors() {
|
|
4237
|
+
return this.errorCapture.getErrors();
|
|
4238
|
+
}
|
|
4239
|
+
/**
|
|
4240
|
+
* Configure privacy settings
|
|
4241
|
+
*/
|
|
4242
|
+
configurePrivacy(config) {
|
|
4243
|
+
this.privacyMasker.configure({
|
|
4244
|
+
enabled: config.enabled,
|
|
4245
|
+
autoMask: config.autoMask ? {
|
|
4246
|
+
emails: config.autoMask.emails ?? true,
|
|
4247
|
+
creditCards: config.autoMask.creditCards ?? true,
|
|
4248
|
+
phoneNumbers: config.autoMask.phoneNumbers ?? true,
|
|
4249
|
+
passwords: config.autoMask.passwords ?? true,
|
|
4250
|
+
socialSecurity: true,
|
|
4251
|
+
ipAddresses: false,
|
|
4252
|
+
customPatterns: [],
|
|
4253
|
+
} : undefined,
|
|
4254
|
+
excludeSelectors: config.excludeSelectors,
|
|
4255
|
+
});
|
|
4256
|
+
this.log('Privacy configured', config);
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* Mask text using privacy rules
|
|
4260
|
+
*/
|
|
4261
|
+
maskText(text) {
|
|
4262
|
+
return this.privacyMasker.maskText(text);
|
|
4263
|
+
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Get privacy masker instance
|
|
4266
|
+
*/
|
|
4267
|
+
getPrivacyMasker() {
|
|
4268
|
+
return this.privacyMasker;
|
|
4269
|
+
}
|
|
4270
|
+
/**
|
|
4271
|
+
* Destroy the SDK instance
|
|
4272
|
+
*/
|
|
4273
|
+
destroy() {
|
|
4274
|
+
this.log('Destroying SDK...');
|
|
4275
|
+
this.hideWidget();
|
|
4276
|
+
this.contextCapture.stopCapture();
|
|
4277
|
+
this.errorCapture.stop();
|
|
4278
|
+
this.isInitialized = false;
|
|
4279
|
+
if (CheckFlow.instance === this) {
|
|
4280
|
+
CheckFlow.instance = null;
|
|
4281
|
+
}
|
|
4282
|
+
this.log('SDK destroyed');
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Get the singleton instance
|
|
4286
|
+
*/
|
|
4287
|
+
static getInstance() {
|
|
4288
|
+
return CheckFlow.instance;
|
|
4289
|
+
}
|
|
4290
|
+
// Private methods
|
|
4291
|
+
async handleWidgetSubmit(data) {
|
|
4292
|
+
// Prepare feedback with user info from widget form
|
|
4293
|
+
const feedback = {
|
|
4294
|
+
title: `Rapport de ${data.name}`,
|
|
4295
|
+
description: data.description,
|
|
4296
|
+
type: data.type,
|
|
4297
|
+
priority: data.priority,
|
|
4298
|
+
includeScreenshot: !!data.screenshot,
|
|
4299
|
+
includeConsoleLogs: data.includeConsole,
|
|
4300
|
+
includeNetworkLogs: data.includeNetwork,
|
|
4301
|
+
user: {
|
|
4302
|
+
name: data.name,
|
|
4303
|
+
email: data.email,
|
|
4304
|
+
},
|
|
4305
|
+
};
|
|
4306
|
+
// If screenshot provided directly from widget, add to capture result
|
|
4307
|
+
let captureResult;
|
|
4308
|
+
if (data.screenshot) {
|
|
4309
|
+
captureResult = await this.contextCapture.capture({
|
|
4310
|
+
includeConsole: data.includeConsole,
|
|
4311
|
+
includeNetwork: data.includeNetwork,
|
|
4312
|
+
includePerformance: true,
|
|
4313
|
+
});
|
|
4314
|
+
captureResult.screenshot = data.screenshot;
|
|
4315
|
+
}
|
|
4316
|
+
// Submit with annotations
|
|
4317
|
+
const result = await this.apiClient.submitFeedback(feedback, captureResult, { name: data.name, email: data.email }, undefined, // session recording
|
|
4318
|
+
data.annotations);
|
|
4319
|
+
if (!result.success) {
|
|
4320
|
+
throw new Error(result.error || 'Failed to submit feedback');
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
handleError(error) {
|
|
4324
|
+
this.log('Error captured', error);
|
|
4325
|
+
// Report to backend if configured
|
|
4326
|
+
if (this.options.captureErrors) {
|
|
4327
|
+
this.apiClient.reportError({
|
|
4328
|
+
message: error.message,
|
|
4329
|
+
stack: error.stack,
|
|
4330
|
+
type: error.type,
|
|
4331
|
+
filename: error.filename,
|
|
4332
|
+
lineno: error.lineno,
|
|
4333
|
+
colno: error.colno,
|
|
4334
|
+
context: error.context,
|
|
4335
|
+
}).catch((e) => this.log('Failed to report error', e));
|
|
4336
|
+
}
|
|
4337
|
+
// Call user error handler
|
|
4338
|
+
if (this.options.onError) {
|
|
4339
|
+
this.options.onError(new Error(error.message));
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
log(...args) {
|
|
4343
|
+
if (this.options.debug) {
|
|
4344
|
+
console.log('[CheckFlow]', ...args);
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
CheckFlow.instance = null;
|
|
4349
|
+
/**
|
|
4350
|
+
* Factory function for creating CheckFlow instance
|
|
4351
|
+
*/
|
|
4352
|
+
function createCheckFlow(apiKey, options) {
|
|
4353
|
+
return new CheckFlow(apiKey, options).init();
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
const CheckFlowContext = react.createContext(null);
|
|
4357
|
+
function CheckFlowProvider({ apiKey, options = {}, children }) {
|
|
4358
|
+
const [checkflow, setCheckflow] = react.useState(null);
|
|
4359
|
+
const [isReady, setIsReady] = react.useState(false);
|
|
4360
|
+
react.useEffect(() => {
|
|
4361
|
+
const instance = createCheckFlow(apiKey, options);
|
|
4362
|
+
setCheckflow(instance);
|
|
4363
|
+
setIsReady(true);
|
|
4364
|
+
return () => {
|
|
4365
|
+
instance.destroy();
|
|
4366
|
+
};
|
|
4367
|
+
}, [apiKey]);
|
|
4368
|
+
const capture = react.useCallback(async () => {
|
|
4369
|
+
if (!checkflow)
|
|
4370
|
+
return null;
|
|
4371
|
+
return checkflow.capture();
|
|
4372
|
+
}, [checkflow]);
|
|
4373
|
+
const submitFeedback = react.useCallback(async (feedback) => {
|
|
4374
|
+
if (!checkflow) {
|
|
4375
|
+
return { success: false, error: 'CheckFlow not initialized' };
|
|
4376
|
+
}
|
|
4377
|
+
return checkflow.submitFeedback(feedback);
|
|
4378
|
+
}, [checkflow]);
|
|
4379
|
+
const openWidget = react.useCallback(() => {
|
|
4380
|
+
checkflow?.open();
|
|
4381
|
+
}, [checkflow]);
|
|
4382
|
+
const closeWidget = react.useCallback(() => {
|
|
4383
|
+
checkflow?.close();
|
|
4384
|
+
}, [checkflow]);
|
|
4385
|
+
const setUser = react.useCallback((user) => {
|
|
4386
|
+
checkflow?.setUser(user);
|
|
4387
|
+
}, [checkflow]);
|
|
4388
|
+
const clearUser = react.useCallback(() => {
|
|
4389
|
+
checkflow?.clearUser();
|
|
4390
|
+
}, [checkflow]);
|
|
4391
|
+
const value = react.useMemo(() => ({
|
|
4392
|
+
checkflow,
|
|
4393
|
+
isReady,
|
|
4394
|
+
capture,
|
|
4395
|
+
submitFeedback,
|
|
4396
|
+
openWidget,
|
|
4397
|
+
closeWidget,
|
|
4398
|
+
setUser,
|
|
4399
|
+
clearUser,
|
|
4400
|
+
}), [checkflow, isReady, capture, submitFeedback, openWidget, closeWidget, setUser, clearUser]);
|
|
4401
|
+
return (jsxRuntime.jsx(CheckFlowContext.Provider, { value: value, children: children }));
|
|
4402
|
+
}
|
|
4403
|
+
// ==================== Hook ====================
|
|
4404
|
+
function useCheckFlow() {
|
|
4405
|
+
const context = react.useContext(CheckFlowContext);
|
|
4406
|
+
if (!context) {
|
|
4407
|
+
throw new Error('useCheckFlow must be used within a CheckFlowProvider');
|
|
4408
|
+
}
|
|
4409
|
+
return context;
|
|
4410
|
+
}
|
|
4411
|
+
function useFeedbackForm(options = {}) {
|
|
4412
|
+
const { submitFeedback, capture } = useCheckFlow();
|
|
4413
|
+
const [isSubmitting, setIsSubmitting] = react.useState(false);
|
|
4414
|
+
const [error, setError] = react.useState(null);
|
|
4415
|
+
const [screenshot, setScreenshot] = react.useState(null);
|
|
4416
|
+
const [formState, setFormState] = react.useState({
|
|
4417
|
+
title: '',
|
|
4418
|
+
description: '',
|
|
4419
|
+
type: options.defaultType || 'bug',
|
|
4420
|
+
priority: options.defaultPriority || 'medium',
|
|
4421
|
+
includeScreenshot: true,
|
|
4422
|
+
includeConsoleLogs: true,
|
|
4423
|
+
includeNetworkLogs: true,
|
|
4424
|
+
});
|
|
4425
|
+
const updateField = react.useCallback((field, value) => {
|
|
4426
|
+
setFormState(prev => ({ ...prev, [field]: value }));
|
|
4427
|
+
}, []);
|
|
4428
|
+
const captureScreenshot = react.useCallback(async () => {
|
|
4429
|
+
const result = await capture();
|
|
4430
|
+
if (result?.screenshot) {
|
|
4431
|
+
setScreenshot(result.screenshot);
|
|
4432
|
+
}
|
|
4433
|
+
return result;
|
|
4434
|
+
}, [capture]);
|
|
4435
|
+
const submit = react.useCallback(async () => {
|
|
4436
|
+
if (!formState.title.trim()) {
|
|
4437
|
+
setError('Title is required');
|
|
4438
|
+
return null;
|
|
4439
|
+
}
|
|
4440
|
+
setIsSubmitting(true);
|
|
4441
|
+
setError(null);
|
|
4442
|
+
try {
|
|
4443
|
+
const result = await submitFeedback({
|
|
4444
|
+
...formState,
|
|
4445
|
+
});
|
|
4446
|
+
if (result.success) {
|
|
4447
|
+
options.onSuccess?.(result);
|
|
4448
|
+
// Reset form
|
|
4449
|
+
setFormState({
|
|
4450
|
+
title: '',
|
|
4451
|
+
description: '',
|
|
4452
|
+
type: options.defaultType || 'bug',
|
|
4453
|
+
priority: options.defaultPriority || 'medium',
|
|
4454
|
+
includeScreenshot: true,
|
|
4455
|
+
includeConsoleLogs: true,
|
|
4456
|
+
includeNetworkLogs: true,
|
|
4457
|
+
});
|
|
4458
|
+
setScreenshot(null);
|
|
4459
|
+
}
|
|
4460
|
+
else {
|
|
4461
|
+
setError(result.error || 'Failed to submit feedback');
|
|
4462
|
+
options.onError?.(new Error(result.error));
|
|
4463
|
+
}
|
|
4464
|
+
return result;
|
|
4465
|
+
}
|
|
4466
|
+
catch (err) {
|
|
4467
|
+
const errorMessage = err.message || 'An error occurred';
|
|
4468
|
+
setError(errorMessage);
|
|
4469
|
+
options.onError?.(err);
|
|
4470
|
+
return null;
|
|
4471
|
+
}
|
|
4472
|
+
finally {
|
|
4473
|
+
setIsSubmitting(false);
|
|
4474
|
+
}
|
|
4475
|
+
}, [formState, submitFeedback, options]);
|
|
4476
|
+
const reset = react.useCallback(() => {
|
|
4477
|
+
setFormState({
|
|
4478
|
+
title: '',
|
|
4479
|
+
description: '',
|
|
4480
|
+
type: options.defaultType || 'bug',
|
|
4481
|
+
priority: options.defaultPriority || 'medium',
|
|
4482
|
+
includeScreenshot: true,
|
|
4483
|
+
includeConsoleLogs: true,
|
|
4484
|
+
includeNetworkLogs: true,
|
|
4485
|
+
});
|
|
4486
|
+
setScreenshot(null);
|
|
4487
|
+
setError(null);
|
|
4488
|
+
}, [options.defaultType, options.defaultPriority]);
|
|
4489
|
+
return {
|
|
4490
|
+
formState,
|
|
4491
|
+
updateField,
|
|
4492
|
+
screenshot,
|
|
4493
|
+
captureScreenshot,
|
|
4494
|
+
submit,
|
|
4495
|
+
reset,
|
|
4496
|
+
isSubmitting,
|
|
4497
|
+
error,
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
class CheckFlowErrorBoundary extends react.Component {
|
|
4501
|
+
constructor(props) {
|
|
4502
|
+
super(props);
|
|
4503
|
+
this.reset = () => {
|
|
4504
|
+
this.setState({ hasError: false, error: null });
|
|
4505
|
+
};
|
|
4506
|
+
this.state = { hasError: false, error: null };
|
|
4507
|
+
}
|
|
4508
|
+
static getDerivedStateFromError(error) {
|
|
4509
|
+
return { hasError: true, error };
|
|
4510
|
+
}
|
|
4511
|
+
componentDidCatch(error, errorInfo) {
|
|
4512
|
+
// Call user handler
|
|
4513
|
+
this.props.onError?.(error, errorInfo);
|
|
4514
|
+
// Report to CheckFlow
|
|
4515
|
+
if (this.props.reportToCheckFlow !== false) {
|
|
4516
|
+
const checkflow = CheckFlow.getInstance();
|
|
4517
|
+
if (checkflow) {
|
|
4518
|
+
checkflow.captureException(error, {
|
|
4519
|
+
componentStack: errorInfo.componentStack,
|
|
4520
|
+
});
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
render() {
|
|
4525
|
+
if (this.state.hasError && this.state.error) {
|
|
4526
|
+
const { fallback } = this.props;
|
|
4527
|
+
if (typeof fallback === 'function') {
|
|
4528
|
+
return fallback(this.state.error, this.reset);
|
|
4529
|
+
}
|
|
4530
|
+
if (fallback) {
|
|
4531
|
+
return fallback;
|
|
4532
|
+
}
|
|
4533
|
+
// Default fallback
|
|
4534
|
+
return (jsxRuntime.jsxs("div", { style: {
|
|
4535
|
+
padding: '20px',
|
|
4536
|
+
background: '#fef2f2',
|
|
4537
|
+
border: '1px solid #fecaca',
|
|
4538
|
+
borderRadius: '8px',
|
|
4539
|
+
color: '#991b1b',
|
|
4540
|
+
}, children: [jsxRuntime.jsx("h3", { style: { margin: '0 0 8px' }, children: "Something went wrong" }), jsxRuntime.jsx("p", { style: { margin: '0 0 12px', fontSize: '14px' }, children: this.state.error.message }), jsxRuntime.jsx("button", { onClick: this.reset, style: {
|
|
4541
|
+
padding: '8px 16px',
|
|
4542
|
+
background: '#dc2626',
|
|
4543
|
+
color: 'white',
|
|
4544
|
+
border: 'none',
|
|
4545
|
+
borderRadius: '4px',
|
|
4546
|
+
cursor: 'pointer',
|
|
4547
|
+
}, children: "Try again" })] }));
|
|
4548
|
+
}
|
|
4549
|
+
return this.props.children;
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
// ==================== HOC ====================
|
|
4553
|
+
function withCheckFlow(WrappedComponent) {
|
|
4554
|
+
return function WithCheckFlowComponent(props) {
|
|
4555
|
+
const checkflow = useCheckFlow();
|
|
4556
|
+
return jsxRuntime.jsx(WrappedComponent, { ...props, checkflow: checkflow });
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
function withErrorBoundary(WrappedComponent, errorBoundaryProps) {
|
|
4560
|
+
return function WithErrorBoundaryComponent(props) {
|
|
4561
|
+
return (jsxRuntime.jsx(CheckFlowErrorBoundary, { ...errorBoundaryProps, children: jsxRuntime.jsx(WrappedComponent, { ...props }) }));
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
4564
|
+
function FeedbackButton({ children, className, style }) {
|
|
4565
|
+
const { openWidget } = useCheckFlow();
|
|
4566
|
+
return (jsxRuntime.jsx("button", { onClick: openWidget, className: className, style: style, children: children || 'Send Feedback' }));
|
|
4567
|
+
}
|
|
4568
|
+
|
|
4569
|
+
/**
|
|
4570
|
+
* CheckFlow SDK
|
|
4571
|
+
* User feedback collection with automatic context capture
|
|
4572
|
+
*
|
|
4573
|
+
* @packageDocumentation
|
|
4574
|
+
*/
|
|
4575
|
+
// Main exports
|
|
4576
|
+
// Version
|
|
4577
|
+
const VERSION = '1.0.0';
|
|
4578
|
+
|
|
4579
|
+
exports.APIClient = APIClient;
|
|
4580
|
+
exports.AnnotationEditor = AnnotationEditor;
|
|
4581
|
+
exports.AnnotationToolbar = AnnotationToolbar;
|
|
4582
|
+
exports.BLUR_STYLE = BLUR_STYLE;
|
|
4583
|
+
exports.COLOR_PALETTE = COLOR_PALETTE;
|
|
4584
|
+
exports.CheckFlow = CheckFlow;
|
|
4585
|
+
exports.CheckFlowErrorBoundary = CheckFlowErrorBoundary;
|
|
4586
|
+
exports.CheckFlowProvider = CheckFlowProvider;
|
|
4587
|
+
exports.ContextCapture = ContextCapture;
|
|
4588
|
+
exports.DEFAULT_PRIVACY_CONFIG = DEFAULT_PRIVACY_CONFIG;
|
|
4589
|
+
exports.DEFAULT_STYLE = DEFAULT_STYLE;
|
|
4590
|
+
exports.DEFAULT_TRANSLATIONS = DEFAULT_TRANSLATIONS;
|
|
4591
|
+
exports.ErrorCapture = ErrorCapture;
|
|
4592
|
+
exports.FeedbackButton = FeedbackButton;
|
|
4593
|
+
exports.FeedbackWidget = FeedbackWidget;
|
|
4594
|
+
exports.HIGHLIGHT_STYLE = HIGHLIGHT_STYLE;
|
|
4595
|
+
exports.PRIVACY_PATTERNS = PATTERNS;
|
|
4596
|
+
exports.PrivacyDetector = PrivacyDetector;
|
|
4597
|
+
exports.PrivacyMasker = PrivacyMasker;
|
|
4598
|
+
exports.STROKE_WIDTHS = STROKE_WIDTHS;
|
|
4599
|
+
exports.SessionRecording = SessionRecording;
|
|
4600
|
+
exports.TRANSLATIONS = TRANSLATIONS;
|
|
4601
|
+
exports.VERSION = VERSION;
|
|
4602
|
+
exports.capturePageContext = capturePageContext;
|
|
4603
|
+
exports.capturePerformance = capturePerformance;
|
|
4604
|
+
exports.captureScreenshot = captureScreenshot;
|
|
4605
|
+
exports.createCheckFlow = createCheckFlow;
|
|
4606
|
+
exports.default = CheckFlow;
|
|
4607
|
+
exports.injectAnnotationStyles = injectAnnotationStyles;
|
|
4608
|
+
exports.removeAnnotationStyles = removeAnnotationStyles;
|
|
4609
|
+
exports.useCheckFlow = useCheckFlow;
|
|
4610
|
+
exports.useFeedbackForm = useFeedbackForm;
|
|
4611
|
+
exports.withCheckFlow = withCheckFlow;
|
|
4612
|
+
exports.withErrorBoundary = withErrorBoundary;
|
|
4613
|
+
//# sourceMappingURL=index.js.map
|