tokimeki-image-editor 0.1.13 → 0.2.1

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