@triagly/sdk 0.1.2 → 1.4.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -1,3 +1,950 @@
1
+ // Screenshot Capture Module
2
+ // Dynamically loads html2canvas and captures the page
3
+ // CDN URLs for html2canvas with fallbacks
4
+ const HTML2CANVAS_CDN_URLS = [
5
+ 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
6
+ 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js',
7
+ 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js',
8
+ ];
9
+ class ScreenshotCapture {
10
+ constructor(config = {}) {
11
+ this.html2canvasLoaded = false;
12
+ this.loadPromise = null;
13
+ this.quality = config.quality ?? 0.8;
14
+ this.maxWidth = config.maxWidth ?? 1920;
15
+ this.options = config.html2canvasOptions ?? {};
16
+ this.customCdnUrl = config.html2canvasCdnUrl;
17
+ }
18
+ /**
19
+ * Dynamically load html2canvas from CDN with fallbacks
20
+ * Returns true if loaded successfully, false otherwise
21
+ */
22
+ async loadHtml2Canvas() {
23
+ // Return cached result if already attempted
24
+ if (this.html2canvasLoaded && window.html2canvas) {
25
+ return true;
26
+ }
27
+ // Return existing promise if load is in progress
28
+ if (this.loadPromise) {
29
+ return this.loadPromise;
30
+ }
31
+ // Check if already available in window (user may have loaded it)
32
+ if (window.html2canvas) {
33
+ this.html2canvasLoaded = true;
34
+ return true;
35
+ }
36
+ // Build list of CDNs to try (custom URL first if provided)
37
+ const cdnUrls = this.customCdnUrl
38
+ ? [this.customCdnUrl, ...HTML2CANVAS_CDN_URLS]
39
+ : HTML2CANVAS_CDN_URLS;
40
+ // Try loading from CDNs with fallback
41
+ this.loadPromise = this.tryLoadFromCdns(cdnUrls);
42
+ return this.loadPromise;
43
+ }
44
+ /**
45
+ * Try loading html2canvas from multiple CDN URLs
46
+ */
47
+ async tryLoadFromCdns(urls) {
48
+ for (const url of urls) {
49
+ const success = await this.loadScriptFromUrl(url);
50
+ if (success && window.html2canvas) {
51
+ this.html2canvasLoaded = true;
52
+ return true;
53
+ }
54
+ }
55
+ console.error('[Triagly] Failed to load html2canvas from all CDN sources');
56
+ return false;
57
+ }
58
+ /**
59
+ * Load a script from a specific URL
60
+ */
61
+ loadScriptFromUrl(url) {
62
+ return new Promise((resolve) => {
63
+ const script = document.createElement('script');
64
+ script.src = url;
65
+ script.async = true;
66
+ const cleanup = () => {
67
+ script.onload = null;
68
+ script.onerror = null;
69
+ };
70
+ script.onload = () => {
71
+ cleanup();
72
+ resolve(true);
73
+ };
74
+ script.onerror = () => {
75
+ cleanup();
76
+ // Remove failed script from DOM
77
+ script.remove();
78
+ resolve(false);
79
+ };
80
+ document.head.appendChild(script);
81
+ });
82
+ }
83
+ /**
84
+ * Capture screenshot of the current page
85
+ * Returns base64 data URL of the screenshot
86
+ */
87
+ async capture() {
88
+ const loaded = await this.loadHtml2Canvas();
89
+ if (!loaded || !window.html2canvas) {
90
+ throw new Error('Screenshot capture requires html2canvas. ' +
91
+ 'Include <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> ' +
92
+ 'or ensure network access to CDN.');
93
+ }
94
+ // Hide the Triagly widget elements during capture
95
+ const widgetElements = document.querySelectorAll('.triagly-overlay, .triagly-button, #triagly-button');
96
+ const originalVisibility = new Map();
97
+ widgetElements.forEach((el) => {
98
+ const htmlEl = el;
99
+ originalVisibility.set(el, htmlEl.style.visibility);
100
+ htmlEl.style.visibility = 'hidden';
101
+ });
102
+ try {
103
+ const canvas = await window.html2canvas(document.body, {
104
+ useCORS: true,
105
+ allowTaint: false,
106
+ logging: false,
107
+ backgroundColor: null,
108
+ scale: Math.min(window.devicePixelRatio || 1, 2), // Cap at 2x for performance
109
+ ...this.options,
110
+ });
111
+ // Resize if needed
112
+ const resized = this.resizeCanvas(canvas);
113
+ // Convert to JPEG data URL
114
+ return resized.toDataURL('image/jpeg', this.quality);
115
+ }
116
+ finally {
117
+ // Restore widget visibility
118
+ widgetElements.forEach((el) => {
119
+ const htmlEl = el;
120
+ htmlEl.style.visibility = originalVisibility.get(el) || '';
121
+ });
122
+ }
123
+ }
124
+ /**
125
+ * Resize canvas if it exceeds maxWidth
126
+ */
127
+ resizeCanvas(canvas) {
128
+ if (canvas.width <= this.maxWidth) {
129
+ return canvas;
130
+ }
131
+ const ratio = this.maxWidth / canvas.width;
132
+ const resized = document.createElement('canvas');
133
+ resized.width = this.maxWidth;
134
+ resized.height = Math.round(canvas.height * ratio);
135
+ const ctx = resized.getContext('2d');
136
+ if (ctx) {
137
+ // Use high-quality image smoothing
138
+ ctx.imageSmoothingEnabled = true;
139
+ ctx.imageSmoothingQuality = 'high';
140
+ ctx.drawImage(canvas, 0, 0, resized.width, resized.height);
141
+ }
142
+ return resized;
143
+ }
144
+ /**
145
+ * Get approximate file size of a data URL in bytes
146
+ */
147
+ static getDataUrlSize(dataUrl) {
148
+ // Remove data URL prefix to get base64 content
149
+ const base64 = dataUrl.split(',')[1] || '';
150
+ // Base64 encodes 3 bytes into 4 characters
151
+ return Math.round((base64.length * 3) / 4);
152
+ }
153
+ }
154
+
155
+ // Annotation Editor Module
156
+ // Canvas-based annotation overlay for marking up screenshots
157
+ class AnnotationEditor {
158
+ constructor(imageData, onComplete, onCancel) {
159
+ this.overlay = null;
160
+ this.canvas = null;
161
+ this.ctx = null;
162
+ this.image = null;
163
+ this.annotations = [];
164
+ this.currentTool = 'highlight';
165
+ this.currentColor = '#ff0000';
166
+ this.strokeWidth = 3;
167
+ this.isDrawing = false;
168
+ this.currentAnnotation = null;
169
+ this.scale = 1;
170
+ this.offsetX = 0;
171
+ this.offsetY = 0;
172
+ this.pendingTextPoint = null;
173
+ this.handleKeyDown = (e) => {
174
+ if (e.key === 'Escape') {
175
+ this.close(true);
176
+ }
177
+ };
178
+ this.imageData = imageData;
179
+ this.onComplete = onComplete;
180
+ this.onCancel = onCancel;
181
+ }
182
+ /**
183
+ * Open the annotation editor
184
+ */
185
+ async open() {
186
+ // Load the image first
187
+ this.image = await this.loadImage(this.imageData);
188
+ // Create the overlay UI
189
+ this.createOverlay();
190
+ // Set up the canvas
191
+ this.setupCanvas();
192
+ // Draw the image
193
+ this.render();
194
+ }
195
+ /**
196
+ * Load image from data URL
197
+ */
198
+ loadImage(dataUrl) {
199
+ return new Promise((resolve, reject) => {
200
+ const img = new Image();
201
+ img.onload = () => resolve(img);
202
+ img.onerror = () => reject(new Error('Failed to load image'));
203
+ img.src = dataUrl;
204
+ });
205
+ }
206
+ /**
207
+ * Create the fullscreen overlay UI
208
+ */
209
+ createOverlay() {
210
+ this.overlay = document.createElement('div');
211
+ this.overlay.className = 'triagly-annotation-overlay';
212
+ this.overlay.innerHTML = `
213
+ <div class="triagly-annotation-header">
214
+ <div class="triagly-annotation-tools">
215
+ <button type="button" class="triagly-tool-btn active" data-tool="highlight" title="Highlight">
216
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
217
+ <rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" fill="currentColor"/>
218
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
219
+ </svg>
220
+ </button>
221
+ <button type="button" class="triagly-tool-btn" data-tool="arrow" title="Arrow">
222
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
223
+ <line x1="5" y1="19" x2="19" y2="5"/>
224
+ <polyline points="10,5 19,5 19,14"/>
225
+ </svg>
226
+ </button>
227
+ <button type="button" class="triagly-tool-btn" data-tool="freehand" title="Draw">
228
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
229
+ <path d="M12 19l7-7 3 3-7 7-3-3z"/>
230
+ <path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
231
+ <path d="M2 2l7.586 7.586"/>
232
+ </svg>
233
+ </button>
234
+ <button type="button" class="triagly-tool-btn" data-tool="text" title="Text">
235
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
236
+ <polyline points="4 7 4 4 20 4 20 7"/>
237
+ <line x1="9" y1="20" x2="15" y2="20"/>
238
+ <line x1="12" y1="4" x2="12" y2="20"/>
239
+ </svg>
240
+ </button>
241
+ <div class="triagly-tool-divider"></div>
242
+ <input type="color" class="triagly-color-picker" value="#ff0000" title="Color">
243
+ </div>
244
+ <div class="triagly-annotation-actions">
245
+ <button type="button" class="triagly-annotation-btn triagly-btn-clear" title="Clear All">
246
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
247
+ <polyline points="3 6 5 6 21 6"/>
248
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
249
+ </svg>
250
+ Clear
251
+ </button>
252
+ <button type="button" class="triagly-annotation-btn triagly-btn-cancel">Cancel</button>
253
+ <button type="button" class="triagly-annotation-btn triagly-btn-done">Done</button>
254
+ </div>
255
+ </div>
256
+ <div class="triagly-annotation-canvas-container">
257
+ <canvas class="triagly-annotation-canvas"></canvas>
258
+ </div>
259
+ <div class="triagly-text-modal" id="triagly-text-modal" style="display: none;">
260
+ <div class="triagly-text-modal-content">
261
+ <h3>Add Text Annotation</h3>
262
+ <input type="text" id="triagly-text-input" placeholder="Enter text..." maxlength="200" autofocus />
263
+ <div class="triagly-text-modal-actions">
264
+ <button type="button" class="triagly-annotation-btn triagly-btn-cancel" id="triagly-text-cancel">Cancel</button>
265
+ <button type="button" class="triagly-annotation-btn triagly-btn-done" id="triagly-text-confirm">Add Text</button>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ `;
270
+ // Inject styles
271
+ this.injectStyles();
272
+ // Add event listeners
273
+ this.setupEventListeners();
274
+ document.body.appendChild(this.overlay);
275
+ }
276
+ /**
277
+ * Set up the canvas with correct dimensions
278
+ */
279
+ setupCanvas() {
280
+ if (!this.overlay || !this.image)
281
+ return;
282
+ this.canvas = this.overlay.querySelector('.triagly-annotation-canvas');
283
+ const container = this.overlay.querySelector('.triagly-annotation-canvas-container');
284
+ if (!this.canvas || !container)
285
+ return;
286
+ this.ctx = this.canvas.getContext('2d');
287
+ if (!this.ctx)
288
+ return;
289
+ // Calculate scale to fit image in container
290
+ const containerRect = container.getBoundingClientRect();
291
+ const maxWidth = containerRect.width - 40; // Padding
292
+ const maxHeight = containerRect.height - 40;
293
+ const imageAspect = this.image.width / this.image.height;
294
+ const containerAspect = maxWidth / maxHeight;
295
+ let canvasWidth;
296
+ let canvasHeight;
297
+ if (imageAspect > containerAspect) {
298
+ // Image is wider - fit to width
299
+ canvasWidth = Math.min(this.image.width, maxWidth);
300
+ canvasHeight = canvasWidth / imageAspect;
301
+ }
302
+ else {
303
+ // Image is taller - fit to height
304
+ canvasHeight = Math.min(this.image.height, maxHeight);
305
+ canvasWidth = canvasHeight * imageAspect;
306
+ }
307
+ this.scale = canvasWidth / this.image.width;
308
+ this.canvas.width = canvasWidth;
309
+ this.canvas.height = canvasHeight;
310
+ this.canvas.style.width = `${canvasWidth}px`;
311
+ this.canvas.style.height = `${canvasHeight}px`;
312
+ // Calculate offset for centering
313
+ this.offsetX = (containerRect.width - canvasWidth) / 2;
314
+ this.offsetY = (containerRect.height - canvasHeight) / 2;
315
+ }
316
+ /**
317
+ * Set up event listeners
318
+ */
319
+ setupEventListeners() {
320
+ if (!this.overlay || !this.canvas)
321
+ return;
322
+ // Tool selection
323
+ const toolBtns = this.overlay.querySelectorAll('.triagly-tool-btn');
324
+ toolBtns.forEach((btn) => {
325
+ btn.addEventListener('click', (e) => {
326
+ const tool = e.currentTarget.dataset.tool;
327
+ if (tool) {
328
+ this.currentTool = tool;
329
+ toolBtns.forEach((b) => b.classList.remove('active'));
330
+ e.currentTarget.classList.add('active');
331
+ }
332
+ });
333
+ });
334
+ // Color picker
335
+ const colorPicker = this.overlay.querySelector('.triagly-color-picker');
336
+ if (colorPicker) {
337
+ colorPicker.addEventListener('change', (e) => {
338
+ this.currentColor = e.target.value;
339
+ });
340
+ }
341
+ // Clear button
342
+ const clearBtn = this.overlay.querySelector('.triagly-btn-clear');
343
+ if (clearBtn) {
344
+ clearBtn.addEventListener('click', () => {
345
+ this.annotations = [];
346
+ this.render();
347
+ });
348
+ }
349
+ // Cancel button
350
+ const cancelBtn = this.overlay.querySelector('.triagly-btn-cancel');
351
+ if (cancelBtn) {
352
+ cancelBtn.addEventListener('click', () => this.close(true));
353
+ }
354
+ // Done button
355
+ const doneBtn = this.overlay.querySelector('.triagly-btn-done');
356
+ if (doneBtn) {
357
+ doneBtn.addEventListener('click', () => this.close(false));
358
+ }
359
+ // Canvas drawing events
360
+ this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e));
361
+ this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
362
+ this.canvas.addEventListener('mouseup', () => this.handleMouseUp());
363
+ this.canvas.addEventListener('mouseleave', () => this.handleMouseUp());
364
+ // Touch support
365
+ this.canvas.addEventListener('touchstart', (e) => {
366
+ e.preventDefault();
367
+ const touch = e.touches[0];
368
+ this.handleMouseDown(touch);
369
+ });
370
+ this.canvas.addEventListener('touchmove', (e) => {
371
+ e.preventDefault();
372
+ const touch = e.touches[0];
373
+ this.handleMouseMove(touch);
374
+ });
375
+ this.canvas.addEventListener('touchend', () => this.handleMouseUp());
376
+ this.canvas.addEventListener('touchcancel', () => this.handleMouseUp());
377
+ // Text modal event listeners
378
+ const textConfirmBtn = this.overlay.querySelector('#triagly-text-confirm');
379
+ const textCancelBtn = this.overlay.querySelector('#triagly-text-cancel');
380
+ const textInput = this.overlay.querySelector('#triagly-text-input');
381
+ textConfirmBtn?.addEventListener('click', () => this.confirmTextAnnotation());
382
+ textCancelBtn?.addEventListener('click', () => this.hideTextModal());
383
+ textInput?.addEventListener('keydown', (e) => {
384
+ if (e.key === 'Enter') {
385
+ e.preventDefault();
386
+ this.confirmTextAnnotation();
387
+ }
388
+ else if (e.key === 'Escape') {
389
+ e.preventDefault();
390
+ this.hideTextModal();
391
+ }
392
+ });
393
+ // Escape key to cancel
394
+ document.addEventListener('keydown', this.handleKeyDown);
395
+ }
396
+ /**
397
+ * Get canvas coordinates from mouse event
398
+ */
399
+ getCanvasPoint(e) {
400
+ if (!this.canvas)
401
+ return { x: 0, y: 0 };
402
+ const rect = this.canvas.getBoundingClientRect();
403
+ return {
404
+ x: e.clientX - rect.left,
405
+ y: e.clientY - rect.top,
406
+ };
407
+ }
408
+ /**
409
+ * Handle mouse down - start drawing
410
+ */
411
+ handleMouseDown(e) {
412
+ const point = this.getCanvasPoint(e);
413
+ this.isDrawing = true;
414
+ if (this.currentTool === 'text') {
415
+ // For text, show custom modal input
416
+ this.pendingTextPoint = point;
417
+ this.showTextModal();
418
+ this.isDrawing = false;
419
+ return;
420
+ }
421
+ this.currentAnnotation = {
422
+ type: this.currentTool,
423
+ color: this.currentColor,
424
+ strokeWidth: this.strokeWidth,
425
+ points: [point],
426
+ };
427
+ }
428
+ /**
429
+ * Show the text input modal
430
+ */
431
+ showTextModal() {
432
+ if (!this.overlay)
433
+ return;
434
+ const modal = this.overlay.querySelector('#triagly-text-modal');
435
+ const input = this.overlay.querySelector('#triagly-text-input');
436
+ if (modal && input) {
437
+ modal.style.display = 'flex';
438
+ input.value = '';
439
+ input.focus();
440
+ }
441
+ }
442
+ /**
443
+ * Hide the text input modal
444
+ */
445
+ hideTextModal() {
446
+ if (!this.overlay)
447
+ return;
448
+ const modal = this.overlay.querySelector('#triagly-text-modal');
449
+ if (modal) {
450
+ modal.style.display = 'none';
451
+ }
452
+ this.pendingTextPoint = null;
453
+ }
454
+ /**
455
+ * Confirm text annotation from modal
456
+ */
457
+ confirmTextAnnotation() {
458
+ if (!this.overlay || !this.pendingTextPoint)
459
+ return;
460
+ const input = this.overlay.querySelector('#triagly-text-input');
461
+ const text = input?.value.trim();
462
+ if (text) {
463
+ this.annotations.push({
464
+ type: 'text',
465
+ color: this.currentColor,
466
+ strokeWidth: this.strokeWidth,
467
+ points: [this.pendingTextPoint],
468
+ text,
469
+ });
470
+ this.render();
471
+ }
472
+ this.hideTextModal();
473
+ }
474
+ /**
475
+ * Handle mouse move - continue drawing
476
+ */
477
+ handleMouseMove(e) {
478
+ if (!this.isDrawing || !this.currentAnnotation)
479
+ return;
480
+ const point = this.getCanvasPoint(e);
481
+ if (this.currentTool === 'freehand') {
482
+ // Add point to path
483
+ this.currentAnnotation.points.push(point);
484
+ }
485
+ else {
486
+ // For highlight and arrow, just update the end point
487
+ if (this.currentAnnotation.points.length === 1) {
488
+ this.currentAnnotation.points.push(point);
489
+ }
490
+ else {
491
+ this.currentAnnotation.points[1] = point;
492
+ }
493
+ }
494
+ this.render();
495
+ }
496
+ /**
497
+ * Handle mouse up - finish drawing
498
+ */
499
+ handleMouseUp() {
500
+ if (this.isDrawing && this.currentAnnotation && this.currentAnnotation.points.length > 1) {
501
+ this.annotations.push(this.currentAnnotation);
502
+ }
503
+ this.isDrawing = false;
504
+ this.currentAnnotation = null;
505
+ this.render();
506
+ }
507
+ /**
508
+ * Render the canvas with image and annotations
509
+ */
510
+ render() {
511
+ if (!this.ctx || !this.canvas || !this.image)
512
+ return;
513
+ // Clear canvas
514
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
515
+ // Draw the image
516
+ this.ctx.drawImage(this.image, 0, 0, this.canvas.width, this.canvas.height);
517
+ // Draw all annotations
518
+ for (const annotation of this.annotations) {
519
+ this.drawAnnotation(annotation);
520
+ }
521
+ // Draw current annotation being made
522
+ if (this.currentAnnotation) {
523
+ this.drawAnnotation(this.currentAnnotation);
524
+ }
525
+ }
526
+ /**
527
+ * Draw a single annotation
528
+ */
529
+ drawAnnotation(annotation) {
530
+ if (!this.ctx)
531
+ return;
532
+ this.ctx.strokeStyle = annotation.color;
533
+ this.ctx.fillStyle = annotation.color;
534
+ this.ctx.lineWidth = annotation.strokeWidth;
535
+ this.ctx.lineCap = 'round';
536
+ this.ctx.lineJoin = 'round';
537
+ switch (annotation.type) {
538
+ case 'highlight':
539
+ this.drawHighlight(annotation);
540
+ break;
541
+ case 'arrow':
542
+ this.drawArrow(annotation);
543
+ break;
544
+ case 'freehand':
545
+ this.drawFreehand(annotation);
546
+ break;
547
+ case 'text':
548
+ this.drawText(annotation);
549
+ break;
550
+ }
551
+ }
552
+ /**
553
+ * Draw a semi-transparent highlight rectangle
554
+ */
555
+ drawHighlight(annotation) {
556
+ if (!this.ctx || annotation.points.length < 2)
557
+ return;
558
+ const [start, end] = annotation.points;
559
+ const width = end.x - start.x;
560
+ const height = end.y - start.y;
561
+ // Semi-transparent fill
562
+ this.ctx.globalAlpha = 0.3;
563
+ this.ctx.fillRect(start.x, start.y, width, height);
564
+ // Solid border
565
+ this.ctx.globalAlpha = 1;
566
+ this.ctx.strokeRect(start.x, start.y, width, height);
567
+ }
568
+ /**
569
+ * Draw an arrow from start to end
570
+ */
571
+ drawArrow(annotation) {
572
+ if (!this.ctx || annotation.points.length < 2)
573
+ return;
574
+ const [start, end] = annotation.points;
575
+ const headLength = 15;
576
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
577
+ // Draw the line
578
+ this.ctx.beginPath();
579
+ this.ctx.moveTo(start.x, start.y);
580
+ this.ctx.lineTo(end.x, end.y);
581
+ this.ctx.stroke();
582
+ // Draw the arrow head
583
+ this.ctx.beginPath();
584
+ this.ctx.moveTo(end.x, end.y);
585
+ this.ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
586
+ this.ctx.moveTo(end.x, end.y);
587
+ this.ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
588
+ this.ctx.stroke();
589
+ }
590
+ /**
591
+ * Draw a freehand path
592
+ */
593
+ drawFreehand(annotation) {
594
+ if (!this.ctx || annotation.points.length < 2)
595
+ return;
596
+ this.ctx.beginPath();
597
+ this.ctx.moveTo(annotation.points[0].x, annotation.points[0].y);
598
+ for (let i = 1; i < annotation.points.length; i++) {
599
+ this.ctx.lineTo(annotation.points[i].x, annotation.points[i].y);
600
+ }
601
+ this.ctx.stroke();
602
+ }
603
+ /**
604
+ * Draw text annotation
605
+ */
606
+ drawText(annotation) {
607
+ if (!this.ctx || !annotation.text || annotation.points.length < 1)
608
+ return;
609
+ const point = annotation.points[0];
610
+ // Text settings
611
+ this.ctx.font = 'bold 16px sans-serif';
612
+ this.ctx.textBaseline = 'top';
613
+ // Measure text for background
614
+ const metrics = this.ctx.measureText(annotation.text);
615
+ const padding = 4;
616
+ // Draw background
617
+ this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
618
+ this.ctx.fillRect(point.x - padding, point.y - padding, metrics.width + padding * 2, 20 + padding * 2);
619
+ // Draw text
620
+ this.ctx.fillStyle = '#ffffff';
621
+ this.ctx.fillText(annotation.text, point.x, point.y);
622
+ }
623
+ /**
624
+ * Close the editor
625
+ */
626
+ close(cancelled) {
627
+ document.removeEventListener('keydown', this.handleKeyDown);
628
+ if (cancelled) {
629
+ this.onCancel();
630
+ }
631
+ else {
632
+ // Generate final annotated image
633
+ const annotatedImage = this.renderFinalImage();
634
+ this.onComplete(annotatedImage);
635
+ }
636
+ // Remove overlay
637
+ if (this.overlay) {
638
+ this.overlay.remove();
639
+ this.overlay = null;
640
+ }
641
+ }
642
+ /**
643
+ * Render the final annotated image at full resolution
644
+ */
645
+ renderFinalImage() {
646
+ if (!this.image)
647
+ return this.imageData;
648
+ // Create a full-resolution canvas
649
+ const canvas = document.createElement('canvas');
650
+ canvas.width = this.image.width;
651
+ canvas.height = this.image.height;
652
+ const ctx = canvas.getContext('2d');
653
+ if (!ctx)
654
+ return this.imageData;
655
+ // Draw original image
656
+ ctx.drawImage(this.image, 0, 0);
657
+ // Calculate scale factor
658
+ const scaleFactor = 1 / this.scale;
659
+ // Draw annotations at full resolution
660
+ for (const annotation of this.annotations) {
661
+ ctx.strokeStyle = annotation.color;
662
+ ctx.fillStyle = annotation.color;
663
+ ctx.lineWidth = annotation.strokeWidth * scaleFactor;
664
+ ctx.lineCap = 'round';
665
+ ctx.lineJoin = 'round';
666
+ // Scale points
667
+ const scaledPoints = annotation.points.map((p) => ({
668
+ x: p.x * scaleFactor,
669
+ y: p.y * scaleFactor,
670
+ }));
671
+ const scaledAnnotation = { ...annotation, points: scaledPoints };
672
+ switch (annotation.type) {
673
+ case 'highlight':
674
+ this.drawHighlightOnCtx(ctx, scaledAnnotation);
675
+ break;
676
+ case 'arrow':
677
+ this.drawArrowOnCtx(ctx, scaledAnnotation, scaleFactor);
678
+ break;
679
+ case 'freehand':
680
+ this.drawFreehandOnCtx(ctx, scaledAnnotation);
681
+ break;
682
+ case 'text':
683
+ this.drawTextOnCtx(ctx, scaledAnnotation, scaleFactor);
684
+ break;
685
+ }
686
+ }
687
+ return canvas.toDataURL('image/jpeg', 0.9);
688
+ }
689
+ drawHighlightOnCtx(ctx, annotation) {
690
+ if (annotation.points.length < 2)
691
+ return;
692
+ const [start, end] = annotation.points;
693
+ const width = end.x - start.x;
694
+ const height = end.y - start.y;
695
+ ctx.globalAlpha = 0.3;
696
+ ctx.fillRect(start.x, start.y, width, height);
697
+ ctx.globalAlpha = 1;
698
+ ctx.strokeRect(start.x, start.y, width, height);
699
+ }
700
+ drawArrowOnCtx(ctx, annotation, scaleFactor) {
701
+ if (annotation.points.length < 2)
702
+ return;
703
+ const [start, end] = annotation.points;
704
+ const headLength = 15 * scaleFactor;
705
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
706
+ ctx.beginPath();
707
+ ctx.moveTo(start.x, start.y);
708
+ ctx.lineTo(end.x, end.y);
709
+ ctx.stroke();
710
+ ctx.beginPath();
711
+ ctx.moveTo(end.x, end.y);
712
+ ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
713
+ ctx.moveTo(end.x, end.y);
714
+ ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
715
+ ctx.stroke();
716
+ }
717
+ drawFreehandOnCtx(ctx, annotation) {
718
+ if (annotation.points.length < 2)
719
+ return;
720
+ ctx.beginPath();
721
+ ctx.moveTo(annotation.points[0].x, annotation.points[0].y);
722
+ for (let i = 1; i < annotation.points.length; i++) {
723
+ ctx.lineTo(annotation.points[i].x, annotation.points[i].y);
724
+ }
725
+ ctx.stroke();
726
+ }
727
+ drawTextOnCtx(ctx, annotation, scaleFactor) {
728
+ if (!annotation.text || annotation.points.length < 1)
729
+ return;
730
+ const point = annotation.points[0];
731
+ const fontSize = 16 * scaleFactor;
732
+ ctx.font = `bold ${fontSize}px sans-serif`;
733
+ ctx.textBaseline = 'top';
734
+ const metrics = ctx.measureText(annotation.text);
735
+ const padding = 4 * scaleFactor;
736
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
737
+ ctx.fillRect(point.x - padding, point.y - padding, metrics.width + padding * 2, fontSize * 1.2 + padding * 2);
738
+ ctx.fillStyle = '#ffffff';
739
+ ctx.fillText(annotation.text, point.x, point.y);
740
+ }
741
+ /**
742
+ * Inject annotation editor styles
743
+ */
744
+ injectStyles() {
745
+ const styleId = 'triagly-annotation-styles';
746
+ if (document.getElementById(styleId))
747
+ return;
748
+ const style = document.createElement('style');
749
+ style.id = styleId;
750
+ style.textContent = `
751
+ .triagly-annotation-overlay {
752
+ position: fixed;
753
+ top: 0;
754
+ left: 0;
755
+ right: 0;
756
+ bottom: 0;
757
+ z-index: 999999;
758
+ background: rgba(0, 0, 0, 0.9);
759
+ display: flex;
760
+ flex-direction: column;
761
+ }
762
+
763
+ .triagly-annotation-header {
764
+ display: flex;
765
+ justify-content: space-between;
766
+ align-items: center;
767
+ padding: 12px 20px;
768
+ background: #1a1a1a;
769
+ border-bottom: 1px solid #333;
770
+ }
771
+
772
+ .triagly-annotation-tools {
773
+ display: flex;
774
+ align-items: center;
775
+ gap: 8px;
776
+ }
777
+
778
+ .triagly-tool-btn {
779
+ display: flex;
780
+ align-items: center;
781
+ justify-content: center;
782
+ width: 40px;
783
+ height: 40px;
784
+ border: none;
785
+ border-radius: 8px;
786
+ background: #333;
787
+ color: #fff;
788
+ cursor: pointer;
789
+ transition: all 0.2s;
790
+ }
791
+
792
+ .triagly-tool-btn:hover {
793
+ background: #444;
794
+ }
795
+
796
+ .triagly-tool-btn.active {
797
+ background: #0066cc;
798
+ }
799
+
800
+ .triagly-tool-divider {
801
+ width: 1px;
802
+ height: 24px;
803
+ background: #444;
804
+ margin: 0 8px;
805
+ }
806
+
807
+ .triagly-color-picker {
808
+ width: 40px;
809
+ height: 40px;
810
+ border: none;
811
+ border-radius: 8px;
812
+ cursor: pointer;
813
+ padding: 0;
814
+ background: none;
815
+ }
816
+
817
+ .triagly-color-picker::-webkit-color-swatch-wrapper {
818
+ padding: 4px;
819
+ }
820
+
821
+ .triagly-color-picker::-webkit-color-swatch {
822
+ border: none;
823
+ border-radius: 4px;
824
+ }
825
+
826
+ .triagly-annotation-actions {
827
+ display: flex;
828
+ gap: 8px;
829
+ }
830
+
831
+ .triagly-annotation-btn {
832
+ padding: 8px 16px;
833
+ border: none;
834
+ border-radius: 8px;
835
+ font-size: 14px;
836
+ font-weight: 500;
837
+ cursor: pointer;
838
+ transition: all 0.2s;
839
+ display: flex;
840
+ align-items: center;
841
+ gap: 6px;
842
+ }
843
+
844
+ .triagly-btn-clear {
845
+ background: transparent;
846
+ color: #999;
847
+ border: 1px solid #444;
848
+ }
849
+
850
+ .triagly-btn-clear:hover {
851
+ background: #333;
852
+ color: #fff;
853
+ }
854
+
855
+ .triagly-btn-cancel {
856
+ background: #333;
857
+ color: #fff;
858
+ }
859
+
860
+ .triagly-btn-cancel:hover {
861
+ background: #444;
862
+ }
863
+
864
+ .triagly-btn-done {
865
+ background: #0066cc;
866
+ color: #fff;
867
+ }
868
+
869
+ .triagly-btn-done:hover {
870
+ background: #0055aa;
871
+ }
872
+
873
+ .triagly-annotation-canvas-container {
874
+ flex: 1;
875
+ display: flex;
876
+ align-items: center;
877
+ justify-content: center;
878
+ overflow: hidden;
879
+ padding: 20px;
880
+ }
881
+
882
+ .triagly-annotation-canvas {
883
+ cursor: crosshair;
884
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
885
+ border-radius: 4px;
886
+ }
887
+
888
+ .triagly-text-modal {
889
+ position: absolute;
890
+ top: 0;
891
+ left: 0;
892
+ right: 0;
893
+ bottom: 0;
894
+ background: rgba(0, 0, 0, 0.7);
895
+ display: flex;
896
+ align-items: center;
897
+ justify-content: center;
898
+ z-index: 1000000;
899
+ }
900
+
901
+ .triagly-text-modal-content {
902
+ background: #1a1a1a;
903
+ border-radius: 12px;
904
+ padding: 24px;
905
+ min-width: 320px;
906
+ max-width: 400px;
907
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
908
+ }
909
+
910
+ .triagly-text-modal-content h3 {
911
+ color: #fff;
912
+ font-size: 16px;
913
+ font-weight: 600;
914
+ margin: 0 0 16px 0;
915
+ }
916
+
917
+ .triagly-text-modal-content input {
918
+ width: 100%;
919
+ padding: 12px;
920
+ border: 1px solid #444;
921
+ border-radius: 8px;
922
+ background: #2a2a2a;
923
+ color: #fff;
924
+ font-size: 14px;
925
+ outline: none;
926
+ box-sizing: border-box;
927
+ }
928
+
929
+ .triagly-text-modal-content input:focus {
930
+ border-color: #0066cc;
931
+ }
932
+
933
+ .triagly-text-modal-content input::placeholder {
934
+ color: #666;
935
+ }
936
+
937
+ .triagly-text-modal-actions {
938
+ display: flex;
939
+ gap: 8px;
940
+ justify-content: flex-end;
941
+ margin-top: 16px;
942
+ }
943
+ `;
944
+ document.head.appendChild(style);
945
+ }
946
+ }
947
+
1
948
  // Feedback Widget UI
