@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/README.md +1 -24
- package/dist/annotation.d.ts +111 -0
- package/dist/annotation.d.ts.map +1 -0
- package/dist/api.d.ts +2 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +1262 -129
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1262 -129
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/dist/screenshot.d.ts +42 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/types.d.ts +16 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +6 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/utils.d.ts +0 -4
- package/dist/utils.d.ts.map +1 -1
- package/package.json +2 -3
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 || '
|
|
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
|
|
56
|
-
button.innerHTML =
|
|
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
|
-
|
|
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
|
|
134
|
-
const
|
|
135
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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, #
|
|
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(
|
|
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, #
|
|
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(
|
|
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, #
|
|
500
|
-
box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(
|
|
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:
|
|
1636
|
+
justify-content: flex-end;
|
|
560
1637
|
align-items: center;
|
|
561
|
-
padding:
|
|
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, #
|
|
629
|
-
box-shadow: 0 0 0
|
|
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 #
|
|
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, #
|
|
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, #
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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
|