tokimeki-image-editor 0.2.7 → 0.2.9
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 +555 -34
- package/dist/components/Canvas.svelte +5 -4
- package/dist/components/Canvas.svelte.d.ts +1 -0
- package/dist/components/ImageEditor.svelte +1 -0
- package/dist/components/ToolPanel.svelte +17 -7
- package/dist/config/stamps.js +1 -0
- package/dist/i18n/locales/en.json +4 -1
- package/dist/i18n/locales/ja.json +4 -1
- package/dist/types.d.ts +3 -1
- package/dist/utils/canvas.js +9 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">import { onMount } from 'svelte';
|
|
2
2
|
import { _ } from 'svelte-i18n';
|
|
3
3
|
import { screenToImageCoords } from '../utils/canvas';
|
|
4
|
-
import { Pencil, Eraser, ArrowRight, Square, Brush } from 'lucide-svelte';
|
|
4
|
+
import { Pencil, Eraser, ArrowRight, Square, Brush, Type } from 'lucide-svelte';
|
|
5
5
|
import ToolPanel from './ToolPanel.svelte';
|
|
6
6
|
let { canvas, image, viewport, transform, annotations, cropArea, initialTool, initialStrokeWidth, initialColor, onUpdate, onClose, onViewportChange } = $props();
|
|
7
7
|
let containerElement = $state(null);
|
|
@@ -10,8 +10,87 @@ let currentTool = $state(initialTool ?? 'pen');
|
|
|
10
10
|
let currentColor = $state(initialColor ?? '#FF6B6B');
|
|
11
11
|
let strokeWidth = $state(initialStrokeWidth ?? 10);
|
|
12
12
|
let shadowEnabled = $state(false);
|
|
13
|
+
// Text tool state
|
|
14
|
+
let fontSize = $state(48);
|
|
15
|
+
let textInput = $state('');
|
|
16
|
+
let isTextInputVisible = $state(false);
|
|
17
|
+
let textInputPosition = $state(null);
|
|
18
|
+
let textInputElement = $state(null);
|
|
19
|
+
// Text drag state
|
|
20
|
+
let isDraggingText = $state(false);
|
|
21
|
+
let draggedTextId = $state(null);
|
|
22
|
+
let textDragStart = $state(null);
|
|
23
|
+
let isHoveringText = $state(false);
|
|
24
|
+
// Text selection and resize state
|
|
25
|
+
let selectedTextId = $state(null);
|
|
26
|
+
let isResizingText = $state(false);
|
|
27
|
+
let textResizeStart = $state(null);
|
|
28
|
+
let isHoveringResizeHandle = $state(false);
|
|
29
|
+
// Text editing state
|
|
30
|
+
let editingTextId = $state(null);
|
|
31
|
+
let editingTextValue = $state('');
|
|
32
|
+
// Clear text selection and editing when switching away from text tool
|
|
33
|
+
$effect(() => {
|
|
34
|
+
if (currentTool !== 'text') {
|
|
35
|
+
selectedTextId = null;
|
|
36
|
+
editingTextId = null;
|
|
37
|
+
editingTextValue = '';
|
|
38
|
+
isTextInputVisible = false;
|
|
39
|
+
textInputPosition = null;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
// Update selected text color when currentColor changes
|
|
43
|
+
let prevColor = $state(currentColor);
|
|
44
|
+
$effect(() => {
|
|
45
|
+
if (selectedTextId && currentColor !== prevColor) {
|
|
46
|
+
const updatedAnnotations = annotations.map(a => {
|
|
47
|
+
if (a.id === selectedTextId) {
|
|
48
|
+
return { ...a, color: currentColor };
|
|
49
|
+
}
|
|
50
|
+
return a;
|
|
51
|
+
});
|
|
52
|
+
onUpdate(updatedAnnotations);
|
|
53
|
+
}
|
|
54
|
+
prevColor = currentColor;
|
|
55
|
+
});
|
|
56
|
+
// Get selected text annotation with canvas bounds
|
|
57
|
+
let selectedTextBounds = $derived.by(() => {
|
|
58
|
+
if (!selectedTextId)
|
|
59
|
+
return null;
|
|
60
|
+
const annotation = annotations.find(a => a.id === selectedTextId);
|
|
61
|
+
if (!annotation || annotation.type !== 'text' || !annotation.text)
|
|
62
|
+
return null;
|
|
63
|
+
const point = toCanvasCoords(annotation.points[0]);
|
|
64
|
+
if (!point)
|
|
65
|
+
return null;
|
|
66
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
67
|
+
const scaledFontSize = (annotation.fontSize ?? 48) * totalScale;
|
|
68
|
+
const textWidth = calculateTextWidth(annotation.text, scaledFontSize);
|
|
69
|
+
const textHeight = scaledFontSize;
|
|
70
|
+
return {
|
|
71
|
+
annotation,
|
|
72
|
+
x: point.x,
|
|
73
|
+
y: point.y,
|
|
74
|
+
width: textWidth,
|
|
75
|
+
height: textHeight
|
|
76
|
+
};
|
|
77
|
+
});
|
|
13
78
|
// Preset colors - modern bright tones
|
|
14
79
|
const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9B72F2', '#F8F9FA', '#495057'];
|
|
80
|
+
// Calculate text width considering full-width and half-width characters
|
|
81
|
+
function calculateTextWidth(text, fontSize) {
|
|
82
|
+
let width = 0;
|
|
83
|
+
for (const char of text) {
|
|
84
|
+
const code = char.charCodeAt(0);
|
|
85
|
+
// Full-width characters: CJK, full-width alphanumeric, Hangul
|
|
86
|
+
const isFullWidth = (code >= 0x3000 && code <= 0x9FFF) || // CJK symbols, Hiragana, Katakana, CJK Unified
|
|
87
|
+
(code >= 0xFF00 && code <= 0xFFEF) || // Full-width forms
|
|
88
|
+
(code >= 0xAC00 && code <= 0xD7AF) || // Hangul syllables
|
|
89
|
+
(code >= 0x1100 && code <= 0x11FF); // Hangul Jamo
|
|
90
|
+
width += isFullWidth ? fontSize : fontSize * 0.6;
|
|
91
|
+
}
|
|
92
|
+
return width;
|
|
93
|
+
}
|
|
15
94
|
// Drawing state
|
|
16
95
|
let isDrawing = $state(false);
|
|
17
96
|
let currentAnnotation = $state(null);
|
|
@@ -95,12 +174,22 @@ onMount(() => {
|
|
|
95
174
|
});
|
|
96
175
|
// Keyboard handlers for panning (Space + drag)
|
|
97
176
|
function handleKeyDown(event) {
|
|
177
|
+
// Skip if input/textarea is focused (allow normal text input)
|
|
178
|
+
const target = event.target;
|
|
179
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
98
182
|
if (event.code === 'Space' && !isSpaceHeld) {
|
|
99
183
|
isSpaceHeld = true;
|
|
100
184
|
event.preventDefault();
|
|
101
185
|
}
|
|
102
186
|
}
|
|
103
187
|
function handleKeyUp(event) {
|
|
188
|
+
// Skip if input/textarea is focused
|
|
189
|
+
const target = event.target;
|
|
190
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
104
193
|
if (event.code === 'Space') {
|
|
105
194
|
isSpaceHeld = false;
|
|
106
195
|
isPanning = false;
|
|
@@ -128,6 +217,63 @@ function handleMouseDown(event) {
|
|
|
128
217
|
const imagePoint = toImageCoords(coords.clientX, coords.clientY);
|
|
129
218
|
if (!imagePoint)
|
|
130
219
|
return;
|
|
220
|
+
if (currentTool === 'text') {
|
|
221
|
+
const rect = canvas.getBoundingClientRect();
|
|
222
|
+
const scaleX = canvas.width / rect.width;
|
|
223
|
+
const scaleY = canvas.height / rect.height;
|
|
224
|
+
const canvasX = (coords.clientX - rect.left) * scaleX;
|
|
225
|
+
const canvasY = (coords.clientY - rect.top) * scaleY;
|
|
226
|
+
// Check if clicking on resize handle of selected text
|
|
227
|
+
if (selectedTextBounds) {
|
|
228
|
+
const handleSize = 12;
|
|
229
|
+
const handleX = selectedTextBounds.x + selectedTextBounds.width;
|
|
230
|
+
const handleY = selectedTextBounds.y + selectedTextBounds.height;
|
|
231
|
+
if (canvasX >= handleX - handleSize && canvasX <= handleX + handleSize &&
|
|
232
|
+
canvasY >= handleY - handleSize && canvasY <= handleY + handleSize) {
|
|
233
|
+
// Start resizing
|
|
234
|
+
isResizingText = true;
|
|
235
|
+
textResizeStart = {
|
|
236
|
+
y: coords.clientY,
|
|
237
|
+
originalFontSize: selectedTextBounds.annotation.fontSize ?? 48
|
|
238
|
+
};
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Check if clicking on existing text annotation
|
|
243
|
+
const textAnnotation = findTextAnnotationAtPoint(canvasX, canvasY);
|
|
244
|
+
if (textAnnotation) {
|
|
245
|
+
// Cancel any open text input first
|
|
246
|
+
if (isTextInputVisible) {
|
|
247
|
+
cancelTextInput();
|
|
248
|
+
}
|
|
249
|
+
// Select and start dragging immediately
|
|
250
|
+
selectedTextId = textAnnotation.id;
|
|
251
|
+
isDraggingText = true;
|
|
252
|
+
draggedTextId = textAnnotation.id;
|
|
253
|
+
textDragStart = {
|
|
254
|
+
x: coords.clientX,
|
|
255
|
+
y: coords.clientY,
|
|
256
|
+
originalX: textAnnotation.points[0].x,
|
|
257
|
+
originalY: textAnnotation.points[0].y
|
|
258
|
+
};
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Clicking elsewhere - deselect and show text input for new text
|
|
262
|
+
selectedTextId = null;
|
|
263
|
+
textInputPosition = {
|
|
264
|
+
x: coords.clientX - rect.left,
|
|
265
|
+
y: coords.clientY - rect.top,
|
|
266
|
+
imageX: imagePoint.x,
|
|
267
|
+
imageY: imagePoint.y
|
|
268
|
+
};
|
|
269
|
+
textInput = '';
|
|
270
|
+
isTextInputVisible = true;
|
|
271
|
+
// Focus the input after it's rendered
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
textInputElement?.focus();
|
|
274
|
+
}, 10);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
131
277
|
if (currentTool === 'eraser') {
|
|
132
278
|
// Find and remove annotation at click point
|
|
133
279
|
const canvasPoint = { x: coords.clientX, y: coords.clientY };
|
|
@@ -192,6 +338,33 @@ function handleMouseDown(event) {
|
|
|
192
338
|
const MIN_POINT_DISTANCE = 3;
|
|
193
339
|
function handleMouseMove(event) {
|
|
194
340
|
const coords = getEventCoords(event);
|
|
341
|
+
// Check hover state for text tool
|
|
342
|
+
if (currentTool === 'text' && canvas && !isDraggingText && !isPanning && !isResizingText) {
|
|
343
|
+
const rect = canvas.getBoundingClientRect();
|
|
344
|
+
const scaleX = canvas.width / rect.width;
|
|
345
|
+
const scaleY = canvas.height / rect.height;
|
|
346
|
+
const canvasX = (coords.clientX - rect.left) * scaleX;
|
|
347
|
+
const canvasY = (coords.clientY - rect.top) * scaleY;
|
|
348
|
+
// Check if hovering over resize handle
|
|
349
|
+
if (selectedTextBounds) {
|
|
350
|
+
const handleSize = 12;
|
|
351
|
+
const handleX = selectedTextBounds.x + selectedTextBounds.width;
|
|
352
|
+
const handleY = selectedTextBounds.y + selectedTextBounds.height;
|
|
353
|
+
if (canvasX >= handleX - handleSize && canvasX <= handleX + handleSize &&
|
|
354
|
+
canvasY >= handleY - handleSize && canvasY <= handleY + handleSize) {
|
|
355
|
+
isHoveringText = false;
|
|
356
|
+
isHoveringResizeHandle = true;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
isHoveringResizeHandle = false;
|
|
361
|
+
const textAnnotation = findTextAnnotationAtPoint(canvasX, canvasY);
|
|
362
|
+
isHoveringText = textAnnotation !== null;
|
|
363
|
+
}
|
|
364
|
+
else if (currentTool !== 'text') {
|
|
365
|
+
isHoveringText = false;
|
|
366
|
+
isHoveringResizeHandle = false;
|
|
367
|
+
}
|
|
195
368
|
// Handle panning
|
|
196
369
|
if (isPanning && panStart && onViewportChange) {
|
|
197
370
|
event.preventDefault();
|
|
@@ -203,6 +376,43 @@ function handleMouseMove(event) {
|
|
|
203
376
|
});
|
|
204
377
|
return;
|
|
205
378
|
}
|
|
379
|
+
// Handle text resizing
|
|
380
|
+
if (isResizingText && selectedTextId && textResizeStart) {
|
|
381
|
+
event.preventDefault();
|
|
382
|
+
const dy = coords.clientY - textResizeStart.y;
|
|
383
|
+
// Scale: moving down increases size, moving up decreases
|
|
384
|
+
const scaleFactor = 1 + dy / 100;
|
|
385
|
+
const newFontSize = Math.max(12, Math.min(300, textResizeStart.originalFontSize * scaleFactor));
|
|
386
|
+
const updatedAnnotations = annotations.map(a => {
|
|
387
|
+
if (a.id === selectedTextId) {
|
|
388
|
+
return { ...a, fontSize: Math.round(newFontSize) };
|
|
389
|
+
}
|
|
390
|
+
return a;
|
|
391
|
+
});
|
|
392
|
+
onUpdate(updatedAnnotations);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Handle text dragging
|
|
396
|
+
if (isDraggingText && draggedTextId && textDragStart && canvas) {
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
const rect = canvas.getBoundingClientRect();
|
|
399
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
400
|
+
// Calculate delta in screen pixels, then convert to image coordinates
|
|
401
|
+
const dx = (coords.clientX - textDragStart.x) * (canvas.width / rect.width) / totalScale;
|
|
402
|
+
const dy = (coords.clientY - textDragStart.y) * (canvas.height / rect.height) / totalScale;
|
|
403
|
+
// Update the annotation position
|
|
404
|
+
const updatedAnnotations = annotations.map(a => {
|
|
405
|
+
if (a.id === draggedTextId) {
|
|
406
|
+
return {
|
|
407
|
+
...a,
|
|
408
|
+
points: [{ x: textDragStart.originalX + dx, y: textDragStart.originalY + dy }]
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return a;
|
|
412
|
+
});
|
|
413
|
+
onUpdate(updatedAnnotations);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
206
416
|
if (!isDrawing || !currentAnnotation || !canvas || !image)
|
|
207
417
|
return;
|
|
208
418
|
const imagePoint = toImageCoords(coords.clientX, coords.clientY);
|
|
@@ -287,6 +497,19 @@ function handleMouseUp(event) {
|
|
|
287
497
|
panStart = null;
|
|
288
498
|
return;
|
|
289
499
|
}
|
|
500
|
+
// Stop text resizing
|
|
501
|
+
if (isResizingText) {
|
|
502
|
+
isResizingText = false;
|
|
503
|
+
textResizeStart = null;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
// Stop text dragging
|
|
507
|
+
if (isDraggingText) {
|
|
508
|
+
isDraggingText = false;
|
|
509
|
+
draggedTextId = null;
|
|
510
|
+
textDragStart = null;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
290
513
|
if (!isDrawing || !currentAnnotation) {
|
|
291
514
|
isDrawing = false;
|
|
292
515
|
return;
|
|
@@ -376,6 +599,27 @@ function handleMouseUp(event) {
|
|
|
376
599
|
recentSpeeds = [];
|
|
377
600
|
strokeStartTime = 0;
|
|
378
601
|
}
|
|
602
|
+
function findTextAnnotationAtPoint(canvasX, canvasY) {
|
|
603
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
604
|
+
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
605
|
+
const annotation = annotations[i];
|
|
606
|
+
if (annotation.type !== 'text' || !annotation.text)
|
|
607
|
+
continue;
|
|
608
|
+
const points = annotation.points.map(p => toCanvasCoords(p)).filter(Boolean);
|
|
609
|
+
if (points.length === 0)
|
|
610
|
+
continue;
|
|
611
|
+
const fontSize = (annotation.fontSize ?? 48) * totalScale;
|
|
612
|
+
const textWidth = calculateTextWidth(annotation.text, fontSize);
|
|
613
|
+
const textHeight = fontSize;
|
|
614
|
+
const textX = points[0].x;
|
|
615
|
+
const textY = points[0].y;
|
|
616
|
+
if (canvasX >= textX - 5 && canvasX <= textX + textWidth + 5 &&
|
|
617
|
+
canvasY >= textY - 5 && canvasY <= textY + textHeight + 5) {
|
|
618
|
+
return annotation;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
379
623
|
function findAnnotationAtPoint(canvasX, canvasY) {
|
|
380
624
|
const hitRadius = 10;
|
|
381
625
|
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
@@ -411,6 +655,20 @@ function findAnnotationAtPoint(canvasX, canvasY) {
|
|
|
411
655
|
return i;
|
|
412
656
|
}
|
|
413
657
|
}
|
|
658
|
+
else if (annotation.type === 'text' && annotation.text) {
|
|
659
|
+
if (points.length >= 1) {
|
|
660
|
+
const totalScale = viewport.scale * viewport.zoom;
|
|
661
|
+
const fontSize = (annotation.fontSize ?? 48) * totalScale;
|
|
662
|
+
const textWidth = calculateTextWidth(annotation.text, fontSize);
|
|
663
|
+
const textHeight = fontSize;
|
|
664
|
+
const textX = points[0].x;
|
|
665
|
+
const textY = points[0].y;
|
|
666
|
+
if (canvasX >= textX - hitRadius && canvasX <= textX + textWidth + hitRadius &&
|
|
667
|
+
canvasY >= textY - hitRadius && canvasY <= textY + textHeight + hitRadius) {
|
|
668
|
+
return i;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
414
672
|
}
|
|
415
673
|
return -1;
|
|
416
674
|
}
|
|
@@ -430,6 +688,103 @@ function pointToSegmentDistance(px, py, a, b) {
|
|
|
430
688
|
function handleClearAll() {
|
|
431
689
|
onUpdate([]);
|
|
432
690
|
}
|
|
691
|
+
function confirmTextInput() {
|
|
692
|
+
if (textInput.trim() && textInputPosition) {
|
|
693
|
+
const newAnnotation = {
|
|
694
|
+
id: `annotation-${Date.now()}`,
|
|
695
|
+
type: 'text',
|
|
696
|
+
color: currentColor,
|
|
697
|
+
strokeWidth: strokeWidth,
|
|
698
|
+
points: [{ x: textInputPosition.imageX, y: textInputPosition.imageY }],
|
|
699
|
+
shadow: shadowEnabled,
|
|
700
|
+
text: textInput.trim(),
|
|
701
|
+
fontSize: fontSize
|
|
702
|
+
};
|
|
703
|
+
onUpdate([...annotations, newAnnotation]);
|
|
704
|
+
}
|
|
705
|
+
cancelTextInput();
|
|
706
|
+
}
|
|
707
|
+
function cancelTextInput() {
|
|
708
|
+
isTextInputVisible = false;
|
|
709
|
+
textInput = '';
|
|
710
|
+
textInputPosition = null;
|
|
711
|
+
}
|
|
712
|
+
function handleTextInputKeydown(event) {
|
|
713
|
+
if (event.key === 'Enter') {
|
|
714
|
+
event.preventDefault();
|
|
715
|
+
if (editingTextId) {
|
|
716
|
+
confirmTextEdit();
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
confirmTextInput();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else if (event.key === 'Escape') {
|
|
723
|
+
event.preventDefault();
|
|
724
|
+
if (editingTextId) {
|
|
725
|
+
cancelTextEdit();
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
cancelTextInput();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function startTextEdit(annotation) {
|
|
733
|
+
if (!annotation.text)
|
|
734
|
+
return;
|
|
735
|
+
editingTextId = annotation.id;
|
|
736
|
+
editingTextValue = annotation.text;
|
|
737
|
+
isTextInputVisible = true;
|
|
738
|
+
// Position the input at the text location
|
|
739
|
+
const point = toCanvasCoords(annotation.points[0]);
|
|
740
|
+
if (point && canvas) {
|
|
741
|
+
const rect = canvas.getBoundingClientRect();
|
|
742
|
+
const scaleX = rect.width / canvas.width;
|
|
743
|
+
const scaleY = rect.height / canvas.height;
|
|
744
|
+
textInputPosition = {
|
|
745
|
+
x: point.x * scaleX,
|
|
746
|
+
y: point.y * scaleY,
|
|
747
|
+
imageX: annotation.points[0].x,
|
|
748
|
+
imageY: annotation.points[0].y
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
setTimeout(() => {
|
|
752
|
+
textInputElement?.focus();
|
|
753
|
+
textInputElement?.select();
|
|
754
|
+
}, 10);
|
|
755
|
+
}
|
|
756
|
+
function confirmTextEdit() {
|
|
757
|
+
if (editingTextId && editingTextValue.trim()) {
|
|
758
|
+
const updatedAnnotations = annotations.map(a => {
|
|
759
|
+
if (a.id === editingTextId) {
|
|
760
|
+
return { ...a, text: editingTextValue.trim() };
|
|
761
|
+
}
|
|
762
|
+
return a;
|
|
763
|
+
});
|
|
764
|
+
onUpdate(updatedAnnotations);
|
|
765
|
+
}
|
|
766
|
+
cancelTextEdit();
|
|
767
|
+
}
|
|
768
|
+
function cancelTextEdit() {
|
|
769
|
+
editingTextId = null;
|
|
770
|
+
editingTextValue = '';
|
|
771
|
+
isTextInputVisible = false;
|
|
772
|
+
textInputPosition = null;
|
|
773
|
+
}
|
|
774
|
+
function handleDoubleClick(event) {
|
|
775
|
+
if (currentTool !== 'text' || !canvas)
|
|
776
|
+
return;
|
|
777
|
+
const rect = canvas.getBoundingClientRect();
|
|
778
|
+
const scaleX = canvas.width / rect.width;
|
|
779
|
+
const scaleY = canvas.height / rect.height;
|
|
780
|
+
const canvasX = (event.clientX - rect.left) * scaleX;
|
|
781
|
+
const canvasY = (event.clientY - rect.top) * scaleY;
|
|
782
|
+
const textAnnotation = findTextAnnotationAtPoint(canvasX, canvasY);
|
|
783
|
+
if (textAnnotation) {
|
|
784
|
+
event.preventDefault();
|
|
785
|
+
startTextEdit(textAnnotation);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
433
788
|
function handleTouchStart(event) {
|
|
434
789
|
// Two-finger touch starts panning
|
|
435
790
|
if (event.touches.length === 2) {
|
|
@@ -764,7 +1119,13 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
764
1119
|
bind:this={containerElement}
|
|
765
1120
|
class="annotation-tool-overlay"
|
|
766
1121
|
class:panning={isSpaceHeld || isTwoFingerTouch}
|
|
1122
|
+
class:dragging-text={isDraggingText}
|
|
1123
|
+
class:text-mode={currentTool === 'text'}
|
|
1124
|
+
class:hovering-text={isHoveringText}
|
|
1125
|
+
class:hovering-resize={isHoveringResizeHandle}
|
|
1126
|
+
class:resizing-text={isResizingText}
|
|
767
1127
|
onmousedown={handleMouseDown}
|
|
1128
|
+
ondblclick={handleDoubleClick}
|
|
768
1129
|
role="button"
|
|
769
1130
|
tabindex="-1"
|
|
770
1131
|
>
|
|
@@ -783,15 +1144,26 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
783
1144
|
{@const shadowFilter = annotation.shadow ? 'url(#annotation-shadow)' : 'none'}
|
|
784
1145
|
|
|
785
1146
|
{#if annotation.type === 'pen' && points.length > 0}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1147
|
+
{#if points.length === 1}
|
|
1148
|
+
<!-- Single point - draw a dot -->
|
|
1149
|
+
<circle
|
|
1150
|
+
cx={points[0].x}
|
|
1151
|
+
cy={points[0].y}
|
|
1152
|
+
r={annotation.strokeWidth * totalScale / 2}
|
|
1153
|
+
fill={annotation.color}
|
|
1154
|
+
filter={shadowFilter}
|
|
1155
|
+
/>
|
|
1156
|
+
{:else}
|
|
1157
|
+
<path
|
|
1158
|
+
d={generateSmoothPath(points)}
|
|
1159
|
+
fill="none"
|
|
1160
|
+
stroke={annotation.color}
|
|
1161
|
+
stroke-width={annotation.strokeWidth * totalScale}
|
|
1162
|
+
stroke-linecap="round"
|
|
1163
|
+
stroke-linejoin="round"
|
|
1164
|
+
filter={shadowFilter}
|
|
1165
|
+
/>
|
|
1166
|
+
{/if}
|
|
795
1167
|
{:else if annotation.type === 'brush' && points.length >= 1}
|
|
796
1168
|
{@const brushPoints = annotation.points.map(p => {
|
|
797
1169
|
const canvasCoords = toCanvasCoords(p);
|
|
@@ -848,6 +1220,18 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
848
1220
|
stroke-width={annotation.strokeWidth * totalScale}
|
|
849
1221
|
filter={shadowFilter}
|
|
850
1222
|
/>
|
|
1223
|
+
{:else if annotation.type === 'text' && points.length >= 1 && annotation.text && annotation.id !== editingTextId}
|
|
1224
|
+
{@const scaledFontSize = (annotation.fontSize ?? 48) * totalScale}
|
|
1225
|
+
<text
|
|
1226
|
+
x={points[0].x}
|
|
1227
|
+
y={points[0].y + scaledFontSize * 0.88}
|
|
1228
|
+
fill={annotation.color}
|
|
1229
|
+
font-size={scaledFontSize}
|
|
1230
|
+
font-family="sans-serif"
|
|
1231
|
+
font-weight="bold"
|
|
1232
|
+
dominant-baseline="alphabetic"
|
|
1233
|
+
filter={shadowFilter}
|
|
1234
|
+
>{annotation.text}</text>
|
|
851
1235
|
{/if}
|
|
852
1236
|
{/each}
|
|
853
1237
|
|
|
@@ -858,15 +1242,26 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
858
1242
|
{@const currentShadowFilter = currentAnnotationCanvas.shadow ? 'url(#annotation-shadow)' : 'none'}
|
|
859
1243
|
|
|
860
1244
|
{#if currentAnnotationCanvas.type === 'pen'}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1245
|
+
{#if points.length === 1}
|
|
1246
|
+
<!-- Single point - draw a dot -->
|
|
1247
|
+
<circle
|
|
1248
|
+
cx={points[0].x}
|
|
1249
|
+
cy={points[0].y}
|
|
1250
|
+
r={currentAnnotationCanvas.strokeWidth * totalScale / 2}
|
|
1251
|
+
fill={currentAnnotationCanvas.color}
|
|
1252
|
+
filter={currentShadowFilter}
|
|
1253
|
+
/>
|
|
1254
|
+
{:else}
|
|
1255
|
+
<path
|
|
1256
|
+
d={generateSmoothPath(points)}
|
|
1257
|
+
fill="none"
|
|
1258
|
+
stroke={currentAnnotationCanvas.color}
|
|
1259
|
+
stroke-width={currentAnnotationCanvas.strokeWidth * totalScale}
|
|
1260
|
+
stroke-linecap="round"
|
|
1261
|
+
stroke-linejoin="round"
|
|
1262
|
+
filter={currentShadowFilter}
|
|
1263
|
+
/>
|
|
1264
|
+
{/if}
|
|
870
1265
|
{:else if currentAnnotationCanvas.type === 'brush' && points.length >= 1}
|
|
871
1266
|
<path
|
|
872
1267
|
d={generateBrushPath(points, currentAnnotationCanvas.strokeWidth, totalScale)}
|
|
@@ -920,7 +1315,70 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
920
1315
|
/>
|
|
921
1316
|
{/if}
|
|
922
1317
|
{/if}
|
|
1318
|
+
|
|
1319
|
+
<!-- Selection box and resize handle for selected text -->
|
|
1320
|
+
{#if selectedTextBounds}
|
|
1321
|
+
{@const padding = 4}
|
|
1322
|
+
{@const handleSize = 10}
|
|
1323
|
+
<rect
|
|
1324
|
+
x={selectedTextBounds.x - padding}
|
|
1325
|
+
y={selectedTextBounds.y - padding}
|
|
1326
|
+
width={selectedTextBounds.width + padding * 2}
|
|
1327
|
+
height={selectedTextBounds.height + padding * 2}
|
|
1328
|
+
fill="none"
|
|
1329
|
+
stroke="var(--primary-color, #63b97b)"
|
|
1330
|
+
stroke-width="2"
|
|
1331
|
+
stroke-dasharray="4 2"
|
|
1332
|
+
/>
|
|
1333
|
+
<!-- Resize handle (bottom-right corner) -->
|
|
1334
|
+
<rect
|
|
1335
|
+
x={selectedTextBounds.x + selectedTextBounds.width - handleSize / 2 + padding}
|
|
1336
|
+
y={selectedTextBounds.y + selectedTextBounds.height - handleSize / 2 + padding}
|
|
1337
|
+
width={handleSize}
|
|
1338
|
+
height={handleSize}
|
|
1339
|
+
fill="var(--primary-color, #63b97b)"
|
|
1340
|
+
stroke="#fff"
|
|
1341
|
+
stroke-width="1"
|
|
1342
|
+
rx="2"
|
|
1343
|
+
class="resize-handle"
|
|
1344
|
+
/>
|
|
1345
|
+
{/if}
|
|
923
1346
|
</svg>
|
|
1347
|
+
|
|
1348
|
+
<!-- Text input overlay -->
|
|
1349
|
+
{#if isTextInputVisible && textInputPosition}
|
|
1350
|
+
{@const editingAnnotation = editingTextId ? annotations.find(a => a.id === editingTextId) : null}
|
|
1351
|
+
{@const inputColor = editingAnnotation?.color ?? currentColor}
|
|
1352
|
+
{@const inputFontSize = (editingAnnotation?.fontSize ?? fontSize) * viewport.scale * viewport.zoom}
|
|
1353
|
+
<div
|
|
1354
|
+
class="text-input-container"
|
|
1355
|
+
style="left: {textInputPosition.x}px; top: {textInputPosition.y}px;"
|
|
1356
|
+
>
|
|
1357
|
+
{#if editingTextId}
|
|
1358
|
+
<input
|
|
1359
|
+
bind:this={textInputElement}
|
|
1360
|
+
type="text"
|
|
1361
|
+
class="text-input"
|
|
1362
|
+
bind:value={editingTextValue}
|
|
1363
|
+
onkeydown={handleTextInputKeydown}
|
|
1364
|
+
onblur={confirmTextEdit}
|
|
1365
|
+
placeholder={$_('annotate.textPlaceholder')}
|
|
1366
|
+
style="color: {inputColor}; font-size: {inputFontSize}px;"
|
|
1367
|
+
/>
|
|
1368
|
+
{:else}
|
|
1369
|
+
<input
|
|
1370
|
+
bind:this={textInputElement}
|
|
1371
|
+
type="text"
|
|
1372
|
+
class="text-input"
|
|
1373
|
+
bind:value={textInput}
|
|
1374
|
+
onkeydown={handleTextInputKeydown}
|
|
1375
|
+
onblur={confirmTextInput}
|
|
1376
|
+
placeholder={$_('annotate.textPlaceholder')}
|
|
1377
|
+
style="color: {inputColor}; font-size: {inputFontSize}px;"
|
|
1378
|
+
/>
|
|
1379
|
+
{/if}
|
|
1380
|
+
</div>
|
|
1381
|
+
{/if}
|
|
924
1382
|
</div>
|
|
925
1383
|
|
|
926
1384
|
<!-- Control panel -->
|
|
@@ -970,6 +1428,14 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
970
1428
|
>
|
|
971
1429
|
<Square size={20} />
|
|
972
1430
|
</button>
|
|
1431
|
+
<button
|
|
1432
|
+
class="tool-btn"
|
|
1433
|
+
class:active={currentTool === 'text'}
|
|
1434
|
+
onclick={() => currentTool = 'text'}
|
|
1435
|
+
title={$_('annotate.text')}
|
|
1436
|
+
>
|
|
1437
|
+
<Type size={20} />
|
|
1438
|
+
</button>
|
|
973
1439
|
</div>
|
|
974
1440
|
</div>
|
|
975
1441
|
|
|
@@ -995,20 +1461,39 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
995
1461
|
</div>
|
|
996
1462
|
</div>
|
|
997
1463
|
|
|
998
|
-
<!-- Stroke width -->
|
|
999
|
-
|
|
1000
|
-
<
|
|
1001
|
-
<
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1464
|
+
<!-- Stroke width (hidden for text tool) -->
|
|
1465
|
+
{#if currentTool !== 'text'}
|
|
1466
|
+
<div class="control-group">
|
|
1467
|
+
<label for="stroke-width">
|
|
1468
|
+
<span>{$_('annotate.strokeWidth')}</span>
|
|
1469
|
+
<span class="value">{strokeWidth}px</span>
|
|
1470
|
+
</label>
|
|
1471
|
+
<input
|
|
1472
|
+
id="stroke-width"
|
|
1473
|
+
type="range"
|
|
1474
|
+
min="5"
|
|
1475
|
+
max="100"
|
|
1476
|
+
bind:value={strokeWidth}
|
|
1477
|
+
/>
|
|
1478
|
+
</div>
|
|
1479
|
+
{/if}
|
|
1480
|
+
|
|
1481
|
+
<!-- Font size (text tool only) -->
|
|
1482
|
+
{#if currentTool === 'text'}
|
|
1483
|
+
<div class="control-group">
|
|
1484
|
+
<label for="font-size">
|
|
1485
|
+
<span>{$_('annotate.fontSize')}</span>
|
|
1486
|
+
<span class="value">{fontSize}px</span>
|
|
1487
|
+
</label>
|
|
1488
|
+
<input
|
|
1489
|
+
id="font-size"
|
|
1490
|
+
type="range"
|
|
1491
|
+
min="12"
|
|
1492
|
+
max="200"
|
|
1493
|
+
bind:value={fontSize}
|
|
1494
|
+
/>
|
|
1495
|
+
</div>
|
|
1496
|
+
{/if}
|
|
1012
1497
|
|
|
1013
1498
|
<!-- Shadow toggle -->
|
|
1014
1499
|
<div class="control-group">
|
|
@@ -1055,6 +1540,20 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
1055
1540
|
cursor: grabbing;
|
|
1056
1541
|
}
|
|
1057
1542
|
|
|
1543
|
+
.annotation-tool-overlay.text-mode {
|
|
1544
|
+
cursor: text;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
.annotation-tool-overlay.hovering-text,
|
|
1548
|
+
.annotation-tool-overlay.dragging-text {
|
|
1549
|
+
cursor: move;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
.annotation-tool-overlay.hovering-resize,
|
|
1553
|
+
.annotation-tool-overlay.resizing-text {
|
|
1554
|
+
cursor: nwse-resize;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1058
1557
|
.annotation-tool-svg {
|
|
1059
1558
|
width: 100%;
|
|
1060
1559
|
height: 100%;
|
|
@@ -1300,4 +1799,26 @@ let currentAnnotationCanvas = $derived.by(() => {
|
|
|
1300
1799
|
|
|
1301
1800
|
.toggle-btn.active .toggle-thumb {
|
|
1302
1801
|
transform: translateX(20px);
|
|
1303
|
-
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
.text-input-container {
|
|
1805
|
+
position: absolute;
|
|
1806
|
+
z-index: 10;
|
|
1807
|
+
transform: translateY(-50%);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
.text-input {
|
|
1811
|
+
background: rgba(0, 0, 0, 0.7);
|
|
1812
|
+
border: 2px solid var(--primary-color, #63b97b);
|
|
1813
|
+
border-radius: 4px;
|
|
1814
|
+
padding: 4px 8px;
|
|
1815
|
+
min-width: 100px;
|
|
1816
|
+
max-width: 400px;
|
|
1817
|
+
font-weight: bold;
|
|
1818
|
+
outline: none;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
.text-input::placeholder {
|
|
1822
|
+
color: rgba(255, 255, 255, 0.5);
|
|
1823
|
+
font-weight: normal;
|
|
1824
|
+
}</style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">import { onMount, onDestroy } from 'svelte';
|
|
2
2
|
import { drawImage, preloadStampImage, applyStamps, applyAnnotations } from '../utils/canvas';
|
|
3
3
|
import { initWebGPUCanvas, uploadImageToGPU, renderWithAdjustments, cleanupWebGPU, isWebGPUInitialized } from '../utils/webgpu-render';
|
|
4
|
-
let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = [], onZoom, onViewportChange } = $props();
|
|
4
|
+
let { canvas = $bindable(null), width, height, image, viewport, transform, adjustments, cropArea = null, blurAreas = [], stampAreas = [], annotations = [], skipAnnotations = false, onZoom, onViewportChange } = $props();
|
|
5
5
|
// Constants
|
|
6
6
|
const PAN_OVERFLOW_MARGIN = 0.2; // Allow 20% overflow when panning
|
|
7
7
|
// State
|
|
@@ -164,7 +164,8 @@ function renderWebGPU() {
|
|
|
164
164
|
ensureCanvasSize(canvasElement, width, height);
|
|
165
165
|
renderWithAdjustments(adjustments, viewport, transform, width, height, currentImage.width, currentImage.height, cropArea, blurAreas);
|
|
166
166
|
// Render stamps and annotations on overlay canvas
|
|
167
|
-
|
|
167
|
+
const shouldRenderAnnotations = !skipAnnotations && annotations.length > 0;
|
|
168
|
+
if (overlayCanvasElement && (stampAreas.length > 0 || shouldRenderAnnotations)) {
|
|
168
169
|
ensureCanvasSize(overlayCanvasElement, width, height);
|
|
169
170
|
// Clear overlay canvas
|
|
170
171
|
const ctx = overlayCanvasElement.getContext('2d');
|
|
@@ -173,7 +174,7 @@ function renderWebGPU() {
|
|
|
173
174
|
if (stampAreas.length > 0) {
|
|
174
175
|
applyStamps(overlayCanvasElement, currentImage, viewport, stampAreas, cropArea);
|
|
175
176
|
}
|
|
176
|
-
if (
|
|
177
|
+
if (shouldRenderAnnotations) {
|
|
177
178
|
applyAnnotations(overlayCanvasElement, currentImage, viewport, annotations, cropArea);
|
|
178
179
|
}
|
|
179
180
|
}
|
|
@@ -217,7 +218,7 @@ async function performRender() {
|
|
|
217
218
|
return;
|
|
218
219
|
canvasElement.width = width;
|
|
219
220
|
canvasElement.height = height;
|
|
220
|
-
await drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas, annotations);
|
|
221
|
+
await drawImage(canvasElement, image, viewport, transform, adjustments, cropArea, blurAreas, stampAreas, skipAnnotations ? [] : annotations);
|
|
221
222
|
}
|
|
222
223
|
function handleMouseDown(e) {
|
|
223
224
|
if (e.button === 0 || e.button === 1) {
|
|
@@ -11,6 +11,7 @@ interface Props {
|
|
|
11
11
|
blurAreas?: BlurArea[];
|
|
12
12
|
stampAreas?: StampArea[];
|
|
13
13
|
annotations?: Annotation[];
|
|
14
|
+
skipAnnotations?: boolean;
|
|
14
15
|
onZoom?: (delta: number, centerX?: number, centerY?: number) => void;
|
|
15
16
|
onViewportChange?: (viewportUpdate: Partial<Viewport>) => void;
|
|
16
17
|
}
|
|
@@ -439,6 +439,7 @@ function handleKeyDown(event) {
|
|
|
439
439
|
blurAreas={state.blurAreas}
|
|
440
440
|
stampAreas={state.stampAreas}
|
|
441
441
|
annotations={state.annotations}
|
|
442
|
+
skipAnnotations={state.mode === 'annotate'}
|
|
442
443
|
onZoom={handleZoom}
|
|
443
444
|
onViewportChange={handleViewportChange}
|
|
444
445
|
/>
|
|
@@ -51,7 +51,7 @@ function handleSheetDragEnd() {
|
|
|
51
51
|
<div
|
|
52
52
|
bind:this={panelElement}
|
|
53
53
|
class="tool-panel"
|
|
54
|
-
style="--sheet-offset: {sheetOffset}px; --sheet-max-height: {SHEET_MAX_HEIGHT}px"
|
|
54
|
+
style="--sheet-offset: {sheetOffset}px; --sheet-max-height: {SHEET_MAX_HEIGHT}px; --sheet-visible-height: {SHEET_MAX_HEIGHT - sheetOffset}px"
|
|
55
55
|
>
|
|
56
56
|
<!-- Drag handle for mobile bottom sheet -->
|
|
57
57
|
<div
|
|
@@ -113,8 +113,9 @@ function handleSheetDragEnd() {
|
|
|
113
113
|
height: var(--sheet-max-height, 400px);
|
|
114
114
|
border-radius: 16px 16px 0 0;
|
|
115
115
|
z-index: 9999;
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
overflow: hidden;
|
|
118
119
|
padding-top: 0;
|
|
119
120
|
padding-bottom: 1.5rem;
|
|
120
121
|
transform: translateY(var(--sheet-offset, 0px));
|
|
@@ -137,7 +138,8 @@ function handleSheetDragEnd() {
|
|
|
137
138
|
cursor: grab;
|
|
138
139
|
touch-action: pan-x;
|
|
139
140
|
user-select: none;
|
|
140
|
-
-webkit-user-select: none
|
|
141
|
+
-webkit-user-select: none;
|
|
142
|
+
flex-shrink: 0
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
.sheet-drag-handle:active {
|
|
@@ -162,7 +164,8 @@ function handleSheetDragEnd() {
|
|
|
162
164
|
@media (max-width: 767px) {
|
|
163
165
|
|
|
164
166
|
.panel-header {
|
|
165
|
-
margin-bottom: 0.5rem
|
|
167
|
+
margin-bottom: 0.5rem;
|
|
168
|
+
flex-shrink: 0
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -206,7 +209,13 @@ function handleSheetDragEnd() {
|
|
|
206
209
|
@media (max-width: 767px) {
|
|
207
210
|
|
|
208
211
|
.panel-content {
|
|
209
|
-
gap: 0.75rem
|
|
212
|
+
gap: 0.75rem;
|
|
213
|
+
flex: 1;
|
|
214
|
+
overflow-y: auto;
|
|
215
|
+
overscroll-behavior: contain;
|
|
216
|
+
min-height: 0;
|
|
217
|
+
/* Calculate max height: visible area - header (~40px) - padding - actions (~50px) - drag handle (~32px) */
|
|
218
|
+
max-height: calc(var(--sheet-visible-height, 400px) - 130px)
|
|
210
219
|
}
|
|
211
220
|
}
|
|
212
221
|
|
|
@@ -220,6 +229,7 @@ function handleSheetDragEnd() {
|
|
|
220
229
|
@media (max-width: 767px) {
|
|
221
230
|
|
|
222
231
|
.panel-actions {
|
|
223
|
-
margin-top: 0.5rem
|
|
232
|
+
margin-top: 0.5rem;
|
|
233
|
+
flex-shrink: 0
|
|
224
234
|
}
|
|
225
235
|
}</style>
|
package/dist/config/stamps.js
CHANGED
|
@@ -17,6 +17,7 @@ export const STAMP_ASSETS = [
|
|
|
17
17
|
{ id: 'emoji-cloud', type: 'emoji', content: '☁️', preview: '☁️' },
|
|
18
18
|
{ id: 'emoji-flower', type: 'emoji', content: '🌸', preview: '🌸' },
|
|
19
19
|
{ id: 'emoji-cherry', type: 'emoji', content: '🍒', preview: '🍒' },
|
|
20
|
+
{ id: 'emoji-bamboo', type: 'emoji', content: '🎍', preview: '🎍' },
|
|
20
21
|
// { id: 'image-1', type: 'image', content: '/stamps/christmas_tree.png', preview: '/stamps/christmas_tree.png' },
|
|
21
22
|
// { id: 'svg-1', type: 'svg', content: '/stamps/svg-1.svg', preview: '/stamps/svg-1.svg' }
|
|
22
23
|
];
|
|
@@ -85,9 +85,12 @@
|
|
|
85
85
|
"eraser": "Eraser",
|
|
86
86
|
"arrow": "Arrow",
|
|
87
87
|
"rectangle": "Rectangle",
|
|
88
|
+
"text": "Text",
|
|
88
89
|
"color": "Color",
|
|
89
90
|
"strokeWidth": "Stroke Width",
|
|
91
|
+
"fontSize": "Font Size",
|
|
90
92
|
"shadow": "Shadow",
|
|
91
|
-
"clearAll": "Clear All"
|
|
93
|
+
"clearAll": "Clear All",
|
|
94
|
+
"textPlaceholder": "Enter text..."
|
|
92
95
|
}
|
|
93
96
|
}
|
|
@@ -85,9 +85,12 @@
|
|
|
85
85
|
"eraser": "消しゴム",
|
|
86
86
|
"arrow": "矢印",
|
|
87
87
|
"rectangle": "四角形",
|
|
88
|
+
"text": "テキスト",
|
|
88
89
|
"color": "色",
|
|
89
90
|
"strokeWidth": "線の太さ",
|
|
91
|
+
"fontSize": "フォントサイズ",
|
|
90
92
|
"shadow": "影",
|
|
91
|
-
"clearAll": "すべて消去"
|
|
93
|
+
"clearAll": "すべて消去",
|
|
94
|
+
"textPlaceholder": "テキストを入力..."
|
|
92
95
|
}
|
|
93
96
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface StampArea {
|
|
|
37
37
|
stampType: StampType;
|
|
38
38
|
stampContent: string;
|
|
39
39
|
}
|
|
40
|
-
export type AnnotationType = 'pen' | 'brush' | 'arrow' | 'rectangle';
|
|
40
|
+
export type AnnotationType = 'pen' | 'brush' | 'arrow' | 'rectangle' | 'text';
|
|
41
41
|
export interface AnnotationPoint {
|
|
42
42
|
x: number;
|
|
43
43
|
y: number;
|
|
@@ -50,6 +50,8 @@ export interface Annotation {
|
|
|
50
50
|
strokeWidth: number;
|
|
51
51
|
points: AnnotationPoint[];
|
|
52
52
|
shadow: boolean;
|
|
53
|
+
text?: string;
|
|
54
|
+
fontSize?: number;
|
|
53
55
|
}
|
|
54
56
|
export interface TransformState {
|
|
55
57
|
rotation: number;
|
package/dist/utils/canvas.js
CHANGED
|
@@ -641,6 +641,15 @@ export function applyAnnotations(canvas, img, viewport, annotations, cropArea) {
|
|
|
641
641
|
ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius);
|
|
642
642
|
ctx.stroke();
|
|
643
643
|
}
|
|
644
|
+
else if (annotation.type === 'text' && annotation.points.length >= 1 && annotation.text) {
|
|
645
|
+
// Draw text annotation
|
|
646
|
+
const pos = toCanvasCoords(annotation.points[0].x, annotation.points[0].y);
|
|
647
|
+
const scaledFontSize = (annotation.fontSize ?? 48) * totalScale;
|
|
648
|
+
ctx.font = `bold ${scaledFontSize}px sans-serif`;
|
|
649
|
+
ctx.textAlign = 'left';
|
|
650
|
+
ctx.textBaseline = 'alphabetic';
|
|
651
|
+
ctx.fillText(annotation.text, pos.x, pos.y + scaledFontSize * 0.88);
|
|
652
|
+
}
|
|
644
653
|
ctx.restore();
|
|
645
654
|
});
|
|
646
655
|
}
|