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.
@@ -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
- <path
787
- d={generateSmoothPath(points)}
788
- fill="none"
789
- stroke={annotation.color}
790
- stroke-width={annotation.strokeWidth * totalScale}
791
- stroke-linecap="round"
792
- stroke-linejoin="round"
793
- filter={shadowFilter}
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
- <path
862
- d={generateSmoothPath(points)}
863
- fill="none"
864
- stroke={currentAnnotationCanvas.color}
865
- stroke-width={currentAnnotationCanvas.strokeWidth * totalScale}
866
- stroke-linecap="round"
867
- stroke-linejoin="round"
868
- filter={currentShadowFilter}
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
- <div class="control-group">
1000
- <label for="stroke-width">
1001
- <span>{$_('annotate.strokeWidth')}</span>
1002
- <span class="value">{strokeWidth}px</span>
1003
- </label>
1004
- <input
1005
- id="stroke-width"
1006
- type="range"
1007
- min="5"
1008
- max="100"
1009
- bind:value={strokeWidth}
1010
- />
1011
- </div>
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
- }</style>
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
- if (overlayCanvasElement && (stampAreas.length > 0 || annotations.length > 0)) {
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 (annotations.length > 0) {
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
- overflow-y: auto;
117
- overscroll-behavior: contain;
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>
@@ -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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",