tokimeki-image-editor 0.1.13 → 0.2.0

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.
@@ -0,0 +1,844 @@
1
+ <script lang="ts">import { onMount } from 'svelte';
2
+ import { _ } from 'svelte-i18n';
3
+ import { screenToImageCoords } from '../utils/canvas';
4
+ import { X, Pencil, Eraser, ArrowRight, Square } from 'lucide-svelte';
5
+ let { canvas, image, viewport, transform, annotations, cropArea, onUpdate, onClose, onViewportChange } = $props();
6
+ let containerElement = $state(null);
7
+ // Tool settings
8
+ let currentTool = $state('pen');
9
+ let currentColor = $state('#FF6B6B');
10
+ let strokeWidth = $state(10);
11
+ let shadowEnabled = $state(false);
12
+ // Preset colors - modern bright tones
13
+ const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9B72F2', '#F8F9FA', '#495057'];
14
+ // Drawing state
15
+ let isDrawing = $state(false);
16
+ let currentAnnotation = $state(null);
17
+ // Helper to get coordinates from mouse or touch event
18
+ function getEventCoords(event) {
19
+ if ('touches' in event && event.touches.length > 0) {
20
+ return { clientX: event.touches[0].clientX, clientY: event.touches[0].clientY };
21
+ }
22
+ else if ('clientX' in event) {
23
+ return { clientX: event.clientX, clientY: event.clientY };
24
+ }
25
+ return { clientX: 0, clientY: 0 };
26
+ }
27
+ // Convert screen coords to image coords (crop-aware)
28
+ function toImageCoords(clientX, clientY) {
29
+ if (!canvas || !image)
30
+ return null;
31
+ const rect = canvas.getBoundingClientRect();
32
+ const scaleX = canvas.width / rect.width;
33
+ const scaleY = canvas.height / rect.height;
34
+ const canvasX = (clientX - rect.left) * scaleX;
35
+ const canvasY = (clientY - rect.top) * scaleY;
36
+ const centerX = canvas.width / 2;
37
+ const centerY = canvas.height / 2;
38
+ const totalScale = viewport.scale * viewport.zoom;
39
+ // Calculate based on crop or full image
40
+ const sourceWidth = cropArea ? cropArea.width : image.width;
41
+ const sourceHeight = cropArea ? cropArea.height : image.height;
42
+ const offsetX = cropArea ? cropArea.x : 0;
43
+ const offsetY = cropArea ? cropArea.y : 0;
44
+ // Convert to crop-relative coordinates
45
+ const relativeX = (canvasX - centerX - viewport.offsetX) / totalScale + sourceWidth / 2;
46
+ const relativeY = (canvasY - centerY - viewport.offsetY) / totalScale + sourceHeight / 2;
47
+ // Convert to absolute image coordinates
48
+ return {
49
+ x: relativeX + offsetX,
50
+ y: relativeY + offsetY
51
+ };
52
+ }
53
+ // Convert image coords to canvas coords for rendering
54
+ function toCanvasCoords(point) {
55
+ if (!canvas || !image)
56
+ return null;
57
+ const centerX = canvas.width / 2;
58
+ const centerY = canvas.height / 2;
59
+ const totalScale = viewport.scale * viewport.zoom;
60
+ const sourceWidth = cropArea ? cropArea.width : image.width;
61
+ const sourceHeight = cropArea ? cropArea.height : image.height;
62
+ const offsetX = cropArea ? cropArea.x : 0;
63
+ const offsetY = cropArea ? cropArea.y : 0;
64
+ const relativeX = point.x - offsetX;
65
+ const relativeY = point.y - offsetY;
66
+ return {
67
+ x: (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX,
68
+ y: (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY
69
+ };
70
+ }
71
+ onMount(() => {
72
+ if (containerElement) {
73
+ containerElement.addEventListener('touchstart', handleTouchStart, { passive: false });
74
+ containerElement.addEventListener('touchmove', handleTouchMove, { passive: false });
75
+ containerElement.addEventListener('touchend', handleTouchEnd, { passive: false });
76
+ }
77
+ return () => {
78
+ if (containerElement) {
79
+ containerElement.removeEventListener('touchstart', handleTouchStart);
80
+ containerElement.removeEventListener('touchmove', handleTouchMove);
81
+ containerElement.removeEventListener('touchend', handleTouchEnd);
82
+ }
83
+ };
84
+ });
85
+ function handleMouseDown(event) {
86
+ if (!canvas || !image)
87
+ return;
88
+ if ('button' in event && event.button !== 0)
89
+ return;
90
+ const coords = getEventCoords(event);
91
+ const imagePoint = toImageCoords(coords.clientX, coords.clientY);
92
+ if (!imagePoint)
93
+ return;
94
+ event.preventDefault();
95
+ if (currentTool === 'eraser') {
96
+ // Find and remove annotation at click point
97
+ const canvasPoint = { x: coords.clientX, y: coords.clientY };
98
+ const rect = canvas.getBoundingClientRect();
99
+ const localX = canvasPoint.x - rect.left;
100
+ const localY = canvasPoint.y - rect.top;
101
+ const scaleX = canvas.width / rect.width;
102
+ const scaleY = canvas.height / rect.height;
103
+ const canvasX = localX * scaleX;
104
+ const canvasY = localY * scaleY;
105
+ // Check each annotation for hit
106
+ const hitIndex = findAnnotationAtPoint(canvasX, canvasY);
107
+ if (hitIndex !== -1) {
108
+ const updated = annotations.filter((_, i) => i !== hitIndex);
109
+ onUpdate(updated);
110
+ }
111
+ return;
112
+ }
113
+ isDrawing = true;
114
+ if (currentTool === 'pen') {
115
+ currentAnnotation = {
116
+ id: `annotation-${Date.now()}`,
117
+ type: 'pen',
118
+ color: currentColor,
119
+ strokeWidth: strokeWidth,
120
+ points: [imagePoint],
121
+ shadow: shadowEnabled
122
+ };
123
+ }
124
+ else if (currentTool === 'arrow' || currentTool === 'rectangle') {
125
+ currentAnnotation = {
126
+ id: `annotation-${Date.now()}`,
127
+ type: currentTool,
128
+ color: currentColor,
129
+ strokeWidth: strokeWidth,
130
+ points: [imagePoint, imagePoint],
131
+ shadow: shadowEnabled
132
+ };
133
+ }
134
+ }
135
+ // Minimum distance threshold for pen tool (in image coordinates)
136
+ // This absorbs micro-movements of the mouse
137
+ const MIN_POINT_DISTANCE = 3;
138
+ function handleMouseMove(event) {
139
+ if (!isDrawing || !currentAnnotation || !canvas || !image)
140
+ return;
141
+ const coords = getEventCoords(event);
142
+ const imagePoint = toImageCoords(coords.clientX, coords.clientY);
143
+ if (!imagePoint)
144
+ return;
145
+ event.preventDefault();
146
+ if (currentTool === 'pen') {
147
+ // Check distance from last point to absorb micro-movements
148
+ const lastPoint = currentAnnotation.points[currentAnnotation.points.length - 1];
149
+ const dx = imagePoint.x - lastPoint.x;
150
+ const dy = imagePoint.y - lastPoint.y;
151
+ const distance = Math.sqrt(dx * dx + dy * dy);
152
+ // Only add point if it's far enough from the last point
153
+ if (distance >= MIN_POINT_DISTANCE) {
154
+ currentAnnotation = {
155
+ ...currentAnnotation,
156
+ points: [...currentAnnotation.points, imagePoint]
157
+ };
158
+ }
159
+ }
160
+ else if (currentTool === 'arrow' || currentTool === 'rectangle') {
161
+ currentAnnotation = {
162
+ ...currentAnnotation,
163
+ points: [currentAnnotation.points[0], imagePoint]
164
+ };
165
+ }
166
+ }
167
+ function handleMouseUp(event) {
168
+ if (!isDrawing || !currentAnnotation) {
169
+ isDrawing = false;
170
+ return;
171
+ }
172
+ // Only save if annotation has enough points
173
+ if (currentAnnotation.points.length >= 2 ||
174
+ (currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1)) {
175
+ // For arrow/rectangle, ensure start and end are different
176
+ if (currentAnnotation.type !== 'pen') {
177
+ const start = currentAnnotation.points[0];
178
+ const end = currentAnnotation.points[1];
179
+ const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
180
+ if (distance > 5) {
181
+ onUpdate([...annotations, currentAnnotation]);
182
+ }
183
+ }
184
+ else {
185
+ onUpdate([...annotations, currentAnnotation]);
186
+ }
187
+ }
188
+ isDrawing = false;
189
+ currentAnnotation = null;
190
+ }
191
+ function findAnnotationAtPoint(canvasX, canvasY) {
192
+ const hitRadius = 10;
193
+ for (let i = annotations.length - 1; i >= 0; i--) {
194
+ const annotation = annotations[i];
195
+ const points = annotation.points.map(p => toCanvasCoords(p)).filter(Boolean);
196
+ if (annotation.type === 'pen') {
197
+ // Check distance to any segment
198
+ for (let j = 0; j < points.length - 1; j++) {
199
+ const dist = pointToSegmentDistance(canvasX, canvasY, points[j], points[j + 1]);
200
+ if (dist < hitRadius)
201
+ return i;
202
+ }
203
+ }
204
+ else if (annotation.type === 'arrow') {
205
+ if (points.length >= 2) {
206
+ const dist = pointToSegmentDistance(canvasX, canvasY, points[0], points[1]);
207
+ if (dist < hitRadius)
208
+ return i;
209
+ }
210
+ }
211
+ else if (annotation.type === 'rectangle') {
212
+ if (points.length >= 2) {
213
+ const minX = Math.min(points[0].x, points[1].x);
214
+ const maxX = Math.max(points[0].x, points[1].x);
215
+ const minY = Math.min(points[0].y, points[1].y);
216
+ const maxY = Math.max(points[0].y, points[1].y);
217
+ // Check if near any edge
218
+ const nearTop = Math.abs(canvasY - minY) < hitRadius && canvasX >= minX - hitRadius && canvasX <= maxX + hitRadius;
219
+ const nearBottom = Math.abs(canvasY - maxY) < hitRadius && canvasX >= minX - hitRadius && canvasX <= maxX + hitRadius;
220
+ const nearLeft = Math.abs(canvasX - minX) < hitRadius && canvasY >= minY - hitRadius && canvasY <= maxY + hitRadius;
221
+ const nearRight = Math.abs(canvasX - maxX) < hitRadius && canvasY >= minY - hitRadius && canvasY <= maxY + hitRadius;
222
+ if (nearTop || nearBottom || nearLeft || nearRight)
223
+ return i;
224
+ }
225
+ }
226
+ }
227
+ return -1;
228
+ }
229
+ function pointToSegmentDistance(px, py, a, b) {
230
+ const dx = b.x - a.x;
231
+ const dy = b.y - a.y;
232
+ const lengthSq = dx * dx + dy * dy;
233
+ if (lengthSq === 0) {
234
+ return Math.sqrt((px - a.x) ** 2 + (py - a.y) ** 2);
235
+ }
236
+ let t = ((px - a.x) * dx + (py - a.y) * dy) / lengthSq;
237
+ t = Math.max(0, Math.min(1, t));
238
+ const nearestX = a.x + t * dx;
239
+ const nearestY = a.y + t * dy;
240
+ return Math.sqrt((px - nearestX) ** 2 + (py - nearestY) ** 2);
241
+ }
242
+ function handleClearAll() {
243
+ onUpdate([]);
244
+ }
245
+ const handleTouchStart = handleMouseDown;
246
+ const handleTouchMove = handleMouseMove;
247
+ function handleTouchEnd(event) {
248
+ if (event.touches.length === 0) {
249
+ handleMouseUp();
250
+ }
251
+ }
252
+ // Generate smooth SVG path using quadratic bezier curves
253
+ function generateSmoothPath(points) {
254
+ if (points.length === 0)
255
+ return '';
256
+ if (points.length === 1)
257
+ return `M ${points[0].x} ${points[0].y}`;
258
+ if (points.length === 2) {
259
+ return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
260
+ }
261
+ let path = `M ${points[0].x} ${points[0].y}`;
262
+ // Use quadratic bezier curves for smooth lines
263
+ for (let i = 1; i < points.length - 1; i++) {
264
+ const prev = points[i - 1];
265
+ const curr = points[i];
266
+ const next = points[i + 1];
267
+ // Calculate control point (midpoint between previous and current)
268
+ const cpX = curr.x;
269
+ const cpY = curr.y;
270
+ // Calculate end point (midpoint between current and next)
271
+ const endX = (curr.x + next.x) / 2;
272
+ const endY = (curr.y + next.y) / 2;
273
+ if (i === 1) {
274
+ // First segment: line to first midpoint, then curve
275
+ const firstMidX = (prev.x + curr.x) / 2;
276
+ const firstMidY = (prev.y + curr.y) / 2;
277
+ path += ` L ${firstMidX} ${firstMidY}`;
278
+ }
279
+ path += ` Q ${cpX} ${cpY} ${endX} ${endY}`;
280
+ }
281
+ // Final segment to last point
282
+ const lastPoint = points[points.length - 1];
283
+ path += ` L ${lastPoint.x} ${lastPoint.y}`;
284
+ return path;
285
+ }
286
+ // Get current annotation canvas coordinates
287
+ let currentAnnotationCanvas = $derived.by(() => {
288
+ if (!currentAnnotation)
289
+ return null;
290
+ const points = currentAnnotation.points.map(p => toCanvasCoords(p)).filter(Boolean);
291
+ return { ...currentAnnotation, canvasPoints: points };
292
+ });
293
+ </script>
294
+
295
+ <svelte:window
296
+ onmousemove={handleMouseMove}
297
+ onmouseup={handleMouseUp}
298
+ />
299
+
300
+ <!-- Overlay -->
301
+ <div
302
+ bind:this={containerElement}
303
+ class="annotation-tool-overlay"
304
+ onmousedown={handleMouseDown}
305
+ role="button"
306
+ tabindex="-1"
307
+ >
308
+ <svg class="annotation-tool-svg">
309
+ <!-- Shadow filter definition -->
310
+ <defs>
311
+ <filter id="annotation-shadow" x="-50%" y="-50%" width="200%" height="200%">
312
+ <feDropShadow dx="2" dy="2" stdDeviation="3" flood-color="rgba(0,0,0,0.5)" />
313
+ </filter>
314
+ </defs>
315
+
316
+ <!-- Render existing annotations -->
317
+ {#each annotations as annotation (annotation.id)}
318
+ {@const points = annotation.points.map(p => toCanvasCoords(p)).filter(Boolean) as { x: number; y: number }[]}
319
+ {@const totalScale = viewport.scale * viewport.zoom}
320
+ {@const shadowFilter = annotation.shadow ? 'url(#annotation-shadow)' : 'none'}
321
+
322
+ {#if annotation.type === 'pen' && points.length > 0}
323
+ <path
324
+ d={generateSmoothPath(points)}
325
+ fill="none"
326
+ stroke={annotation.color}
327
+ stroke-width={annotation.strokeWidth * totalScale}
328
+ stroke-linecap="round"
329
+ stroke-linejoin="round"
330
+ filter={shadowFilter}
331
+ />
332
+ {:else if annotation.type === 'arrow' && points.length >= 2}
333
+ {@const start = points[0]}
334
+ {@const end = points[1]}
335
+ {@const angle = Math.atan2(end.y - start.y, end.x - start.x)}
336
+ {@const scaledStroke = annotation.strokeWidth * totalScale}
337
+ {@const headLength = scaledStroke * 3}
338
+ {@const headWidth = scaledStroke * 2}
339
+ {@const lineEndX = end.x - headLength * 0.7 * Math.cos(angle)}
340
+ {@const lineEndY = end.y - headLength * 0.7 * Math.sin(angle)}
341
+
342
+ <g filter={shadowFilter}>
343
+ <line
344
+ x1={start.x}
345
+ y1={start.y}
346
+ x2={lineEndX}
347
+ y2={lineEndY}
348
+ stroke={annotation.color}
349
+ stroke-width={scaledStroke}
350
+ stroke-linecap="round"
351
+ />
352
+ <polygon
353
+ points="{end.x},{end.y} {end.x - headLength * Math.cos(angle) + headWidth * Math.sin(angle)},{end.y - headLength * Math.sin(angle) - headWidth * Math.cos(angle)} {end.x - headLength * Math.cos(angle) - headWidth * Math.sin(angle)},{end.y - headLength * Math.sin(angle) + headWidth * Math.cos(angle)}"
354
+ fill={annotation.color}
355
+ />
356
+ </g>
357
+ {:else if annotation.type === 'rectangle' && points.length >= 2}
358
+ {@const x = Math.min(points[0].x, points[1].x)}
359
+ {@const y = Math.min(points[0].y, points[1].y)}
360
+ {@const w = Math.abs(points[1].x - points[0].x)}
361
+ {@const h = Math.abs(points[1].y - points[0].y)}
362
+ {@const cornerRadius = annotation.strokeWidth * totalScale * 1.5}
363
+
364
+ <rect
365
+ x={x}
366
+ y={y}
367
+ width={w}
368
+ height={h}
369
+ rx={cornerRadius}
370
+ ry={cornerRadius}
371
+ fill="none"
372
+ stroke={annotation.color}
373
+ stroke-width={annotation.strokeWidth * totalScale}
374
+ filter={shadowFilter}
375
+ />
376
+ {/if}
377
+ {/each}
378
+
379
+ <!-- Render current annotation being drawn -->
380
+ {#if currentAnnotationCanvas && currentAnnotationCanvas.canvasPoints.length > 0}
381
+ {@const points = currentAnnotationCanvas.canvasPoints}
382
+ {@const totalScale = viewport.scale * viewport.zoom}
383
+ {@const currentShadowFilter = currentAnnotationCanvas.shadow ? 'url(#annotation-shadow)' : 'none'}
384
+
385
+ {#if currentAnnotationCanvas.type === 'pen'}
386
+ <path
387
+ d={generateSmoothPath(points)}
388
+ fill="none"
389
+ stroke={currentAnnotationCanvas.color}
390
+ stroke-width={currentAnnotationCanvas.strokeWidth * totalScale}
391
+ stroke-linecap="round"
392
+ stroke-linejoin="round"
393
+ filter={currentShadowFilter}
394
+ />
395
+ {:else if currentAnnotationCanvas.type === 'arrow' && points.length >= 2}
396
+ {@const start = points[0]}
397
+ {@const end = points[1]}
398
+ {@const angle = Math.atan2(end.y - start.y, end.x - start.x)}
399
+ {@const scaledStroke = currentAnnotationCanvas.strokeWidth * totalScale}
400
+ {@const headLength = scaledStroke * 3}
401
+ {@const headWidth = scaledStroke * 2}
402
+ {@const lineEndX = end.x - headLength * 0.7 * Math.cos(angle)}
403
+ {@const lineEndY = end.y - headLength * 0.7 * Math.sin(angle)}
404
+
405
+ <g filter={currentShadowFilter}>
406
+ <line
407
+ x1={start.x}
408
+ y1={start.y}
409
+ x2={lineEndX}
410
+ y2={lineEndY}
411
+ stroke={currentAnnotationCanvas.color}
412
+ stroke-width={scaledStroke}
413
+ stroke-linecap="round"
414
+ />
415
+ <polygon
416
+ points="{end.x},{end.y} {end.x - headLength * Math.cos(angle) + headWidth * Math.sin(angle)},{end.y - headLength * Math.sin(angle) - headWidth * Math.cos(angle)} {end.x - headLength * Math.cos(angle) - headWidth * Math.sin(angle)},{end.y - headLength * Math.sin(angle) + headWidth * Math.cos(angle)}"
417
+ fill={currentAnnotationCanvas.color}
418
+ />
419
+ </g>
420
+ {:else if currentAnnotationCanvas.type === 'rectangle' && points.length >= 2}
421
+ {@const x = Math.min(points[0].x, points[1].x)}
422
+ {@const y = Math.min(points[0].y, points[1].y)}
423
+ {@const w = Math.abs(points[1].x - points[0].x)}
424
+ {@const h = Math.abs(points[1].y - points[0].y)}
425
+ {@const cornerRadius = currentAnnotationCanvas.strokeWidth * totalScale * 1.5}
426
+
427
+ <rect
428
+ x={x}
429
+ y={y}
430
+ width={w}
431
+ height={h}
432
+ rx={cornerRadius}
433
+ ry={cornerRadius}
434
+ fill="none"
435
+ stroke={currentAnnotationCanvas.color}
436
+ stroke-width={currentAnnotationCanvas.strokeWidth * totalScale}
437
+ filter={currentShadowFilter}
438
+ />
439
+ {/if}
440
+ {/if}
441
+ </svg>
442
+ </div>
443
+
444
+ <!-- Control panel -->
445
+ <div class="annotation-tool-panel">
446
+ <div class="panel-header">
447
+ <h3>{$_('editor.annotate')}</h3>
448
+ <button class="close-btn" onclick={onClose} title={$_('editor.close')}>
449
+ <X size={20} />
450
+ </button>
451
+ </div>
452
+
453
+ <div class="panel-content">
454
+ <!-- Tool selection -->
455
+ <div class="tool-group">
456
+ <span class="group-label">{$_('annotate.tool')}</span>
457
+ <div class="tool-buttons">
458
+ <button
459
+ class="tool-btn"
460
+ class:active={currentTool === 'pen'}
461
+ onclick={() => currentTool = 'pen'}
462
+ title={$_('annotate.pen')}
463
+ >
464
+ <Pencil size={20} />
465
+ </button>
466
+ <button
467
+ class="tool-btn"
468
+ class:active={currentTool === 'eraser'}
469
+ onclick={() => currentTool = 'eraser'}
470
+ title={$_('annotate.eraser')}
471
+ >
472
+ <Eraser size={20} />
473
+ </button>
474
+ <button
475
+ class="tool-btn"
476
+ class:active={currentTool === 'arrow'}
477
+ onclick={() => currentTool = 'arrow'}
478
+ title={$_('annotate.arrow')}
479
+ >
480
+ <ArrowRight size={20} />
481
+ </button>
482
+ <button
483
+ class="tool-btn"
484
+ class:active={currentTool === 'rectangle'}
485
+ onclick={() => currentTool = 'rectangle'}
486
+ title={$_('annotate.rectangle')}
487
+ >
488
+ <Square size={20} />
489
+ </button>
490
+ </div>
491
+ </div>
492
+
493
+ <!-- Color selection -->
494
+ <div class="control-group">
495
+ <span class="group-label">{$_('annotate.color')}</span>
496
+ <div class="color-presets">
497
+ {#each colorPresets as color}
498
+ <button
499
+ class="color-btn"
500
+ class:active={currentColor === color}
501
+ style="background-color: {color}; {color === '#ffffff' ? 'border: 1px solid #666;' : ''}"
502
+ onclick={() => currentColor = color}
503
+ title={color}
504
+ ></button>
505
+ {/each}
506
+ <input
507
+ type="color"
508
+ class="color-picker"
509
+ value={currentColor}
510
+ oninput={(e) => currentColor = e.currentTarget.value}
511
+ />
512
+ </div>
513
+ </div>
514
+
515
+ <!-- Stroke width -->
516
+ <div class="control-group">
517
+ <label for="stroke-width">
518
+ <span>{$_('annotate.strokeWidth')}</span>
519
+ <span class="value">{strokeWidth}px</span>
520
+ </label>
521
+ <input
522
+ id="stroke-width"
523
+ type="range"
524
+ min="5"
525
+ max="50"
526
+ bind:value={strokeWidth}
527
+ />
528
+ </div>
529
+
530
+ <!-- Shadow toggle -->
531
+ <div class="control-group">
532
+ <label class="toggle-label">
533
+ <span>{$_('annotate.shadow')}</span>
534
+ <button
535
+ class="toggle-btn"
536
+ class:active={shadowEnabled}
537
+ onclick={() => shadowEnabled = !shadowEnabled}
538
+ type="button"
539
+ title={$_('annotate.shadow')}
540
+ >
541
+ <span class="toggle-track">
542
+ <span class="toggle-thumb"></span>
543
+ </span>
544
+ </button>
545
+ </label>
546
+ </div>
547
+
548
+ <!-- Actions -->
549
+ <div class="panel-actions">
550
+ <button class="btn btn-danger" onclick={handleClearAll} disabled={annotations.length === 0}>
551
+ {$_('annotate.clearAll')}
552
+ </button>
553
+ </div>
554
+ </div>
555
+ </div>
556
+
557
+ <style>
558
+ .annotation-tool-overlay {
559
+ position: absolute;
560
+ top: 0;
561
+ left: 0;
562
+ width: 100%;
563
+ height: 100%;
564
+ cursor: crosshair;
565
+ user-select: none;
566
+ }
567
+
568
+ .annotation-tool-svg {
569
+ width: 100%;
570
+ height: 100%;
571
+ pointer-events: none;
572
+ }
573
+
574
+ .annotation-tool-panel {
575
+ position: absolute;
576
+ top: 1rem;
577
+ right: 1rem;
578
+ background: rgba(30, 30, 30, 0.95);
579
+ border: 1px solid #444;
580
+ border-radius: 8px;
581
+ padding: 1rem;
582
+ min-width: 250px;
583
+ backdrop-filter: blur(10px);
584
+ }
585
+
586
+ @media (max-width: 767px) {
587
+
588
+ .annotation-tool-panel {
589
+ position: absolute;
590
+ left: 0;
591
+ right: 0;
592
+ top: auto;
593
+ bottom: 0;
594
+ width: auto;
595
+ min-width: auto;
596
+ max-height: 50vh;
597
+ border-radius: 16px 16px 0 0;
598
+ z-index: 1001;
599
+ overflow-y: auto
600
+ }
601
+ }
602
+
603
+ .panel-header {
604
+ display: flex;
605
+ justify-content: space-between;
606
+ align-items: center;
607
+ margin-bottom: 1rem;
608
+ }
609
+
610
+ .panel-header h3 {
611
+ margin: 0;
612
+ font-size: 1.1rem;
613
+ color: #fff;
614
+ }
615
+
616
+ .close-btn {
617
+ display: flex;
618
+ align-items: center;
619
+ justify-content: center;
620
+ padding: 0.25rem;
621
+ background: transparent;
622
+ border: none;
623
+ color: #999;
624
+ cursor: pointer;
625
+ border-radius: 4px;
626
+ transition: all 0.2s;
627
+ }
628
+
629
+ .close-btn:hover {
630
+ background: #444;
631
+ color: #fff;
632
+ }
633
+
634
+ .panel-content {
635
+ display: flex;
636
+ flex-direction: column;
637
+ gap: 1rem;
638
+ }
639
+
640
+ .tool-group,
641
+ .control-group {
642
+ display: flex;
643
+ flex-direction: column;
644
+ gap: 0.5rem;
645
+ }
646
+
647
+ .group-label {
648
+ font-size: 0.9rem;
649
+ color: #ccc;
650
+ }
651
+
652
+ .tool-buttons {
653
+ display: flex;
654
+ gap: 0.5rem;
655
+ }
656
+
657
+ .tool-btn {
658
+ display: flex;
659
+ align-items: center;
660
+ justify-content: center;
661
+ width: 40px;
662
+ height: 40px;
663
+ background: #333;
664
+ border: 2px solid transparent;
665
+ border-radius: 8px;
666
+ color: #ccc;
667
+ cursor: pointer;
668
+ transition: all 0.2s;
669
+ }
670
+
671
+ .tool-btn:hover {
672
+ background: #444;
673
+ color: #fff;
674
+ }
675
+
676
+ .tool-btn.active {
677
+ background: var(--primary-color, #63b97b);
678
+ border-color: var(--primary-color, #63b97b);
679
+ color: #fff;
680
+ }
681
+
682
+ .color-presets {
683
+ display: flex;
684
+ flex-wrap: wrap;
685
+ gap: 0.5rem;
686
+ align-items: center;
687
+ }
688
+
689
+ .color-btn {
690
+ width: 28px;
691
+ height: 28px;
692
+ border-radius: 50%;
693
+ border: 2px solid transparent;
694
+ cursor: pointer;
695
+ transition: all 0.2s;
696
+ }
697
+
698
+ .color-btn:hover {
699
+ transform: scale(1.1);
700
+ }
701
+
702
+ .color-btn.active {
703
+ border-color: #fff;
704
+ box-shadow: 0 0 0 2px var(--primary-color, #63b97b);
705
+ }
706
+
707
+ .color-picker {
708
+ width: 28px;
709
+ height: 28px;
710
+ border: none;
711
+ border-radius: 50%;
712
+ padding: 0;
713
+ cursor: pointer;
714
+ background: transparent;
715
+ }
716
+
717
+ .color-picker::-webkit-color-swatch-wrapper {
718
+ padding: 0;
719
+ }
720
+
721
+ .color-picker::-webkit-color-swatch {
722
+ border-radius: 50%;
723
+ border: 1px solid #666;
724
+ }
725
+
726
+ .control-group label {
727
+ display: flex;
728
+ justify-content: space-between;
729
+ align-items: center;
730
+ font-size: 0.9rem;
731
+ color: #ccc;
732
+ }
733
+
734
+ .control-group .value {
735
+ color: var(--primary-color, #63b97b);
736
+ font-weight: 600;
737
+ }
738
+
739
+ .control-group input[type='range'] {
740
+ width: 100%;
741
+ height: 6px;
742
+ background: #444;
743
+ border-radius: 3px;
744
+ outline: none;
745
+ cursor: pointer;
746
+ }
747
+
748
+ .control-group input[type='range']::-webkit-slider-thumb {
749
+ appearance: none;
750
+ width: 16px;
751
+ height: 16px;
752
+ background: var(--primary-color, #63b97b);
753
+ border-radius: 50%;
754
+ cursor: pointer;
755
+ transition: all 0.2s;
756
+ }
757
+
758
+ .control-group input[type='range']::-webkit-slider-thumb:hover {
759
+ transform: scale(1.1);
760
+ }
761
+
762
+ .control-group input[type='range']::-moz-range-thumb {
763
+ width: 16px;
764
+ height: 16px;
765
+ background: var(--primary-color, #63b97b);
766
+ border: none;
767
+ border-radius: 50%;
768
+ cursor: pointer;
769
+ transition: all 0.2s;
770
+ }
771
+
772
+ .panel-actions {
773
+ display: flex;
774
+ justify-content: flex-end;
775
+ gap: 0.5rem;
776
+ margin-top: 0.5rem;
777
+ }
778
+
779
+ .btn {
780
+ padding: 0.5rem 1rem;
781
+ border: none;
782
+ border-radius: 4px;
783
+ cursor: pointer;
784
+ font-size: 0.9rem;
785
+ transition: all 0.2s;
786
+ }
787
+
788
+ .btn:disabled {
789
+ opacity: 0.5;
790
+ cursor: not-allowed;
791
+ }
792
+
793
+ .btn-danger {
794
+ background: #cc3333;
795
+ color: #fff;
796
+ }
797
+
798
+ .btn-danger:hover:not(:disabled) {
799
+ background: #dd4444;
800
+ }
801
+
802
+ .toggle-label {
803
+ display: flex;
804
+ justify-content: space-between;
805
+ align-items: center;
806
+ font-size: 0.9rem;
807
+ color: #ccc;
808
+ }
809
+
810
+ .toggle-btn {
811
+ background: transparent;
812
+ border: none;
813
+ padding: 0;
814
+ cursor: pointer;
815
+ }
816
+
817
+ .toggle-track {
818
+ display: block;
819
+ width: 44px;
820
+ height: 24px;
821
+ background: #444;
822
+ border-radius: 12px;
823
+ position: relative;
824
+ transition: background 0.2s;
825
+ }
826
+
827
+ .toggle-btn.active .toggle-track {
828
+ background: var(--primary-color, #63b97b);
829
+ }
830
+
831
+ .toggle-thumb {
832
+ position: absolute;
833
+ top: 2px;
834
+ left: 2px;
835
+ width: 20px;
836
+ height: 20px;
837
+ background: #fff;
838
+ border-radius: 50%;
839
+ transition: transform 0.2s;
840
+ }
841
+
842
+ .toggle-btn.active .toggle-thumb {
843
+ transform: translateX(20px);
844
+ }</style>