2
949
  class FeedbackWidget {
3
950
  constructor(config) {
@@ -5,6 +952,8 @@ class FeedbackWidget {
5
952
  this.isOpen = false;
6
953
  this.previouslyFocusedElement = null;
7
954
  this.focusableElements = [];
955
+ this.screenshotDataUrl = null;
956
+ this.screenshotCapture = null;
8
957
  this.config = config;
9
958
  }
10
959
  /**
@@ -17,6 +966,13 @@ class FeedbackWidget {
17
966
  if (this.config.turnstileSiteKey) {
18
967
  this.loadTurnstileScript();
19
968
  }
969
+ // Initialize screenshot capture if enabled
970
+ if (this.config.enableScreenshot) {
971
+ this.screenshotCapture = new ScreenshotCapture({
972
+ quality: this.config.screenshotQuality,
973
+ maxWidth: this.config.screenshotMaxWidth,
974
+ });
975
+ }
20
976
  }
21
977
  /**
22
978
  * Load Cloudflare Turnstile script dynamically
@@ -45,26 +1001,23 @@ class FeedbackWidget {
45
1001
  // Button orientation
46
1002
  const orientation = this.config.orientation || 'horizontal';
47
1003
  button.classList.add(`triagly-orientation-${orientation}`);
1004
+ // Speech bubble SVG icon
1005
+ const speechBubbleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" class="triagly-icon"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>`;
1006
+ const largeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="currentColor" class="triagly-icon"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>`;
48
1007
  // Handle button text based on shape
49
- const fullText = this.config.buttonText || '🐛 Feedback';
1008
+ const fullText = this.config.buttonText || 'Feedback';
50
1009
  if (shape === 'circular') {
51
- button.innerHTML = '🐛';
1010
+ button.innerHTML = largeIcon;
52
1011
  button.setAttribute('aria-label', fullText);
53
1012
  }
54
1013
  else if (shape === 'expandable') {
55
- // Expandable starts with emoji, expands to full text on hover
56
- button.innerHTML = '<span class="triagly-btn-icon">🐛</span><span class="triagly-btn-text"> Feedback</span>';
1014
+ // Expandable starts with icon, expands to full text on hover
1015
+ button.innerHTML = `<span class="triagly-btn-icon">${largeIcon}</span><span class="triagly-btn-text"> ${this.config.buttonText || 'Feedback'}</span>`;
57
1016
  button.setAttribute('aria-label', fullText);
58
- // Store custom text if provided
59
- if (this.config.buttonText) {
60
- const textSpan = button.querySelector('.triagly-btn-text');
61
- if (textSpan) {
62
- textSpan.textContent = ' ' + this.config.buttonText.replace('🐛', '').trim();
63
- }
64
- }
65
1017
  }
66
1018
  else {
67
- button.innerHTML = fullText;
1019
+ // Default: icon + text
1020
+ button.innerHTML = `${speechBubbleIcon}<span class="triagly-btn-label">${fullText}</span>`;
68
1021
  }
69
1022
  button.onclick = () => this.toggle();
70
1023
  // Position button
@@ -130,9 +1083,9 @@ class FeedbackWidget {
130
1083
  this.setupKeyboardEvents();
131
1084
  // Set up focus trap
132
1085
  this.setupFocusTrap();
133
- // Focus on title field
134
- const titleInput = this.container?.querySelector('input[type="text"]');
135
- titleInput?.focus();
1086
+ // Focus on description field
1087
+ const descInput = this.container?.querySelector('#triagly-description');
1088
+ descInput?.focus();
136
1089
  }, 0);
137
1090
  }
138
1091
  /**
@@ -149,6 +1102,7 @@ class FeedbackWidget {
149
1102
  this.container.remove();
150
1103
  this.container = null;
151
1104
  this.isOpen = false;
1105
+ this.screenshotDataUrl = null;
152
1106
  // Restore focus to previously focused element
153
1107
  if (this.previouslyFocusedElement) {
154
1108
  this.previouslyFocusedElement.focus();
@@ -177,7 +1131,7 @@ class FeedbackWidget {
177
1131
  overlay.className = 'triagly-overlay';
178
1132
  overlay.setAttribute('role', 'dialog');
179
1133
  overlay.setAttribute('aria-modal', 'true');
180
- overlay.setAttribute('aria-labelledby', 'triagly-modal-title');
1134
+ overlay.setAttribute('aria-label', 'Send feedback');
181
1135
  overlay.onclick = (e) => {
182
1136
  if (e.target === overlay)
183
1137
  this.close('overlay');
@@ -188,7 +1142,6 @@ class FeedbackWidget {
188
1142
  const header = document.createElement('div');
189
1143
  header.className = 'triagly-header';
190
1144
  header.innerHTML = `
191
- <h3 id="triagly-modal-title">Send Feedback</h3>
192
1145
  <button type="button" class="triagly-close" aria-label="Close feedback form">×</button>
193
1146
  `;
194
1147
  const closeBtn = header.querySelector('.triagly-close');
@@ -197,16 +1150,7 @@ class FeedbackWidget {
197
1150
  form.className = 'triagly-form';
198
1151
  form.innerHTML = `
199
1152
  <div class="triagly-field">
200
- <label for="triagly-title">Title (optional)</label>
201
- <input
202
- type="text"
203
- id="triagly-title"
204
- placeholder="Brief summary of your feedback"
205
- />
206
- </div>
207
-
208
- <div class="triagly-field">
209
- <label for="triagly-description">Description *</label>
1153
+ <label for="triagly-description">What's on your mind?</label>
210
1154
  <textarea
211
1155
  id="triagly-description"
212
1156
  required
@@ -215,6 +1159,15 @@ class FeedbackWidget {
215
1159
  ></textarea>
216
1160
  </div>
217
1161
 
1162
+ <div class="triagly-field">
1163
+ <label for="triagly-name">Name (optional)</label>
1164
+ <input
1165
+ type="text"
1166
+ id="triagly-name"
1167
+ placeholder="Your name"
1168
+ />
1169
+ </div>
1170
+
218
1171
  <div class="triagly-field">
219
1172
  <label for="triagly-email">Email (optional)</label>
220
1173
  <input
@@ -224,12 +1177,40 @@ class FeedbackWidget {
224
1177
  />
225
1178
  </div>
226
1179
 
227
- <div class="triagly-field triagly-checkbox">
228
- <label>
229
- <input type="checkbox" id="triagly-screenshot" checked />
230
- <span>Include screenshot</span>
231
- </label>
1180
+ ${this.config.enableScreenshot ? `
1181
+ <div class="triagly-field triagly-screenshot-field">
1182
+ <label>Screenshot (optional)</label>
1183
+ <div class="triagly-screenshot-controls" id="triagly-screenshot-controls">
1184
+ <button type="button" class="triagly-btn-capture" id="triagly-capture-btn">
1185
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1186
+ <rect x="3" y="3" width="18" height="18" rx="2"/>
1187
+ <circle cx="8.5" cy="8.5" r="1.5"/>
1188
+ <path d="M21 15l-5-5L5 21"/>
1189
+ </svg>
1190
+ ${this.config.screenshotButtonText || 'Capture Screenshot'}
1191
+ </button>
1192
+ </div>
1193
+ <div class="triagly-screenshot-preview" id="triagly-screenshot-preview" style="display: none;">
1194
+ <img id="triagly-screenshot-img" alt="Screenshot preview" />
1195
+ <div class="triagly-screenshot-actions">
1196
+ ${this.config.enableAnnotation !== false ? `
1197
+ <button type="button" class="triagly-btn-icon" id="triagly-annotate-btn" title="Add annotations">
1198
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1199
+ <path d="M12 19l7-7 3 3-7 7-3-3z"/>
1200
+ <path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
1201
+ </svg>
1202
+ </button>
1203
+ ` : ''}
1204
+ <button type="button" class="triagly-btn-icon triagly-btn-danger" id="triagly-remove-screenshot-btn" title="Remove screenshot">
1205
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1206
+ <line x1="18" y1="6" x2="6" y2="18"/>
1207
+ <line x1="6" y1="6" x2="18" y2="18"/>
1208
+ </svg>
1209
+ </button>
1210
+ </div>
1211
+ </div>
232
1212
  </div>
1213
+ ` : ''}
233
1214
 
234
1215
  ${this.config.turnstileSiteKey ? `
235
1216
  <div class="triagly-field triagly-turnstile">
@@ -254,8 +1235,20 @@ class FeedbackWidget {
254
1235
  e.preventDefault();
255
1236
  this.handleSubmit(form);
256
1237
  };
1238
+ // Set up screenshot event handlers
1239
+ if (this.config.enableScreenshot) {
1240
+ this.setupScreenshotHandlers(form);
1241
+ }
1242
+ const footer = document.createElement('div');
1243
+ footer.className = 'triagly-footer';
1244
+ footer.innerHTML = `
1245
+ <a href="https://triagly.com" target="_blank" rel="noopener noreferrer" class="triagly-branding">
1246
+ Powered by <strong>Triagly</strong>
1247
+ </a>
1248
+ `;
257
1249
  modal.appendChild(header);
258
1250
  modal.appendChild(form);
1251
+ modal.appendChild(footer);
259
1252
  overlay.appendChild(modal);
260
1253
  // Render Turnstile widget if available
261
1254
  if (this.config.turnstileSiteKey) {
@@ -297,17 +1290,90 @@ class FeedbackWidget {
297
1290
  turnstileContainer.setAttribute('data-widget-id', widgetId);
298
1291
  }
299
1292
  catch (error) {
300
- console.error('Triagly: Failed to render Turnstile widget:', error);
1293
+ console.error('Triagly: Failed to render Turnstile widget:', error instanceof Error ? error.message : 'Unknown error');
301
1294
  }
302
1295
  }
1296
+ /**
1297
+ * Set up screenshot capture event handlers
1298
+ */
1299
+ setupScreenshotHandlers(form) {
1300
+ const captureBtn = form.querySelector('#triagly-capture-btn');
1301
+ const annotateBtn = form.querySelector('#triagly-annotate-btn');
1302
+ const removeBtn = form.querySelector('#triagly-remove-screenshot-btn');
1303
+ const preview = form.querySelector('#triagly-screenshot-preview');
1304
+ const controls = form.querySelector('#triagly-screenshot-controls');
1305
+ const previewImg = form.querySelector('#triagly-screenshot-img');
1306
+ // Capture screenshot
1307
+ captureBtn?.addEventListener('click', async () => {
1308
+ if (!this.screenshotCapture)
1309
+ return;
1310
+ const btn = captureBtn;
1311
+ const originalText = btn.innerHTML;
1312
+ btn.disabled = true;
1313
+ btn.innerHTML = `
1314
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="triagly-spin">
1315
+ <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" opacity="0.3"/>
1316
+ <path d="M12 2v4"/>
1317
+ </svg>
1318
+ Capturing...
1319
+ `;
1320
+ try {
1321
+ const dataUrl = await this.screenshotCapture.capture();
1322
+ this.screenshotDataUrl = dataUrl;
1323
+ // Update UI
1324
+ if (previewImg)
1325
+ previewImg.src = dataUrl;
1326
+ if (preview)
1327
+ preview.style.display = 'block';
1328
+ if (controls)
1329
+ controls.style.display = 'none';
1330
+ // Call callback if provided
1331
+ if (this.config.onScreenshotCapture) {
1332
+ this.config.onScreenshotCapture(dataUrl);
1333
+ }
1334
+ }
1335
+ catch (error) {
1336
+ console.error('Triagly: Screenshot capture failed:', error);
1337
+ if (this.config.onScreenshotError) {
1338
+ this.config.onScreenshotError(error instanceof Error ? error : new Error(String(error)));
1339
+ }
1340
+ }
1341
+ finally {
1342
+ btn.disabled = false;
1343
+ btn.innerHTML = originalText;
1344
+ }
1345
+ });
1346
+ // Annotate screenshot
1347
+ annotateBtn?.addEventListener('click', () => {
1348
+ if (!this.screenshotDataUrl)
1349
+ return;
1350
+ const editor = new AnnotationEditor(this.screenshotDataUrl, (annotatedImage) => {
1351
+ this.screenshotDataUrl = annotatedImage;
1352
+ if (previewImg)
1353
+ previewImg.src = annotatedImage;
1354
+ }, () => {
1355
+ // Cancelled - do nothing
1356
+ });
1357
+ editor.open();
1358
+ });
1359
+ // Remove screenshot
1360
+ removeBtn?.addEventListener('click', () => {
1361
+ this.screenshotDataUrl = null;
1362
+ if (preview)
1363
+ preview.style.display = 'none';
1364
+ if (controls)
1365
+ controls.style.display = 'block';
1366
+ if (previewImg)
1367
+ previewImg.src = '';
1368
+ });
1369
+ }
303
1370
  /**
304
1371
  * Handle form submission
305
1372
  */
306
1373
  async handleSubmit(form) {
307
- const titleInput = form.querySelector('#triagly-title');
308
1374
  const descInput = form.querySelector('#triagly-description');
1375
+ const nameInput = form.querySelector('#triagly-name');
309
1376
  const emailInput = form.querySelector('#triagly-email');
310
- const screenshotCheckbox = form.querySelector('#triagly-screenshot');
311
1377
  const statusDiv = form.querySelector('#triagly-status');
312
1378
  const submitBtn = form.querySelector('button[type="submit"]');
313
1379
  const turnstileContainer = form.querySelector('.cf-turnstile');
@@ -321,11 +1387,11 @@ class FeedbackWidget {
321
1387
  turnstileToken = turnstileContainer.getAttribute('data-turnstile-response') || undefined;
322
1388
  }
323
1389
  const data = {
324
- title: titleInput.value.trim() || undefined,
325
1390
  description: descInput.value.trim(),
1391
+ reporterName: nameInput.value.trim() || undefined,
326
1392
  reporterEmail: emailInput.value.trim() || undefined,
327
- includeScreenshot: screenshotCheckbox.checked,
328
1393
  turnstileToken,
1394
+ screenshot: this.screenshotDataUrl || undefined,
329
1395
  };
330
1396
  // Create a promise that waits for actual submission result
331
1397
  const submissionPromise = new Promise((resolve, reject) => {
@@ -388,21 +1454,32 @@ class FeedbackWidget {
388
1454
  position: fixed;
389
1455
  z-index: 999999;
390
1456
  padding: 12px 20px;
391
- background: var(--triagly-button-bg, #6366f1);
1457
+ background: var(--triagly-button-bg, #18181b);
392
1458
  color: var(--triagly-button-text, #ffffff);
393
1459
  border: none;
394
1460
  border-radius: var(--triagly-button-radius, 8px);
395
1461
  font-size: 14px;
396
1462
  font-weight: 500;
397
1463
  cursor: pointer;
398
- box-shadow: var(--triagly-button-shadow, 0 4px 12px rgba(99, 102, 241, 0.3));
1464
+ box-shadow: var(--triagly-button-shadow, 0 4px 12px rgba(0, 0, 0, 0.15));
399
1465
  transition: all 0.2s;
1466
+ display: inline-flex;
1467
+ align-items: center;
1468
+ gap: 8px;
400
1469
  }
401
1470
 
402
1471
  .triagly-button:hover {
403
- background: var(--triagly-button-bg-hover, #4f46e5);
1472
+ background: var(--triagly-button-bg-hover, #27272a);
404
1473
  transform: translateY(-2px);
405
- box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(99, 102, 241, 0.4));
1474
+ box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(0, 0, 0, 0.2));
1475
+ }
1476
+
1477
+ .triagly-button .triagly-icon {
1478
+ flex-shrink: 0;
1479
+ }
1480
+
1481
+ .triagly-button .triagly-btn-label {
1482
+ line-height: 1;
406
1483
  }
407
1484
 
408
1485
  /* Prevent expandable buttons from shifting on hover */
@@ -496,8 +1573,8 @@ class FeedbackWidget {
496
1573
  min-width: auto;
497
1574
  padding: 12px 20px;
498
1575
  border-radius: 30px;
499
- background: var(--triagly-button-bg-hover, #4f46e5);
500
- box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(99, 102, 241, 0.4));
1576
+ background: var(--triagly-button-bg-hover, #27272a);
1577
+ box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(0, 0, 0, 0.2));
501
1578
  }
502
1579
  .triagly-shape-expandable:hover .triagly-btn-text {
503
1580
  width: auto;
@@ -556,18 +1633,10 @@ class FeedbackWidget {
556
1633
 
557
1634
  .triagly-header {
558
1635
  display: flex;
559
- justify-content: space-between;
1636
+ justify-content: flex-end;
560
1637
  align-items: center;
561
- padding: 20px 24px;
1638
+ padding: 8px 12px 0;
562
1639
  background: var(--triagly-header-bg, #ffffff);
563
- border-bottom: 1px solid var(--triagly-header-border, #e5e7eb);
564
- }
565
-
566
- .triagly-header h3 {
567
- margin: 0;
568
- font-size: 18px;
569
- font-weight: 600;
570
- color: var(--triagly-header-text, #111827);
571
1640
  }
572
1641
 
573
1642
  .triagly-close {
@@ -625,46 +1694,21 @@ class FeedbackWidget {
625
1694
  .triagly-field input:focus,
626
1695
  .triagly-field textarea:focus {
627
1696
  outline: none;
628
- border-color: var(--triagly-input-border-focus, #6366f1);
629
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
630
- }
631
-
632
- .triagly-checkbox label {
633
- display: flex;
634
- align-items: center;
635
- gap: 8px;
636
- cursor: pointer;
637
- font-weight: 400;
638
- }
639
-
640
- .triagly-checkbox label span {
641
- user-select: none;
642
- }
643
-
644
- .triagly-checkbox input {
645
- width: 16px;
646
- height: 16px;
647
- margin: 0;
648
- cursor: pointer;
1697
+ border-color: var(--triagly-input-border-focus, #a1a1aa);
1698
+ box-shadow: 0 0 0 2px rgba(161, 161, 170, 0.15);
649
1699
  }
650
1700
 
651
1701
  /* Focus visible styles for accessibility */
652
1702
  .triagly-button:focus-visible,
653
1703
  .triagly-field input:focus-visible,
654
1704
  .triagly-field textarea:focus-visible,
655
- .triagly-checkbox input:focus-visible,
656
1705
  .triagly-btn-primary:focus-visible,
657
1706
  .triagly-btn-secondary:focus-visible,
658
1707
  .triagly-close:focus-visible {
659
- outline: 2px solid #6366f1;
1708
+ outline: 2px solid #a1a1aa;
660
1709
  outline-offset: 2px;
661
1710
  }
662
1711
 
663
- /* Checkbox label gets visual indicator when checkbox is focused */
664
- .triagly-checkbox input:focus-visible + span {
665
- text-decoration: underline;
666
- }
667
-
668
1712
  .triagly-turnstile {
669
1713
  display: flex;
670
1714
  justify-content: center;
@@ -690,12 +1734,12 @@ class FeedbackWidget {
690
1734
  }
691
1735
 
692
1736
  .triagly-btn-primary {
693
- background: var(--triagly-btn-primary-bg, #6366f1);
1737
+ background: var(--triagly-btn-primary-bg, #18181b);
694
1738
  color: var(--triagly-btn-primary-text, #ffffff);
695
1739
  }
696
1740
 
697
1741
  .triagly-btn-primary:hover:not(:disabled) {
698
- background: var(--triagly-btn-primary-bg-hover, #4f46e5);
1742
+ background: var(--triagly-btn-primary-bg-hover, #27272a);
699
1743
  }
700
1744
 
701
1745
  .triagly-btn-primary:disabled {
@@ -731,6 +1775,125 @@ class FeedbackWidget {
731
1775
  background: var(--triagly-error-bg, #fee2e2);
732
1776
  color: var(--triagly-error-text, #991b1b);
733
1777
  }
1778
+
1779
+ .triagly-footer {
1780
+ padding: 12px 24px 16px;
1781
+ text-align: right;
1782
+ border-top: 1px solid var(--triagly-footer-border, #e5e7eb);
1783
+ background: var(--triagly-footer-bg, #f9fafb);
1784
+ border-radius: 0 0 var(--triagly-modal-radius, 12px) var(--triagly-modal-radius, 12px);
1785
+ }
1786
+
1787
+ .triagly-branding {
1788
+ font-size: 12px;
1789
+ color: var(--triagly-footer-text, #6b7280);
1790
+ text-decoration: none;
1791
+ transition: color 0.2s;
1792
+ }
1793
+
1794
+ .triagly-branding:hover {
1795
+ color: var(--triagly-footer-text-hover, #18181b);
1796
+ }
1797
+
1798
+ .triagly-branding strong {
1799
+ font-weight: 600;
1800
+ }
1801
+
1802
+ /* Screenshot styles */
1803
+ .triagly-screenshot-field {
1804
+ margin-bottom: 16px;
1805
+ }
1806
+
1807
+ .triagly-screenshot-controls {
1808
+ display: flex;
1809
+ gap: 8px;
1810
+ }
1811
+
1812
+ .triagly-btn-capture {
1813
+ display: inline-flex;
1814
+ align-items: center;
1815
+ gap: 8px;
1816
+ padding: 10px 16px;
1817
+ background: var(--triagly-btn-secondary-bg, #f3f4f6);
1818
+ color: var(--triagly-btn-secondary-text, #374151);
1819
+ border: 1px dashed var(--triagly-input-border, #d1d5db);
1820
+ border-radius: var(--triagly-input-radius, 6px);
1821
+ font-size: 14px;
1822
+ font-weight: 500;
1823
+ cursor: pointer;
1824
+ transition: all 0.2s;
1825
+ width: 100%;
1826
+ justify-content: center;
1827
+ }
1828
+
1829
+ .triagly-btn-capture:hover:not(:disabled) {
1830
+ background: var(--triagly-btn-secondary-bg-hover, #e5e7eb);
1831
+ border-style: solid;
1832
+ }
1833
+
1834
+ .triagly-btn-capture:disabled {
1835
+ opacity: 0.7;
1836
+ cursor: wait;
1837
+ }
1838
+
1839
+ .triagly-screenshot-preview {
1840
+ position: relative;
1841
+ border-radius: var(--triagly-input-radius, 6px);
1842
+ overflow: hidden;
1843
+ border: 1px solid var(--triagly-input-border, #d1d5db);
1844
+ background: var(--triagly-input-bg, #ffffff);
1845
+ }
1846
+
1847
+ .triagly-screenshot-preview img {
1848
+ display: block;
1849
+ width: 100%;
1850
+ max-height: 200px;
1851
+ object-fit: contain;
1852
+ background: #f9fafb;
1853
+ }
1854
+
1855
+ .triagly-screenshot-actions {
1856
+ position: absolute;
1857
+ top: 8px;
1858
+ right: 8px;
1859
+ display: flex;
1860
+ gap: 4px;
1861
+ }
1862
+
1863
+ .triagly-btn-icon {
1864
+ display: flex;
1865
+ align-items: center;
1866
+ justify-content: center;
1867
+ width: 28px;
1868
+ height: 28px;
1869
+ padding: 0;
1870
+ background: rgba(255, 255, 255, 0.9);
1871
+ border: 1px solid var(--triagly-input-border, #d1d5db);
1872
+ border-radius: 6px;
1873
+ cursor: pointer;
1874
+ transition: all 0.2s;
1875
+ color: #374151;
1876
+ }
1877
+
1878
+ .triagly-btn-icon:hover {
1879
+ background: #ffffff;
1880
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1881
+ }
1882
+
1883
+ .triagly-btn-icon.triagly-btn-danger:hover {
1884
+ background: #fee2e2;
1885
+ border-color: #fca5a5;
1886
+ color: #dc2626;
1887
+ }
1888
+
1889
+ @keyframes triagly-spin {
1890
+ from { transform: rotate(0deg); }
1891
+ to { transform: rotate(360deg); }
1892
+ }
1893
+
1894
+ .triagly-spin {
1895
+ animation: triagly-spin 1s linear infinite;
1896
+ }
734
1897
  `;
735
1898
  document.head.appendChild(style);
736
1899
  }
@@ -818,10 +1981,14 @@ class FeedbackWidget {
818
1981
  }
819
1982
 
820
1983
  // API Client
821
- const DEFAULT_API_URL = 'https://bssghvinezdawvupcyci.supabase.co/functions/v1';
1984
+ const API_URLS = {
1985
+ production: 'https://iipkklhhafrjesryscjh.supabase.co/functions/v1',
1986
+ staging: 'https://bssghvinezdawvupcyci.supabase.co/functions/v1',
1987
+ };
822
1988
  class TriaglyAPI {
823
- constructor(publishableKey, apiUrl, getToken, turnstileSiteKey) {
824
- this.apiUrl = (apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); // Remove trailing slash
1989
+ constructor(publishableKey, environment = 'production', apiUrl, getToken, turnstileSiteKey) {
1990
+ // apiUrl override takes precedence, then environment-based URL
1991
+ this.apiUrl = (apiUrl || API_URLS[environment]).replace(/\/$/, ''); // Remove trailing slash
825
1992
  this.publishableKey = publishableKey;
826
1993
  this.getToken = getToken;
827
1994
  // Always use Triagly's Turnstile site key (can be overridden for testing)
@@ -859,7 +2026,7 @@ class TriaglyAPI {
859
2026
  }
860
2027
  }
861
2028
  catch (error) {
862
- console.warn('Failed to get Turnstile token:', error);
2029
+ console.warn('Failed to get Turnstile token:', error instanceof Error ? error.message : 'Unknown error');
863
2030
  }
864
2031
  }
865
2032
  return null;
@@ -883,7 +2050,7 @@ class TriaglyAPI {
883
2050
  hardenedToken = await this.getToken();
884
2051
  }
885
2052
  catch (error) {
886
- console.error('Failed to get hardened token:', error);
2053
+ console.error('Failed to get hardened token:', error instanceof Error ? error.message : 'Unknown error');
887
2054
  throw new Error('Failed to authenticate. Please try again.');
888
2055
  }
889
2056
  }
@@ -896,9 +2063,9 @@ class TriaglyAPI {
896
2063
  consoleLogs: data.consoleLogs,
897
2064
  },
898
2065
  tags: data.tags,
899
- screenshot: data.screenshot,
900
2066
  reporterEmail: data.reporterEmail,
901
2067
  reporterName: data.reporterName,
2068
+ screenshot: data.screenshot,
902
2069
  turnstileToken,
903
2070
  hardenedToken,
904
2071
  };
@@ -983,34 +2150,6 @@ function detectBrowser() {
983
2150
  }
984
2151
  return browser;
985
2152
  }
986
- /**
987
- * Capture screenshot of current page
988
- */
989
- async function captureScreenshot() {
990
- try {
991
- // Use html2canvas library if available
992
- if (typeof window.html2canvas !== 'undefined') {
993
- const canvas = await window.html2canvas(document.body, {
994
- logging: false,
995
- useCORS: true,
996
- allowTaint: true,
997
- });
998
- return canvas.toDataURL('image/png');
999
- }
1000
- // Fallback to native screenshot API if supported (limited browser support)
1001
- if ('mediaDevices' in navigator && 'getDisplayMedia' in navigator.mediaDevices) {
1002
- // This requires user interaction and shows a permission dialog
1003
- // Not ideal for automatic screenshots
1004
- console.warn('Screenshot capture requires html2canvas library');
1005
- return null;
1006
- }
1007
- return null;
1008
- }
1009
- catch (error) {
1010
- console.error('Screenshot capture failed:', error);
1011
- return null;
1012
- }
1013
- }
1014
2153
  /**
1015
2154
  * Simple rate limiter using localStorage
1016
2155
  */
@@ -1063,7 +2202,7 @@ class RateLimiter {
1063
2202
  localStorage.setItem(this.key, JSON.stringify(data));
1064
2203
  }
1065
2204
  catch (error) {
1066
- console.error('Failed to store rate limit data:', error);
2205
+ console.error('Failed to store rate limit data:', error instanceof Error ? error.message : 'Unknown error');
1067
2206
  }
1068
2207
  }
1069
2208
  }
@@ -1168,7 +2307,7 @@ class ConsoleLogger {
1168
2307
  }
1169
2308
  catch (error) {
1170
2309
  // Don't let logging break the app
1171
- this.originalConsole.error('Failed to capture log:', error);
2310
+ this.originalConsole.error('Failed to capture log:', error instanceof Error ? error.message : 'Unknown error');
1172
2311
  }
1173
2312
  }
1174
2313
  /**
@@ -1231,7 +2370,7 @@ class Triagly {
1231
2370
  theme: 'auto',
1232
2371
  position: 'bottom-right',
1233
2372
  buttonShape: 'rounded',
1234
- buttonText: '🐛 Feedback',
2373
+ buttonText: 'Feedback',
1235
2374
  placeholderText: 'Describe what happened...',
1236
2375
  successMessage: 'Feedback sent successfully!',
1237
2376
  errorMessage: 'Failed to send feedback. Please try again.',
@@ -1242,7 +2381,7 @@ class Triagly {
1242
2381
  apiKey,
1243
2382
  publishableKey: apiKey, // Keep for backward compatibility
1244
2383
  };
1245
- this.api = new TriaglyAPI(apiKey, this.config.apiUrl, this.config.getToken, this.config.turnstileSiteKey);
2384
+ this.api = new TriaglyAPI(apiKey, this.config.environment || 'production', this.config.apiUrl, this.config.getToken, this.config.turnstileSiteKey);
1246
2385
  // Always pass Turnstile site key to widget (from API which has default)
1247
2386
  this.config.turnstileSiteKey = this.api.getTurnstileSiteKey();
1248
2387
  this.widget = new FeedbackWidget(this.config);
@@ -1286,18 +2425,13 @@ class Triagly {
1286
2425
  }
1287
2426
  // Collect metadata
1288
2427
  const metadata = collectMetadata(this.config.metadata);
1289
- // Capture screenshot if requested
1290
- let screenshot = null;
1291
- if (data.includeScreenshot) {
1292
- screenshot = await captureScreenshot();
1293
- }
1294
2428
  // Prepare feedback data
1295
2429
  const feedbackData = {
1296
2430
  title: data.title,
1297
2431
  description: data.description,
1298
2432
  reporterEmail: data.reporterEmail,
1299
- screenshot: screenshot || undefined,
1300
2433
  consoleLogs: this.consoleLogger?.getLogs(),
2434
+ screenshot: data.screenshot,
1301
2435
  };
1302
2436
  // Submit to API with Turnstile token if provided
1303
2437
  const response = await this.api.submitFeedback(feedbackData, metadata, data.turnstileToken);
@@ -1311,10 +2445,9 @@ class Triagly {
1311
2445
  if (this.config.onSuccess) {
1312
2446
  this.config.onSuccess(response.id);
1313
2447
  }
1314
- console.log('Feedback submitted successfully:', response.id);
1315
2448
  }
1316
2449
  catch (error) {
1317
- console.error('Failed to submit feedback:', error);
2450
+ console.error('Failed to submit feedback:', error instanceof Error ? error.message : 'Unknown error');
1318
2451
  // Dispatch error event for UI layer
1319
2452
  document.dispatchEvent(new CustomEvent('triagly:error', {
1320
2453
  detail: error