@triagly/sdk 1.3.0 → 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/annotation.d.ts +111 -0
- package/dist/annotation.d.ts.map +1 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +1169 -0
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1169 -0
- 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 +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +6 -0
- package/dist/ui.d.ts.map +1 -1
- package/package.json +2 -2
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
|
|
@@ -146,6 +1102,7 @@ class FeedbackWidget {
|
|
|
146
1102
|
this.container.remove();
|
|
147
1103
|
this.container = null;
|
|
148
1104
|
this.isOpen = false;
|
|
1105
|
+
this.screenshotDataUrl = null;
|
|
149
1106
|
// Restore focus to previously focused element
|
|
150
1107
|
if (this.previouslyFocusedElement) {
|
|
151
1108
|
this.previouslyFocusedElement.focus();
|
|
@@ -220,6 +1177,41 @@ class FeedbackWidget {
|
|
|
220
1177
|
/>
|
|
221
1178
|
</div>
|
|
222
1179
|
|
|
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>
|
|
1212
|
+
</div>
|
|
1213
|
+
` : ''}
|
|
1214
|
+
|
|
223
1215
|
${this.config.turnstileSiteKey ? `
|
|
224
1216
|
<div class="triagly-field triagly-turnstile">
|
|
225
1217
|
<div class="cf-turnstile" data-sitekey="${this.config.turnstileSiteKey}" data-theme="light"></div>
|
|
@@ -243,6 +1235,10 @@ class FeedbackWidget {
|
|
|
243
1235
|
e.preventDefault();
|
|
244
1236
|
this.handleSubmit(form);
|
|
245
1237
|
};
|
|
1238
|
+
// Set up screenshot event handlers
|
|
1239
|
+
if (this.config.enableScreenshot) {
|
|
1240
|
+
this.setupScreenshotHandlers(form);
|
|
1241
|
+
}
|
|
246
1242
|
const footer = document.createElement('div');
|
|
247
1243
|
footer.className = 'triagly-footer';
|
|
248
1244
|
footer.innerHTML = `
|
|
@@ -297,6 +1293,80 @@ class FeedbackWidget {
|
|
|
297
1293
|
console.error('Triagly: Failed to render Turnstile widget:', error instanceof Error ? error.message : 'Unknown error');
|
|
298
1294
|
}
|
|
299
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
|
+
}
|
|
300
1370
|
/**
|
|
301
1371
|
* Handle form submission
|
|
302
1372
|
*/
|
|
@@ -321,6 +1391,7 @@ class FeedbackWidget {
|
|
|
321
1391
|
reporterName: nameInput.value.trim() || undefined,
|
|
322
1392
|
reporterEmail: emailInput.value.trim() || undefined,
|
|
323
1393
|
turnstileToken,
|
|
1394
|
+
screenshot: this.screenshotDataUrl || undefined,
|
|
324
1395
|
};
|
|
325
1396
|
// Create a promise that waits for actual submission result
|
|
326
1397
|
const submissionPromise = new Promise((resolve, reject) => {
|
|
@@ -727,6 +1798,102 @@ class FeedbackWidget {
|
|
|
727
1798
|
.triagly-branding strong {
|
|
728
1799
|
font-weight: 600;
|
|
729
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
|
+
}
|
|
730
1897
|
`;
|
|
731
1898
|
document.head.appendChild(style);
|
|
732
1899
|
}
|
|
@@ -898,6 +2065,7 @@ class TriaglyAPI {
|
|
|
898
2065
|
tags: data.tags,
|
|
899
2066
|
reporterEmail: data.reporterEmail,
|
|
900
2067
|
reporterName: data.reporterName,
|
|
2068
|
+
screenshot: data.screenshot,
|
|
901
2069
|
turnstileToken,
|
|
902
2070
|
hardenedToken,
|
|
903
2071
|
};
|
|
@@ -1263,6 +2431,7 @@ class Triagly {
|
|
|
1263
2431
|
description: data.description,
|
|
1264
2432
|
reporterEmail: data.reporterEmail,
|
|
1265
2433
|
consoleLogs: this.consoleLogger?.getLogs(),
|
|
2434
|
+
screenshot: data.screenshot,
|
|
1266
2435
|
};
|
|
1267
2436
|
// Submit to API with Turnstile token if provided
|
|
1268
2437
|
const response = await this.api.submitFeedback(feedbackData, metadata, data.turnstileToken);
|