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.
- package/dist/components/AnnotationTool.svelte +900 -0
- package/dist/components/AnnotationTool.svelte.d.ts +15 -0
- package/dist/components/Canvas.svelte +14 -7
- package/dist/components/Canvas.svelte.d.ts +2 -1
- package/dist/components/ImageEditor.svelte +30 -4
- package/dist/components/Toolbar.svelte +12 -1
- package/dist/i18n/locales/en.json +13 -0
- package/dist/i18n/locales/ja.json +13 -0
- package/dist/types.d.ts +16 -1
- package/dist/utils/canvas.d.ts +9 -4
- package/dist/utils/canvas.js +140 -8
- package/dist/utils/history.d.ts +1 -1
- package/dist/utils/history.js +6 -2
- package/package.json +1 -1
- package/dist/shaders/blur.wgsl +0 -59
- package/dist/shaders/composite.wgsl +0 -46
- package/dist/shaders/grain.wgsl +0 -225
- package/dist/shaders/image-editor.wgsl +0 -296
|
@@ -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>
|