@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.js
CHANGED
|
@@ -4,6 +4,953 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Triagly = {}));
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
|
+
// Screenshot Capture Module
|
|
8
|
+
// Dynamically loads html2canvas and captures the page
|
|
9
|
+
// CDN URLs for html2canvas with fallbacks
|
|
10
|
+
const HTML2CANVAS_CDN_URLS = [
|
|
11
|
+
'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js',
|
|
12
|
+
'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js',
|
|
13
|
+
'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js',
|
|
14
|
+
];
|
|
15
|
+
class ScreenshotCapture {
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
this.html2canvasLoaded = false;
|
|
18
|
+
this.loadPromise = null;
|
|
19
|
+
this.quality = config.quality ?? 0.8;
|
|
20
|
+
this.maxWidth = config.maxWidth ?? 1920;
|
|
21
|
+
this.options = config.html2canvasOptions ?? {};
|
|
22
|
+
this.customCdnUrl = config.html2canvasCdnUrl;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Dynamically load html2canvas from CDN with fallbacks
|
|
26
|
+
* Returns true if loaded successfully, false otherwise
|
|
27
|
+
*/
|
|
28
|
+
async loadHtml2Canvas() {
|
|
29
|
+
// Return cached result if already attempted
|
|
30
|
+
if (this.html2canvasLoaded && window.html2canvas) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Return existing promise if load is in progress
|
|
34
|
+
if (this.loadPromise) {
|
|
35
|
+
return this.loadPromise;
|
|
36
|
+
}
|
|
37
|
+
// Check if already available in window (user may have loaded it)
|
|
38
|
+
if (window.html2canvas) {
|
|
39
|
+
this.html2canvasLoaded = true;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
// Build list of CDNs to try (custom URL first if provided)
|
|
43
|
+
const cdnUrls = this.customCdnUrl
|
|
44
|
+
? [this.customCdnUrl, ...HTML2CANVAS_CDN_URLS]
|
|
45
|
+
: HTML2CANVAS_CDN_URLS;
|
|
46
|
+
// Try loading from CDNs with fallback
|
|
47
|
+
this.loadPromise = this.tryLoadFromCdns(cdnUrls);
|
|
48
|
+
return this.loadPromise;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Try loading html2canvas from multiple CDN URLs
|
|
52
|
+
*/
|
|
53
|
+
async tryLoadFromCdns(urls) {
|
|
54
|
+
for (const url of urls) {
|
|
55
|
+
const success = await this.loadScriptFromUrl(url);
|
|
56
|
+
if (success && window.html2canvas) {
|
|
57
|
+
this.html2canvasLoaded = true;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
console.error('[Triagly] Failed to load html2canvas from all CDN sources');
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Load a script from a specific URL
|
|
66
|
+
*/
|
|
67
|
+
loadScriptFromUrl(url) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const script = document.createElement('script');
|
|
70
|
+
script.src = url;
|
|
71
|
+
script.async = true;
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
script.onload = null;
|
|
74
|
+
script.onerror = null;
|
|
75
|
+
};
|
|
76
|
+
script.onload = () => {
|
|
77
|
+
cleanup();
|
|
78
|
+
resolve(true);
|
|
79
|
+
};
|
|
80
|
+
script.onerror = () => {
|
|
81
|
+
cleanup();
|
|
82
|
+
// Remove failed script from DOM
|
|
83
|
+
script.remove();
|
|
84
|
+
resolve(false);
|
|
85
|
+
};
|
|
86
|
+
document.head.appendChild(script);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Capture screenshot of the current page
|
|
91
|
+
* Returns base64 data URL of the screenshot
|
|
92
|
+
*/
|
|
93
|
+
async capture() {
|
|
94
|
+
const loaded = await this.loadHtml2Canvas();
|
|
95
|
+
if (!loaded || !window.html2canvas) {
|
|
96
|
+
throw new Error('Screenshot capture requires html2canvas. ' +
|
|
97
|
+
'Include <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> ' +
|
|
98
|
+
'or ensure network access to CDN.');
|
|
99
|
+
}
|
|
100
|
+
// Hide the Triagly widget elements during capture
|
|
101
|
+
const widgetElements = document.querySelectorAll('.triagly-overlay, .triagly-button, #triagly-button');
|
|
102
|
+
const originalVisibility = new Map();
|
|
103
|
+
widgetElements.forEach((el) => {
|
|
104
|
+
const htmlEl = el;
|
|
105
|
+
originalVisibility.set(el, htmlEl.style.visibility);
|
|
106
|
+
htmlEl.style.visibility = 'hidden';
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const canvas = await window.html2canvas(document.body, {
|
|
110
|
+
useCORS: true,
|
|
111
|
+
allowTaint: false,
|
|
112
|
+
logging: false,
|
|
113
|
+
backgroundColor: null,
|
|
114
|
+
scale: Math.min(window.devicePixelRatio || 1, 2), // Cap at 2x for performance
|
|
115
|
+
...this.options,
|
|
116
|
+
});
|
|
117
|
+
// Resize if needed
|
|
118
|
+
const resized = this.resizeCanvas(canvas);
|
|
119
|
+
// Convert to JPEG data URL
|
|
120
|
+
return resized.toDataURL('image/jpeg', this.quality);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// Restore widget visibility
|
|
124
|
+
widgetElements.forEach((el) => {
|
|
125
|
+
const htmlEl = el;
|
|
126
|
+
htmlEl.style.visibility = originalVisibility.get(el) || '';
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resize canvas if it exceeds maxWidth
|
|
132
|
+
*/
|
|
133
|
+
resizeCanvas(canvas) {
|
|
134
|
+
if (canvas.width <= this.maxWidth) {
|
|
135
|
+
return canvas;
|
|
136
|
+
}
|
|
137
|
+
const ratio = this.maxWidth / canvas.width;
|
|
138
|
+
const resized = document.createElement('canvas');
|
|
139
|
+
resized.width = this.maxWidth;
|
|
140
|
+
resized.height = Math.round(canvas.height * ratio);
|
|
141
|
+
const ctx = resized.getContext('2d');
|
|
142
|
+
if (ctx) {
|
|
143
|
+
// Use high-quality image smoothing
|
|
144
|
+
ctx.imageSmoothingEnabled = true;
|
|
145
|
+
ctx.imageSmoothingQuality = 'high';
|
|
146
|
+
ctx.drawImage(canvas, 0, 0, resized.width, resized.height);
|
|
147
|
+
}
|
|
148
|
+
return resized;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get approximate file size of a data URL in bytes
|
|
152
|
+
*/
|
|
153
|
+
static getDataUrlSize(dataUrl) {
|
|
154
|
+
// Remove data URL prefix to get base64 content
|
|
155
|
+
const base64 = dataUrl.split(',')[1] || '';
|
|
156
|
+
// Base64 encodes 3 bytes into 4 characters
|
|
157
|
+
return Math.round((base64.length * 3) / 4);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Annotation Editor Module
|
|
162
|
+
// Canvas-based annotation overlay for marking up screenshots
|
|
163
|
+
class AnnotationEditor {
|
|
164
|
+
constructor(imageData, onComplete, onCancel) {
|
|
165
|
+
this.overlay = null;
|
|
166
|
+
this.canvas = null;
|
|
167
|
+
this.ctx = null;
|
|
168
|
+
this.image = null;
|
|
169
|
+
this.annotations = [];
|
|
170
|
+
this.currentTool = 'highlight';
|
|
171
|
+
this.currentColor = '#ff0000';
|
|
172
|
+
this.strokeWidth = 3;
|
|
173
|
+
this.isDrawing = false;
|
|
174
|
+
this.currentAnnotation = null;
|
|
175
|
+
this.scale = 1;
|
|
176
|
+
this.offsetX = 0;
|
|
177
|
+
this.offsetY = 0;
|
|
178
|
+
this.pendingTextPoint = null;
|
|
179
|
+
this.handleKeyDown = (e) => {
|
|
180
|
+
if (e.key === 'Escape') {
|
|
181
|
+
this.close(true);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
this.imageData = imageData;
|
|
185
|
+
this.onComplete = onComplete;
|
|
186
|
+
this.onCancel = onCancel;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Open the annotation editor
|
|
190
|
+
*/
|
|
191
|
+
async open() {
|
|
192
|
+
// Load the image first
|
|
193
|
+
this.image = await this.loadImage(this.imageData);
|
|
194
|
+
// Create the overlay UI
|
|
195
|
+
this.createOverlay();
|
|
196
|
+
// Set up the canvas
|
|
197
|
+
this.setupCanvas();
|
|
198
|
+
// Draw the image
|
|
199
|
+
this.render();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Load image from data URL
|
|
203
|
+
*/
|
|
204
|
+
loadImage(dataUrl) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const img = new Image();
|
|
207
|
+
img.onload = () => resolve(img);
|
|
208
|
+
img.onerror = () => reject(new Error('Failed to load image'));
|
|
209
|
+
img.src = dataUrl;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Create the fullscreen overlay UI
|
|
214
|
+
*/
|
|
215
|
+
createOverlay() {
|
|
216
|
+
this.overlay = document.createElement('div');
|
|
217
|
+
this.overlay.className = 'triagly-annotation-overlay';
|
|
218
|
+
this.overlay.innerHTML = `
|
|
219
|
+
<div class="triagly-annotation-header">
|
|
220
|
+
<div class="triagly-annotation-tools">
|
|
221
|
+
<button type="button" class="triagly-tool-btn active" data-tool="highlight" title="Highlight">
|
|
222
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
223
|
+
<rect x="3" y="3" width="18" height="18" rx="2" opacity="0.3" fill="currentColor"/>
|
|
224
|
+
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
225
|
+
</svg>
|
|
226
|
+
</button>
|
|
227
|
+
<button type="button" class="triagly-tool-btn" data-tool="arrow" title="Arrow">
|
|
228
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
229
|
+
<line x1="5" y1="19" x2="19" y2="5"/>
|
|
230
|
+
<polyline points="10,5 19,5 19,14"/>
|
|
231
|
+
</svg>
|
|
232
|
+
</button>
|
|
233
|
+
<button type="button" class="triagly-tool-btn" data-tool="freehand" title="Draw">
|
|
234
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
235
|
+
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
|
|
236
|
+
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
|
|
237
|
+
<path d="M2 2l7.586 7.586"/>
|
|
238
|
+
</svg>
|
|
239
|
+
</button>
|
|
240
|
+
<button type="button" class="triagly-tool-btn" data-tool="text" title="Text">
|
|
241
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
242
|
+
<polyline points="4 7 4 4 20 4 20 7"/>
|
|
243
|
+
<line x1="9" y1="20" x2="15" y2="20"/>
|
|
244
|
+
<line x1="12" y1="4" x2="12" y2="20"/>
|
|
245
|
+
</svg>
|
|
246
|
+
</button>
|
|
247
|
+
<div class="triagly-tool-divider"></div>
|
|
248
|
+
<input type="color" class="triagly-color-picker" value="#ff0000" title="Color">
|
|
249
|
+
</div>
|
|
250
|
+
<div class="triagly-annotation-actions">
|
|
251
|
+
<button type="button" class="triagly-annotation-btn triagly-btn-clear" title="Clear All">
|
|
252
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
253
|
+
<polyline points="3 6 5 6 21 6"/>
|
|
254
|
+
<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"/>
|
|
255
|
+
</svg>
|
|
256
|
+
Clear
|
|
257
|
+
</button>
|
|
258
|
+
<button type="button" class="triagly-annotation-btn triagly-btn-cancel">Cancel</button>
|
|
259
|
+
<button type="button" class="triagly-annotation-btn triagly-btn-done">Done</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="triagly-annotation-canvas-container">
|
|
263
|
+
<canvas class="triagly-annotation-canvas"></canvas>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="triagly-text-modal" id="triagly-text-modal" style="display: none;">
|
|
266
|
+
<div class="triagly-text-modal-content">
|
|
267
|
+
<h3>Add Text Annotation</h3>
|
|
268
|
+
<input type="text" id="triagly-text-input" placeholder="Enter text..." maxlength="200" autofocus />
|
|
269
|
+
<div class="triagly-text-modal-actions">
|
|
270
|
+
<button type="button" class="triagly-annotation-btn triagly-btn-cancel" id="triagly-text-cancel">Cancel</button>
|
|
271
|
+
<button type="button" class="triagly-annotation-btn triagly-btn-done" id="triagly-text-confirm">Add Text</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
`;
|
|
276
|
+
// Inject styles
|
|
277
|
+
this.injectStyles();
|
|
278
|
+
// Add event listeners
|
|
279
|
+
this.setupEventListeners();
|
|
280
|
+
document.body.appendChild(this.overlay);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Set up the canvas with correct dimensions
|
|
284
|
+
*/
|
|
285
|
+
setupCanvas() {
|
|
286
|
+
if (!this.overlay || !this.image)
|
|
287
|
+
return;
|
|
288
|
+
this.canvas = this.overlay.querySelector('.triagly-annotation-canvas');
|
|
289
|
+
const container = this.overlay.querySelector('.triagly-annotation-canvas-container');
|
|
290
|
+
if (!this.canvas || !container)
|
|
291
|
+
return;
|
|
292
|
+
this.ctx = this.canvas.getContext('2d');
|
|
293
|
+
if (!this.ctx)
|
|
294
|
+
return;
|
|
295
|
+
// Calculate scale to fit image in container
|
|
296
|
+
const containerRect = container.getBoundingClientRect();
|
|
297
|
+
const maxWidth = containerRect.width - 40; // Padding
|
|
298
|
+
const maxHeight = containerRect.height - 40;
|
|
299
|
+
const imageAspect = this.image.width / this.image.height;
|
|
300
|
+
const containerAspect = maxWidth / maxHeight;
|
|
301
|
+
let canvasWidth;
|
|
302
|
+
let canvasHeight;
|
|
303
|
+
if (imageAspect > containerAspect) {
|
|
304
|
+
// Image is wider - fit to width
|
|
305
|
+
canvasWidth = Math.min(this.image.width, maxWidth);
|
|
306
|
+
canvasHeight = canvasWidth / imageAspect;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// Image is taller - fit to height
|
|
310
|
+
canvasHeight = Math.min(this.image.height, maxHeight);
|
|
311
|
+
canvasWidth = canvasHeight * imageAspect;
|
|
312
|
+
}
|
|
313
|
+
this.scale = canvasWidth / this.image.width;
|
|
314
|
+
this.canvas.width = canvasWidth;
|
|
315
|
+
this.canvas.height = canvasHeight;
|
|
316
|
+
this.canvas.style.width = `${canvasWidth}px`;
|
|
317
|
+
this.canvas.style.height = `${canvasHeight}px`;
|
|
318
|
+
// Calculate offset for centering
|
|
319
|
+
this.offsetX = (containerRect.width - canvasWidth) / 2;
|
|
320
|
+
this.offsetY = (containerRect.height - canvasHeight) / 2;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Set up event listeners
|
|
324
|
+
*/
|
|
325
|
+
setupEventListeners() {
|
|
326
|
+
if (!this.overlay || !this.canvas)
|
|
327
|
+
return;
|
|
328
|
+
// Tool selection
|
|
329
|
+
const toolBtns = this.overlay.querySelectorAll('.triagly-tool-btn');
|
|
330
|
+
toolBtns.forEach((btn) => {
|
|
331
|
+
btn.addEventListener('click', (e) => {
|
|
332
|
+
const tool = e.currentTarget.dataset.tool;
|
|
333
|
+
if (tool) {
|
|
334
|
+
this.currentTool = tool;
|
|
335
|
+
toolBtns.forEach((b) => b.classList.remove('active'));
|
|
336
|
+
e.currentTarget.classList.add('active');
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
// Color picker
|
|
341
|
+
const colorPicker = this.overlay.querySelector('.triagly-color-picker');
|
|
342
|
+
if (colorPicker) {
|
|
343
|
+
colorPicker.addEventListener('change', (e) => {
|
|
344
|
+
this.currentColor = e.target.value;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// Clear button
|
|
348
|
+
const clearBtn = this.overlay.querySelector('.triagly-btn-clear');
|
|
349
|
+
if (clearBtn) {
|
|
350
|
+
clearBtn.addEventListener('click', () => {
|
|
351
|
+
this.annotations = [];
|
|
352
|
+
this.render();
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// Cancel button
|
|
356
|
+
const cancelBtn = this.overlay.querySelector('.triagly-btn-cancel');
|
|
357
|
+
if (cancelBtn) {
|
|
358
|
+
cancelBtn.addEventListener('click', () => this.close(true));
|
|
359
|
+
}
|
|
360
|
+
// Done button
|
|
361
|
+
const doneBtn = this.overlay.querySelector('.triagly-btn-done');
|
|
362
|
+
if (doneBtn) {
|
|
363
|
+
doneBtn.addEventListener('click', () => this.close(false));
|
|
364
|
+
}
|
|
365
|
+
// Canvas drawing events
|
|
366
|
+
this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e));
|
|
367
|
+
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
|
|
368
|
+
this.canvas.addEventListener('mouseup', () => this.handleMouseUp());
|
|
369
|
+
this.canvas.addEventListener('mouseleave', () => this.handleMouseUp());
|
|
370
|
+
// Touch support
|
|
371
|
+
this.canvas.addEventListener('touchstart', (e) => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
const touch = e.touches[0];
|
|
374
|
+
this.handleMouseDown(touch);
|
|
375
|
+
});
|
|
376
|
+
this.canvas.addEventListener('touchmove', (e) => {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
const touch = e.touches[0];
|
|
379
|
+
this.handleMouseMove(touch);
|
|
380
|
+
});
|
|
381
|
+
this.canvas.addEventListener('touchend', () => this.handleMouseUp());
|
|
382
|
+
this.canvas.addEventListener('touchcancel', () => this.handleMouseUp());
|
|
383
|
+
// Text modal event listeners
|
|
384
|
+
const textConfirmBtn = this.overlay.querySelector('#triagly-text-confirm');
|
|
385
|
+
const textCancelBtn = this.overlay.querySelector('#triagly-text-cancel');
|
|
386
|
+
const textInput = this.overlay.querySelector('#triagly-text-input');
|
|
387
|
+
textConfirmBtn?.addEventListener('click', () => this.confirmTextAnnotation());
|
|
388
|
+
textCancelBtn?.addEventListener('click', () => this.hideTextModal());
|
|
389
|
+
textInput?.addEventListener('keydown', (e) => {
|
|
390
|
+
if (e.key === 'Enter') {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
this.confirmTextAnnotation();
|
|
393
|
+
}
|
|
394
|
+
else if (e.key === 'Escape') {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
this.hideTextModal();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
// Escape key to cancel
|
|
400
|
+
document.addEventListener('keydown', this.handleKeyDown);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Get canvas coordinates from mouse event
|
|
404
|
+
*/
|
|
405
|
+
getCanvasPoint(e) {
|
|
406
|
+
if (!this.canvas)
|
|
407
|
+
return { x: 0, y: 0 };
|
|
408
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
409
|
+
return {
|
|
410
|
+
x: e.clientX - rect.left,
|
|
411
|
+
y: e.clientY - rect.top,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Handle mouse down - start drawing
|
|
416
|
+
*/
|
|
417
|
+
handleMouseDown(e) {
|
|
418
|
+
const point = this.getCanvasPoint(e);
|
|
419
|
+
this.isDrawing = true;
|
|
420
|
+
if (this.currentTool === 'text') {
|
|
421
|
+
// For text, show custom modal input
|
|
422
|
+
this.pendingTextPoint = point;
|
|
423
|
+
this.showTextModal();
|
|
424
|
+
this.isDrawing = false;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
this.currentAnnotation = {
|
|
428
|
+
type: this.currentTool,
|
|
429
|
+
color: this.currentColor,
|
|
430
|
+
strokeWidth: this.strokeWidth,
|
|
431
|
+
points: [point],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Show the text input modal
|
|
436
|
+
*/
|
|
437
|
+
showTextModal() {
|
|
438
|
+
if (!this.overlay)
|
|
439
|
+
return;
|
|
440
|
+
const modal = this.overlay.querySelector('#triagly-text-modal');
|
|
441
|
+
const input = this.overlay.querySelector('#triagly-text-input');
|
|
442
|
+
if (modal && input) {
|
|
443
|
+
modal.style.display = 'flex';
|
|
444
|
+
input.value = '';
|
|
445
|
+
input.focus();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Hide the text input modal
|
|
450
|
+
*/
|
|
451
|
+
hideTextModal() {
|
|
452
|
+
if (!this.overlay)
|
|
453
|
+
return;
|
|
454
|
+
const modal = this.overlay.querySelector('#triagly-text-modal');
|
|
455
|
+
if (modal) {
|
|
456
|
+
modal.style.display = 'none';
|
|
457
|
+
}
|
|
458
|
+
this.pendingTextPoint = null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Confirm text annotation from modal
|
|
462
|
+
*/
|
|
463
|
+
confirmTextAnnotation() {
|
|
464
|
+
if (!this.overlay || !this.pendingTextPoint)
|
|
465
|
+
return;
|
|
466
|
+
const input = this.overlay.querySelector('#triagly-text-input');
|
|
467
|
+
const text = input?.value.trim();
|
|
468
|
+
if (text) {
|
|
469
|
+
this.annotations.push({
|
|
470
|
+
type: 'text',
|
|
471
|
+
color: this.currentColor,
|
|
472
|
+
strokeWidth: this.strokeWidth,
|
|
473
|
+
points: [this.pendingTextPoint],
|
|
474
|
+
text,
|
|
475
|
+
});
|
|
476
|
+
this.render();
|
|
477
|
+
}
|
|
478
|
+
this.hideTextModal();
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Handle mouse move - continue drawing
|
|
482
|
+
*/
|
|
483
|
+
handleMouseMove(e) {
|
|
484
|
+
if (!this.isDrawing || !this.currentAnnotation)
|
|
485
|
+
return;
|
|
486
|
+
const point = this.getCanvasPoint(e);
|
|
487
|
+
if (this.currentTool === 'freehand') {
|
|
488
|
+
// Add point to path
|
|
489
|
+
this.currentAnnotation.points.push(point);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// For highlight and arrow, just update the end point
|
|
493
|
+
if (this.currentAnnotation.points.length === 1) {
|
|
494
|
+
this.currentAnnotation.points.push(point);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
this.currentAnnotation.points[1] = point;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.render();
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Handle mouse up - finish drawing
|
|
504
|
+
*/
|
|
505
|
+
handleMouseUp() {
|
|
506
|
+
if (this.isDrawing && this.currentAnnotation && this.currentAnnotation.points.length > 1) {
|
|
507
|
+
this.annotations.push(this.currentAnnotation);
|
|
508
|
+
}
|
|
509
|
+
this.isDrawing = false;
|
|
510
|
+
this.currentAnnotation = null;
|
|
511
|
+
this.render();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Render the canvas with image and annotations
|
|
515
|
+
*/
|
|
516
|
+
render() {
|
|
517
|
+
if (!this.ctx || !this.canvas || !this.image)
|
|
518
|
+
return;
|
|
519
|
+
// Clear canvas
|
|
520
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
521
|
+
// Draw the image
|
|
522
|
+
this.ctx.drawImage(this.image, 0, 0, this.canvas.width, this.canvas.height);
|
|
523
|
+
// Draw all annotations
|
|
524
|
+
for (const annotation of this.annotations) {
|
|
525
|
+
this.drawAnnotation(annotation);
|
|
526
|
+
}
|
|
527
|
+
// Draw current annotation being made
|
|
528
|
+
if (this.currentAnnotation) {
|
|
529
|
+
this.drawAnnotation(this.currentAnnotation);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Draw a single annotation
|
|
534
|
+
*/
|
|
535
|
+
drawAnnotation(annotation) {
|
|
536
|
+
if (!this.ctx)
|
|
537
|
+
return;
|
|
538
|
+
this.ctx.strokeStyle = annotation.color;
|
|
539
|
+
this.ctx.fillStyle = annotation.color;
|
|
540
|
+
this.ctx.lineWidth = annotation.strokeWidth;
|
|
541
|
+
this.ctx.lineCap = 'round';
|
|
542
|
+
this.ctx.lineJoin = 'round';
|
|
543
|
+
switch (annotation.type) {
|
|
544
|
+
case 'highlight':
|
|
545
|
+
this.drawHighlight(annotation);
|
|
546
|
+
break;
|
|
547
|
+
case 'arrow':
|
|
548
|
+
this.drawArrow(annotation);
|
|
549
|
+
break;
|
|
550
|
+
case 'freehand':
|
|
551
|
+
this.drawFreehand(annotation);
|
|
552
|
+
break;
|
|
553
|
+
case 'text':
|
|
554
|
+
this.drawText(annotation);
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Draw a semi-transparent highlight rectangle
|
|
560
|
+
*/
|
|
561
|
+
drawHighlight(annotation) {
|
|
562
|
+
if (!this.ctx || annotation.points.length < 2)
|
|
563
|
+
return;
|
|
564
|
+
const [start, end] = annotation.points;
|
|
565
|
+
const width = end.x - start.x;
|
|
566
|
+
const height = end.y - start.y;
|
|
567
|
+
// Semi-transparent fill
|
|
568
|
+
this.ctx.globalAlpha = 0.3;
|
|
569
|
+
this.ctx.fillRect(start.x, start.y, width, height);
|
|
570
|
+
// Solid border
|
|
571
|
+
this.ctx.globalAlpha = 1;
|
|
572
|
+
this.ctx.strokeRect(start.x, start.y, width, height);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Draw an arrow from start to end
|
|
576
|
+
*/
|
|
577
|
+
drawArrow(annotation) {
|
|
578
|
+
if (!this.ctx || annotation.points.length < 2)
|
|
579
|
+
return;
|
|
580
|
+
const [start, end] = annotation.points;
|
|
581
|
+
const headLength = 15;
|
|
582
|
+
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
|
583
|
+
// Draw the line
|
|
584
|
+
this.ctx.beginPath();
|
|
585
|
+
this.ctx.moveTo(start.x, start.y);
|
|
586
|
+
this.ctx.lineTo(end.x, end.y);
|
|
587
|
+
this.ctx.stroke();
|
|
588
|
+
// Draw the arrow head
|
|
589
|
+
this.ctx.beginPath();
|
|
590
|
+
this.ctx.moveTo(end.x, end.y);
|
|
591
|
+
this.ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
|
|
592
|
+
this.ctx.moveTo(end.x, end.y);
|
|
593
|
+
this.ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
|
|
594
|
+
this.ctx.stroke();
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Draw a freehand path
|
|
598
|
+
*/
|
|
599
|
+
drawFreehand(annotation) {
|
|
600
|
+
if (!this.ctx || annotation.points.length < 2)
|
|
601
|
+
return;
|
|
602
|
+
this.ctx.beginPath();
|
|
603
|
+
this.ctx.moveTo(annotation.points[0].x, annotation.points[0].y);
|
|
604
|
+
for (let i = 1; i < annotation.points.length; i++) {
|
|
605
|
+
this.ctx.lineTo(annotation.points[i].x, annotation.points[i].y);
|
|
606
|
+
}
|
|
607
|
+
this.ctx.stroke();
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Draw text annotation
|
|
611
|
+
*/
|
|
612
|
+
drawText(annotation) {
|
|
613
|
+
if (!this.ctx || !annotation.text || annotation.points.length < 1)
|
|
614
|
+
return;
|
|
615
|
+
const point = annotation.points[0];
|
|
616
|
+
// Text settings
|
|
617
|
+
this.ctx.font = 'bold 16px sans-serif';
|
|
618
|
+
this.ctx.textBaseline = 'top';
|
|
619
|
+
// Measure text for background
|
|
620
|
+
const metrics = this.ctx.measureText(annotation.text);
|
|
621
|
+
const padding = 4;
|
|
622
|
+
// Draw background
|
|
623
|
+
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
624
|
+
this.ctx.fillRect(point.x - padding, point.y - padding, metrics.width + padding * 2, 20 + padding * 2);
|
|
625
|
+
// Draw text
|
|
626
|
+
this.ctx.fillStyle = '#ffffff';
|
|
627
|
+
this.ctx.fillText(annotation.text, point.x, point.y);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Close the editor
|
|
631
|
+
*/
|
|
632
|
+
close(cancelled) {
|
|
633
|
+
document.removeEventListener('keydown', this.handleKeyDown);
|
|
634
|
+
if (cancelled) {
|
|
635
|
+
this.onCancel();
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
// Generate final annotated image
|
|
639
|
+
const annotatedImage = this.renderFinalImage();
|
|
640
|
+
this.onComplete(annotatedImage);
|
|
641
|
+
}
|
|
642
|
+
// Remove overlay
|
|
643
|
+
if (this.overlay) {
|
|
644
|
+
this.overlay.remove();
|
|
645
|
+
this.overlay = null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Render the final annotated image at full resolution
|
|
650
|
+
*/
|
|
651
|
+
renderFinalImage() {
|
|
652
|
+
if (!this.image)
|
|
653
|
+
return this.imageData;
|
|
654
|
+
// Create a full-resolution canvas
|
|
655
|
+
const canvas = document.createElement('canvas');
|
|
656
|
+
canvas.width = this.image.width;
|
|
657
|
+
canvas.height = this.image.height;
|
|
658
|
+
const ctx = canvas.getContext('2d');
|
|
659
|
+
if (!ctx)
|
|
660
|
+
return this.imageData;
|
|
661
|
+
// Draw original image
|
|
662
|
+
ctx.drawImage(this.image, 0, 0);
|
|
663
|
+
// Calculate scale factor
|
|
664
|
+
const scaleFactor = 1 / this.scale;
|
|
665
|
+
// Draw annotations at full resolution
|
|
666
|
+
for (const annotation of this.annotations) {
|
|
667
|
+
ctx.strokeStyle = annotation.color;
|
|
668
|
+
ctx.fillStyle = annotation.color;
|
|
669
|
+
ctx.lineWidth = annotation.strokeWidth * scaleFactor;
|
|
670
|
+
ctx.lineCap = 'round';
|
|
671
|
+
ctx.lineJoin = 'round';
|
|
672
|
+
// Scale points
|
|
673
|
+
const scaledPoints = annotation.points.map((p) => ({
|
|
674
|
+
x: p.x * scaleFactor,
|
|
675
|
+
y: p.y * scaleFactor,
|
|
676
|
+
}));
|
|
677
|
+
const scaledAnnotation = { ...annotation, points: scaledPoints };
|
|
678
|
+
switch (annotation.type) {
|
|
679
|
+
case 'highlight':
|
|
680
|
+
this.drawHighlightOnCtx(ctx, scaledAnnotation);
|
|
681
|
+
break;
|
|
682
|
+
case 'arrow':
|
|
683
|
+
this.drawArrowOnCtx(ctx, scaledAnnotation, scaleFactor);
|
|
684
|
+
break;
|
|
685
|
+
case 'freehand':
|
|
686
|
+
this.drawFreehandOnCtx(ctx, scaledAnnotation);
|
|
687
|
+
break;
|
|
688
|
+
case 'text':
|
|
689
|
+
this.drawTextOnCtx(ctx, scaledAnnotation, scaleFactor);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return canvas.toDataURL('image/jpeg', 0.9);
|
|
694
|
+
}
|
|
695
|
+
drawHighlightOnCtx(ctx, annotation) {
|
|
696
|
+
if (annotation.points.length < 2)
|
|
697
|
+
return;
|
|
698
|
+
const [start, end] = annotation.points;
|
|
699
|
+
const width = end.x - start.x;
|
|
700
|
+
const height = end.y - start.y;
|
|
701
|
+
ctx.globalAlpha = 0.3;
|
|
702
|
+
ctx.fillRect(start.x, start.y, width, height);
|
|
703
|
+
ctx.globalAlpha = 1;
|
|
704
|
+
ctx.strokeRect(start.x, start.y, width, height);
|
|
705
|
+
}
|
|
706
|
+
drawArrowOnCtx(ctx, annotation, scaleFactor) {
|
|
707
|
+
if (annotation.points.length < 2)
|
|
708
|
+
return;
|
|
709
|
+
const [start, end] = annotation.points;
|
|
710
|
+
const headLength = 15 * scaleFactor;
|
|
711
|
+
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
|
712
|
+
ctx.beginPath();
|
|
713
|
+
ctx.moveTo(start.x, start.y);
|
|
714
|
+
ctx.lineTo(end.x, end.y);
|
|
715
|
+
ctx.stroke();
|
|
716
|
+
ctx.beginPath();
|
|
717
|
+
ctx.moveTo(end.x, end.y);
|
|
718
|
+
ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
|
|
719
|
+
ctx.moveTo(end.x, end.y);
|
|
720
|
+
ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
|
|
721
|
+
ctx.stroke();
|
|
722
|
+
}
|
|
723
|
+
drawFreehandOnCtx(ctx, annotation) {
|
|
724
|
+
if (annotation.points.length < 2)
|
|
725
|
+
return;
|
|
726
|
+
ctx.beginPath();
|
|
727
|
+
ctx.moveTo(annotation.points[0].x, annotation.points[0].y);
|
|
728
|
+
for (let i = 1; i < annotation.points.length; i++) {
|
|
729
|
+
ctx.lineTo(annotation.points[i].x, annotation.points[i].y);
|
|
730
|
+
}
|
|
731
|
+
ctx.stroke();
|
|
732
|
+
}
|
|
733
|
+
drawTextOnCtx(ctx, annotation, scaleFactor) {
|
|
734
|
+
if (!annotation.text || annotation.points.length < 1)
|
|
735
|
+
return;
|
|
736
|
+
const point = annotation.points[0];
|
|
737
|
+
const fontSize = 16 * scaleFactor;
|
|
738
|
+
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
739
|
+
ctx.textBaseline = 'top';
|
|
740
|
+
const metrics = ctx.measureText(annotation.text);
|
|
741
|
+
const padding = 4 * scaleFactor;
|
|
742
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
743
|
+
ctx.fillRect(point.x - padding, point.y - padding, metrics.width + padding * 2, fontSize * 1.2 + padding * 2);
|
|
744
|
+
ctx.fillStyle = '#ffffff';
|
|
745
|
+
ctx.fillText(annotation.text, point.x, point.y);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Inject annotation editor styles
|
|
749
|
+
*/
|
|
750
|
+
injectStyles() {
|
|
751
|
+
const styleId = 'triagly-annotation-styles';
|
|
752
|
+
if (document.getElementById(styleId))
|
|
753
|
+
return;
|
|
754
|
+
const style = document.createElement('style');
|
|
755
|
+
style.id = styleId;
|
|
756
|
+
style.textContent = `
|
|
757
|
+
.triagly-annotation-overlay {
|
|
758
|
+
position: fixed;
|
|
759
|
+
top: 0;
|
|
760
|
+
left: 0;
|
|
761
|
+
right: 0;
|
|
762
|
+
bottom: 0;
|
|
763
|
+
z-index: 999999;
|
|
764
|
+
background: rgba(0, 0, 0, 0.9);
|
|
765
|
+
display: flex;
|
|
766
|
+
flex-direction: column;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.triagly-annotation-header {
|
|
770
|
+
display: flex;
|
|
771
|
+
justify-content: space-between;
|
|
772
|
+
align-items: center;
|
|
773
|
+
padding: 12px 20px;
|
|
774
|
+
background: #1a1a1a;
|
|
775
|
+
border-bottom: 1px solid #333;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.triagly-annotation-tools {
|
|
779
|
+
display: flex;
|
|
780
|
+
align-items: center;
|
|
781
|
+
gap: 8px;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.triagly-tool-btn {
|
|
785
|
+
display: flex;
|
|
786
|
+
align-items: center;
|
|
787
|
+
justify-content: center;
|
|
788
|
+
width: 40px;
|
|
789
|
+
height: 40px;
|
|
790
|
+
border: none;
|
|
791
|
+
border-radius: 8px;
|
|
792
|
+
background: #333;
|
|
793
|
+
color: #fff;
|
|
794
|
+
cursor: pointer;
|
|
795
|
+
transition: all 0.2s;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.triagly-tool-btn:hover {
|
|
799
|
+
background: #444;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.triagly-tool-btn.active {
|
|
803
|
+
background: #0066cc;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.triagly-tool-divider {
|
|
807
|
+
width: 1px;
|
|
808
|
+
height: 24px;
|
|
809
|
+
background: #444;
|
|
810
|
+
margin: 0 8px;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
.triagly-color-picker {
|
|
814
|
+
width: 40px;
|
|
815
|
+
height: 40px;
|
|
816
|
+
border: none;
|
|
817
|
+
border-radius: 8px;
|
|
818
|
+
cursor: pointer;
|
|
819
|
+
padding: 0;
|
|
820
|
+
background: none;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.triagly-color-picker::-webkit-color-swatch-wrapper {
|
|
824
|
+
padding: 4px;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.triagly-color-picker::-webkit-color-swatch {
|
|
828
|
+
border: none;
|
|
829
|
+
border-radius: 4px;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.triagly-annotation-actions {
|
|
833
|
+
display: flex;
|
|
834
|
+
gap: 8px;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
.triagly-annotation-btn {
|
|
838
|
+
padding: 8px 16px;
|
|
839
|
+
border: none;
|
|
840
|
+
border-radius: 8px;
|
|
841
|
+
font-size: 14px;
|
|
842
|
+
font-weight: 500;
|
|
843
|
+
cursor: pointer;
|
|
844
|
+
transition: all 0.2s;
|
|
845
|
+
display: flex;
|
|
846
|
+
align-items: center;
|
|
847
|
+
gap: 6px;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
.triagly-btn-clear {
|
|
851
|
+
background: transparent;
|
|
852
|
+
color: #999;
|
|
853
|
+
border: 1px solid #444;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.triagly-btn-clear:hover {
|
|
857
|
+
background: #333;
|
|
858
|
+
color: #fff;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
.triagly-btn-cancel {
|
|
862
|
+
background: #333;
|
|
863
|
+
color: #fff;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.triagly-btn-cancel:hover {
|
|
867
|
+
background: #444;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.triagly-btn-done {
|
|
871
|
+
background: #0066cc;
|
|
872
|
+
color: #fff;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.triagly-btn-done:hover {
|
|
876
|
+
background: #0055aa;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.triagly-annotation-canvas-container {
|
|
880
|
+
flex: 1;
|
|
881
|
+
display: flex;
|
|
882
|
+
align-items: center;
|
|
883
|
+
justify-content: center;
|
|
884
|
+
overflow: hidden;
|
|
885
|
+
padding: 20px;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.triagly-annotation-canvas {
|
|
889
|
+
cursor: crosshair;
|
|
890
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
891
|
+
border-radius: 4px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.triagly-text-modal {
|
|
895
|
+
position: absolute;
|
|
896
|
+
top: 0;
|
|
897
|
+
left: 0;
|
|
898
|
+
right: 0;
|
|
899
|
+
bottom: 0;
|
|
900
|
+
background: rgba(0, 0, 0, 0.7);
|
|
901
|
+
display: flex;
|
|
902
|
+
align-items: center;
|
|
903
|
+
justify-content: center;
|
|
904
|
+
z-index: 1000000;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
.triagly-text-modal-content {
|
|
908
|
+
background: #1a1a1a;
|
|
909
|
+
border-radius: 12px;
|
|
910
|
+
padding: 24px;
|
|
911
|
+
min-width: 320px;
|
|
912
|
+
max-width: 400px;
|
|
913
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.triagly-text-modal-content h3 {
|
|
917
|
+
color: #fff;
|
|
918
|
+
font-size: 16px;
|
|
919
|
+
font-weight: 600;
|
|
920
|
+
margin: 0 0 16px 0;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
.triagly-text-modal-content input {
|
|
924
|
+
width: 100%;
|
|
925
|
+
padding: 12px;
|
|
926
|
+
border: 1px solid #444;
|
|
927
|
+
border-radius: 8px;
|
|
928
|
+
background: #2a2a2a;
|
|
929
|
+
color: #fff;
|
|
930
|
+
font-size: 14px;
|
|
931
|
+
outline: none;
|
|
932
|
+
box-sizing: border-box;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
.triagly-text-modal-content input:focus {
|
|
936
|
+
border-color: #0066cc;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.triagly-text-modal-content input::placeholder {
|
|
940
|
+
color: #666;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.triagly-text-modal-actions {
|
|
944
|
+
display: flex;
|
|
945
|
+
gap: 8px;
|
|
946
|
+
justify-content: flex-end;
|
|
947
|
+
margin-top: 16px;
|
|
948
|
+
}
|
|
949
|
+
`;
|
|
950
|
+
document.head.appendChild(style);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
7
954
|
// Feedback Widget UI
|
|
8
955
|
class FeedbackWidget {
|
|
9
956
|
constructor(config) {
|
|
@@ -11,6 +958,8 @@
|
|
|
11
958
|
this.isOpen = false;
|
|
12
959
|
this.previouslyFocusedElement = null;
|
|
13
960
|
this.focusableElements = [];
|
|
961
|
+
this.screenshotDataUrl = null;
|
|
962
|
+
this.screenshotCapture = null;
|
|
14
963
|
this.config = config;
|
|
15
964
|
}
|
|
16
965
|
/**
|
|
@@ -23,6 +972,13 @@
|
|
|
23
972
|
if (this.config.turnstileSiteKey) {
|
|
24
973
|
this.loadTurnstileScript();
|
|
25
974
|
}
|
|
975
|
+
// Initialize screenshot capture if enabled
|
|
976
|
+
if (this.config.enableScreenshot) {
|
|
977
|
+
this.screenshotCapture = new ScreenshotCapture({
|
|
978
|
+
quality: this.config.screenshotQuality,
|
|
979
|
+
maxWidth: this.config.screenshotMaxWidth,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
26
982
|
}
|
|
27
983
|
/**
|
|
28
984
|
* Load Cloudflare Turnstile script dynamically
|
|
@@ -152,6 +1108,7 @@
|
|
|
152
1108
|
this.container.remove();
|
|
153
1109
|
this.container = null;
|
|
154
1110
|
this.isOpen = false;
|
|
1111
|
+
this.screenshotDataUrl = null;
|
|
155
1112
|
// Restore focus to previously focused element
|
|
156
1113
|
if (this.previouslyFocusedElement) {
|
|
157
1114
|
this.previouslyFocusedElement.focus();
|
|
@@ -226,6 +1183,41 @@
|
|
|
226
1183
|
/>
|
|
227
1184
|
</div>
|
|
228
1185
|
|
|
1186
|
+
${this.config.enableScreenshot ? `
|
|
1187
|
+
<div class="triagly-field triagly-screenshot-field">
|
|
1188
|
+
<label>Screenshot (optional)</label>
|
|
1189
|
+
<div class="triagly-screenshot-controls" id="triagly-screenshot-controls">
|
|
1190
|
+
<button type="button" class="triagly-btn-capture" id="triagly-capture-btn">
|
|
1191
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1192
|
+
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
1193
|
+
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
1194
|
+
<path d="M21 15l-5-5L5 21"/>
|
|
1195
|
+
</svg>
|
|
1196
|
+
${this.config.screenshotButtonText || 'Capture Screenshot'}
|
|
1197
|
+
</button>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div class="triagly-screenshot-preview" id="triagly-screenshot-preview" style="display: none;">
|
|
1200
|
+
<img id="triagly-screenshot-img" alt="Screenshot preview" />
|
|
1201
|
+
<div class="triagly-screenshot-actions">
|
|
1202
|
+
${this.config.enableAnnotation !== false ? `
|
|
1203
|
+
<button type="button" class="triagly-btn-icon" id="triagly-annotate-btn" title="Add annotations">
|
|
1204
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1205
|
+
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
|
|
1206
|
+
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
|
|
1207
|
+
</svg>
|
|
1208
|
+
</button>
|
|
1209
|
+
` : ''}
|
|
1210
|
+
<button type="button" class="triagly-btn-icon triagly-btn-danger" id="triagly-remove-screenshot-btn" title="Remove screenshot">
|
|
1211
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1212
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
1213
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
1214
|
+
</svg>
|
|
1215
|
+
</button>
|
|
1216
|
+
</div>
|
|
1217
|
+
</div>
|
|
1218
|
+
</div>
|
|
1219
|
+
` : ''}
|
|
1220
|
+
|
|
229
1221
|
${this.config.turnstileSiteKey ? `
|
|
230
1222
|
<div class="triagly-field triagly-turnstile">
|
|
231
1223
|
<div class="cf-turnstile" data-sitekey="${this.config.turnstileSiteKey}" data-theme="light"></div>
|
|
@@ -249,6 +1241,10 @@
|
|
|
249
1241
|
e.preventDefault();
|
|
250
1242
|
this.handleSubmit(form);
|
|
251
1243
|
};
|
|
1244
|
+
// Set up screenshot event handlers
|
|
1245
|
+
if (this.config.enableScreenshot) {
|
|
1246
|
+
this.setupScreenshotHandlers(form);
|
|
1247
|
+
}
|
|
252
1248
|
const footer = document.createElement('div');
|
|
253
1249
|
footer.className = 'triagly-footer';
|
|
254
1250
|
footer.innerHTML = `
|
|
@@ -303,6 +1299,80 @@
|
|
|
303
1299
|
console.error('Triagly: Failed to render Turnstile widget:', error instanceof Error ? error.message : 'Unknown error');
|
|
304
1300
|
}
|
|
305
1301
|
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Set up screenshot capture event handlers
|
|
1304
|
+
*/
|
|
1305
|
+
setupScreenshotHandlers(form) {
|
|
1306
|
+
const captureBtn = form.querySelector('#triagly-capture-btn');
|
|
1307
|
+
const annotateBtn = form.querySelector('#triagly-annotate-btn');
|
|
1308
|
+
const removeBtn = form.querySelector('#triagly-remove-screenshot-btn');
|
|
1309
|
+
const preview = form.querySelector('#triagly-screenshot-preview');
|
|
1310
|
+
const controls = form.querySelector('#triagly-screenshot-controls');
|
|
1311
|
+
const previewImg = form.querySelector('#triagly-screenshot-img');
|
|
1312
|
+
// Capture screenshot
|
|
1313
|
+
captureBtn?.addEventListener('click', async () => {
|
|
1314
|
+
if (!this.screenshotCapture)
|
|
1315
|
+
return;
|
|
1316
|
+
const btn = captureBtn;
|
|
1317
|
+
const originalText = btn.innerHTML;
|
|
1318
|
+
btn.disabled = true;
|
|
1319
|
+
btn.innerHTML = `
|
|
1320
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="triagly-spin">
|
|
1321
|
+
<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"/>
|
|
1322
|
+
<path d="M12 2v4"/>
|
|
1323
|
+
</svg>
|
|
1324
|
+
Capturing...
|
|
1325
|
+
`;
|
|
1326
|
+
try {
|
|
1327
|
+
const dataUrl = await this.screenshotCapture.capture();
|
|
1328
|
+
this.screenshotDataUrl = dataUrl;
|
|
1329
|
+
// Update UI
|
|
1330
|
+
if (previewImg)
|
|
1331
|
+
previewImg.src = dataUrl;
|
|
1332
|
+
if (preview)
|
|
1333
|
+
preview.style.display = 'block';
|
|
1334
|
+
if (controls)
|
|
1335
|
+
controls.style.display = 'none';
|
|
1336
|
+
// Call callback if provided
|
|
1337
|
+
if (this.config.onScreenshotCapture) {
|
|
1338
|
+
this.config.onScreenshotCapture(dataUrl);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
console.error('Triagly: Screenshot capture failed:', error);
|
|
1343
|
+
if (this.config.onScreenshotError) {
|
|
1344
|
+
this.config.onScreenshotError(error instanceof Error ? error : new Error(String(error)));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
btn.disabled = false;
|
|
1349
|
+
btn.innerHTML = originalText;
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
// Annotate screenshot
|
|
1353
|
+
annotateBtn?.addEventListener('click', () => {
|
|
1354
|
+
if (!this.screenshotDataUrl)
|
|
1355
|
+
return;
|
|
1356
|
+
const editor = new AnnotationEditor(this.screenshotDataUrl, (annotatedImage) => {
|
|
1357
|
+
this.screenshotDataUrl = annotatedImage;
|
|
1358
|
+
if (previewImg)
|
|
1359
|
+
previewImg.src = annotatedImage;
|
|
1360
|
+
}, () => {
|
|
1361
|
+
// Cancelled - do nothing
|
|
1362
|
+
});
|
|
1363
|
+
editor.open();
|
|
1364
|
+
});
|
|
1365
|
+
// Remove screenshot
|
|
1366
|
+
removeBtn?.addEventListener('click', () => {
|
|
1367
|
+
this.screenshotDataUrl = null;
|
|
1368
|
+
if (preview)
|
|
1369
|
+
preview.style.display = 'none';
|
|
1370
|
+
if (controls)
|
|
1371
|
+
controls.style.display = 'block';
|
|
1372
|
+
if (previewImg)
|
|
1373
|
+
previewImg.src = '';
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
306
1376
|
/**
|
|
307
1377
|
* Handle form submission
|
|
308
1378
|
*/
|
|
@@ -327,6 +1397,7 @@
|
|
|
327
1397
|
reporterName: nameInput.value.trim() || undefined,
|
|
328
1398
|
reporterEmail: emailInput.value.trim() || undefined,
|
|
329
1399
|
turnstileToken,
|
|
1400
|
+
screenshot: this.screenshotDataUrl || undefined,
|
|
330
1401
|
};
|
|
331
1402
|
// Create a promise that waits for actual submission result
|
|
332
1403
|
const submissionPromise = new Promise((resolve, reject) => {
|
|
@@ -733,6 +1804,102 @@
|
|
|
733
1804
|
.triagly-branding strong {
|
|
734
1805
|
font-weight: 600;
|
|
735
1806
|
}
|
|
1807
|
+
|
|
1808
|
+
/* Screenshot styles */
|
|
1809
|
+
.triagly-screenshot-field {
|
|
1810
|
+
margin-bottom: 16px;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
.triagly-screenshot-controls {
|
|
1814
|
+
display: flex;
|
|
1815
|
+
gap: 8px;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
.triagly-btn-capture {
|
|
1819
|
+
display: inline-flex;
|
|
1820
|
+
align-items: center;
|
|
1821
|
+
gap: 8px;
|
|
1822
|
+
padding: 10px 16px;
|
|
1823
|
+
background: var(--triagly-btn-secondary-bg, #f3f4f6);
|
|
1824
|
+
color: var(--triagly-btn-secondary-text, #374151);
|
|
1825
|
+
border: 1px dashed var(--triagly-input-border, #d1d5db);
|
|
1826
|
+
border-radius: var(--triagly-input-radius, 6px);
|
|
1827
|
+
font-size: 14px;
|
|
1828
|
+
font-weight: 500;
|
|
1829
|
+
cursor: pointer;
|
|
1830
|
+
transition: all 0.2s;
|
|
1831
|
+
width: 100%;
|
|
1832
|
+
justify-content: center;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
.triagly-btn-capture:hover:not(:disabled) {
|
|
1836
|
+
background: var(--triagly-btn-secondary-bg-hover, #e5e7eb);
|
|
1837
|
+
border-style: solid;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
.triagly-btn-capture:disabled {
|
|
1841
|
+
opacity: 0.7;
|
|
1842
|
+
cursor: wait;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
.triagly-screenshot-preview {
|
|
1846
|
+
position: relative;
|
|
1847
|
+
border-radius: var(--triagly-input-radius, 6px);
|
|
1848
|
+
overflow: hidden;
|
|
1849
|
+
border: 1px solid var(--triagly-input-border, #d1d5db);
|
|
1850
|
+
background: var(--triagly-input-bg, #ffffff);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
.triagly-screenshot-preview img {
|
|
1854
|
+
display: block;
|
|
1855
|
+
width: 100%;
|
|
1856
|
+
max-height: 200px;
|
|
1857
|
+
object-fit: contain;
|
|
1858
|
+
background: #f9fafb;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
.triagly-screenshot-actions {
|
|
1862
|
+
position: absolute;
|
|
1863
|
+
top: 8px;
|
|
1864
|
+
right: 8px;
|
|
1865
|
+
display: flex;
|
|
1866
|
+
gap: 4px;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
.triagly-btn-icon {
|
|
1870
|
+
display: flex;
|
|
1871
|
+
align-items: center;
|
|
1872
|
+
justify-content: center;
|
|
1873
|
+
width: 28px;
|
|
1874
|
+
height: 28px;
|
|
1875
|
+
padding: 0;
|
|
1876
|
+
background: rgba(255, 255, 255, 0.9);
|
|
1877
|
+
border: 1px solid var(--triagly-input-border, #d1d5db);
|
|
1878
|
+
border-radius: 6px;
|
|
1879
|
+
cursor: pointer;
|
|
1880
|
+
transition: all 0.2s;
|
|
1881
|
+
color: #374151;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
.triagly-btn-icon:hover {
|
|
1885
|
+
background: #ffffff;
|
|
1886
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
.triagly-btn-icon.triagly-btn-danger:hover {
|
|
1890
|
+
background: #fee2e2;
|
|
1891
|
+
border-color: #fca5a5;
|
|
1892
|
+
color: #dc2626;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
@keyframes triagly-spin {
|
|
1896
|
+
from { transform: rotate(0deg); }
|
|
1897
|
+
to { transform: rotate(360deg); }
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
.triagly-spin {
|
|
1901
|
+
animation: triagly-spin 1s linear infinite;
|
|
1902
|
+
}
|
|
736
1903
|
`;
|
|
737
1904
|
document.head.appendChild(style);
|
|
738
1905
|
}
|
|
@@ -904,6 +2071,7 @@
|
|
|
904
2071
|
tags: data.tags,
|
|
905
2072
|
reporterEmail: data.reporterEmail,
|
|
906
2073
|
reporterName: data.reporterName,
|
|
2074
|
+
screenshot: data.screenshot,
|
|
907
2075
|
turnstileToken,
|
|
908
2076
|
hardenedToken,
|
|
909
2077
|
};
|
|
@@ -1269,6 +2437,7 @@
|
|
|
1269
2437
|
description: data.description,
|
|
1270
2438
|
reporterEmail: data.reporterEmail,
|
|
1271
2439
|
consoleLogs: this.consoleLogger?.getLogs(),
|
|
2440
|
+
screenshot: data.screenshot,
|
|
1272
2441
|
};
|
|
1273
2442
|
// Submit to API with Turnstile token if provided
|
|
1274
2443
|
const response = await this.api.submitFeedback(feedbackData, metadata, data.turnstileToken);
|