tokimeki-image-editor 0.2.4 → 0.2.6

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,20 +1,25 @@
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 } from 'lucide-svelte';
4
+ import { Pencil, Eraser, ArrowRight, Square, Brush } from 'lucide-svelte';
5
5
  import ToolPanel from './ToolPanel.svelte';
6
- let { canvas, image, viewport, transform, annotations, cropArea, onUpdate, onClose, onViewportChange } = $props();
6
+ let { canvas, image, viewport, transform, annotations, cropArea, initialStrokeWidth, initialColor, onUpdate, onClose, onViewportChange } = $props();
7
7
  let containerElement = $state(null);
8
8
  // Tool settings
9
9
  let currentTool = $state('pen');
10
- let currentColor = $state('#FF6B6B');
11
- let strokeWidth = $state(10);
10
+ let currentColor = $state(initialColor ?? '#FF6B6B');
11
+ let strokeWidth = $state(initialStrokeWidth ?? 10);
12
12
  let shadowEnabled = $state(false);
13
13
  // Preset colors - modern bright tones
14
14
  const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9B72F2', '#F8F9FA', '#495057'];
15
15
  // Drawing state
16
16
  let isDrawing = $state(false);
17
17
  let currentAnnotation = $state(null);
18
+ // Brush state for speed-based width calculation
19
+ let lastPointTime = $state(0);
20
+ let lastPointPos = $state(null);
21
+ let recentSpeeds = $state([]); // Track recent speeds for exit stroke analysis
22
+ let strokeStartTime = $state(0); // Track when stroke started
18
23
  // Panning state (Space + drag on desktop, 2-finger drag on mobile)
19
24
  let isSpaceHeld = $state(false);
20
25
  let isPanning = $state(false);
@@ -152,6 +157,25 @@ function handleMouseDown(event) {
152
157
  shadow: shadowEnabled
153
158
  };
154
159
  }
160
+ else if (currentTool === 'brush') {
161
+ // Initialize brush with time tracking for speed-based width
162
+ const now = performance.now();
163
+ lastPointTime = now;
164
+ strokeStartTime = now;
165
+ lastPointPos = { x: imagePoint.x, y: imagePoint.y };
166
+ recentSpeeds = [];
167
+ // Start with a very thin width (entry stroke - 入り)
168
+ // This creates the characteristic thin entry of calligraphy
169
+ const initialWidth = strokeWidth * 0.15;
170
+ currentAnnotation = {
171
+ id: `annotation-${Date.now()}`,
172
+ type: 'brush',
173
+ color: currentColor,
174
+ strokeWidth: strokeWidth,
175
+ points: [{ ...imagePoint, width: initialWidth }],
176
+ shadow: shadowEnabled
177
+ };
178
+ }
155
179
  else if (currentTool === 'arrow' || currentTool === 'rectangle') {
156
180
  currentAnnotation = {
157
181
  id: `annotation-${Date.now()}`,
@@ -199,6 +223,56 @@ function handleMouseMove(event) {
199
223
  };
200
224
  }
201
225
  }
226
+ else if (currentTool === 'brush') {
227
+ // Calculate speed-based width for brush
228
+ const now = performance.now();
229
+ const timeDelta = now - lastPointTime;
230
+ const strokeAge = now - strokeStartTime; // How long since stroke started
231
+ if (lastPointPos && timeDelta > 0) {
232
+ const dx = imagePoint.x - lastPointPos.x;
233
+ const dy = imagePoint.y - lastPointPos.y;
234
+ const distance = Math.sqrt(dx * dx + dy * dy);
235
+ // Calculate speed (pixels per millisecond)
236
+ const speed = distance / timeDelta;
237
+ // Adaptive minimum distance: slower drawing requires larger gaps to reduce zigzag
238
+ // Fast drawing (speed > 0.5): use smaller threshold for detail
239
+ // Slow drawing (speed < 0.2): use larger threshold to prevent zigzag
240
+ const baseMinDistance = MIN_POINT_DISTANCE;
241
+ const slowSpeedFactor = Math.max(0, 1 - speed * 2); // 1 at speed=0, 0 at speed>=0.5
242
+ const adaptiveMinDistance = baseMinDistance * (1 + slowSpeedFactor * 2); // 1x to 3x base distance
243
+ if (distance >= adaptiveMinDistance) {
244
+ // Track recent speeds for exit stroke analysis (とめ/はね detection)
245
+ recentSpeeds = [...recentSpeeds.slice(-9), speed];
246
+ // Map speed to width: faster = thinner, slower = thicker
247
+ const minWidth = strokeWidth * 0.2;
248
+ const maxWidth = strokeWidth * 2.5;
249
+ // Inverse relationship: high speed = low width
250
+ // Use exponential decay for more natural feel
251
+ const speedFactor = Math.exp(-speed * 2.5);
252
+ let targetWidth = minWidth + (maxWidth - minWidth) * speedFactor;
253
+ // Entry stroke enhancement (入り): gradually increase width for first ~100ms
254
+ // This creates smoother entry
255
+ if (strokeAge < 150) {
256
+ const entryFactor = Math.min(1, strokeAge / 150);
257
+ // Use easing function for smooth entry
258
+ const easedEntry = 1 - Math.pow(1 - entryFactor, 3); // Cubic ease-out
259
+ const entryMinWidth = strokeWidth * 0.15;
260
+ targetWidth = entryMinWidth + (targetWidth - entryMinWidth) * easedEntry;
261
+ }
262
+ // Smooth width transition - use stronger smoothing for slow drawing
263
+ // This prevents rapid width changes that cause zigzag
264
+ const lastWidth = currentAnnotation.points[currentAnnotation.points.length - 1].width || strokeWidth;
265
+ const smoothingFactor = 0.3 + slowSpeedFactor * 0.4; // 0.3 (fast) to 0.7 (slow)
266
+ const smoothedWidth = lastWidth * (1 - smoothingFactor) + targetWidth * smoothingFactor;
267
+ currentAnnotation = {
268
+ ...currentAnnotation,
269
+ points: [...currentAnnotation.points, { ...imagePoint, width: smoothedWidth }]
270
+ };
271
+ lastPointTime = now;
272
+ lastPointPos = { x: imagePoint.x, y: imagePoint.y };
273
+ }
274
+ }
275
+ }
202
276
  else if (currentTool === 'arrow' || currentTool === 'rectangle') {
203
277
  currentAnnotation = {
204
278
  ...currentAnnotation,
@@ -217,11 +291,73 @@ function handleMouseUp(event) {
217
291
  isDrawing = false;
218
292
  return;
219
293
  }
294
+ // Apply exit stroke (抜き) for brush - differentiate とめ (tome) vs はね (hane)
295
+ if (currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 2) {
296
+ const points = [...currentAnnotation.points];
297
+ // Analyze exit velocity to determine stroke ending type
298
+ // とめ (tome): slow ending = deliberate stop, maintain width
299
+ // はね (hane): fast ending = flicking motion, taper sharply
300
+ const avgExitSpeed = recentSpeeds.length > 0
301
+ ? recentSpeeds.reduce((a, b) => a + b, 0) / recentSpeeds.length
302
+ : 0.5;
303
+ // Speed threshold: below = とめ, above = はね
304
+ // Typical speeds: 0.1-0.3 (slow), 0.3-0.8 (medium), 0.8+ (fast)
305
+ const isHane = avgExitSpeed > 0.5; // Fast exit = はね
306
+ const isTome = avgExitSpeed < 0.25; // Slow exit = とめ
307
+ if (isTome) {
308
+ // とめ (stopping stroke): maintain width at the end, slight rounding
309
+ // Apply minimal tapering to create a deliberate stop appearance
310
+ const taperCount = Math.min(2, Math.floor(points.length * 0.15));
311
+ for (let i = 0; i < taperCount; i++) {
312
+ const idx = points.length - 1 - i;
313
+ if (idx >= 0 && points[idx].width !== undefined) {
314
+ const taperFactor = (i + 1) / (taperCount + 1);
315
+ // Very subtle taper - maintain most of the width
316
+ points[idx] = {
317
+ ...points[idx],
318
+ width: points[idx].width * (1 - taperFactor * 0.2)
319
+ };
320
+ }
321
+ }
322
+ }
323
+ else if (isHane) {
324
+ // はね (flicking stroke): sharp taper for dynamic flick appearance
325
+ const taperCount = Math.min(8, Math.floor(points.length * 0.4));
326
+ for (let i = 0; i < taperCount; i++) {
327
+ const idx = points.length - 1 - i;
328
+ if (idx >= 0 && points[idx].width !== undefined) {
329
+ const taperFactor = (i + 1) / taperCount;
330
+ // Strong taper with exponential curve for sharp flick
331
+ const easedTaper = Math.pow(taperFactor, 1.5);
332
+ points[idx] = {
333
+ ...points[idx],
334
+ width: points[idx].width * (1 - easedTaper * 0.9)
335
+ };
336
+ }
337
+ }
338
+ }
339
+ else {
340
+ // Medium speed: normal taper
341
+ const taperCount = Math.min(5, Math.floor(points.length * 0.3));
342
+ for (let i = 0; i < taperCount; i++) {
343
+ const idx = points.length - 1 - i;
344
+ if (idx >= 0 && points[idx].width !== undefined) {
345
+ const taperFactor = (i + 1) / taperCount;
346
+ points[idx] = {
347
+ ...points[idx],
348
+ width: points[idx].width * (1 - taperFactor * 0.6)
349
+ };
350
+ }
351
+ }
352
+ }
353
+ currentAnnotation = { ...currentAnnotation, points };
354
+ }
220
355
  // Only save if annotation has enough points
221
356
  if (currentAnnotation.points.length >= 2 ||
222
- (currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1)) {
357
+ (currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1) ||
358
+ (currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 1)) {
223
359
  // For arrow/rectangle, ensure start and end are different
224
- if (currentAnnotation.type !== 'pen') {
360
+ if (currentAnnotation.type !== 'pen' && currentAnnotation.type !== 'brush') {
225
361
  const start = currentAnnotation.points[0];
226
362
  const end = currentAnnotation.points[1];
227
363
  const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
@@ -235,6 +371,10 @@ function handleMouseUp(event) {
235
371
  }
236
372
  isDrawing = false;
237
373
  currentAnnotation = null;
374
+ lastPointTime = 0;
375
+ lastPointPos = null;
376
+ recentSpeeds = [];
377
+ strokeStartTime = 0;
238
378
  }
239
379
  function findAnnotationAtPoint(canvasX, canvasY) {
240
380
  const hitRadius = 10;
@@ -398,11 +538,216 @@ function generateSmoothPath(points) {
398
538
  path += ` L ${lastPoint.x} ${lastPoint.y}`;
399
539
  return path;
400
540
  }
401
- // Get current annotation canvas coordinates
541
+ // Smooth a series of points using moving average
542
+ function smoothPoints(points, windowSize = 3) {
543
+ if (points.length < 3)
544
+ return points;
545
+ const result = [];
546
+ const halfWindow = Math.floor(windowSize / 2);
547
+ for (let i = 0; i < points.length; i++) {
548
+ // Keep first and last points unchanged for proper shape
549
+ if (i < halfWindow || i >= points.length - halfWindow) {
550
+ result.push(points[i]);
551
+ continue;
552
+ }
553
+ let sumX = 0, sumY = 0, count = 0;
554
+ for (let j = -halfWindow; j <= halfWindow; j++) {
555
+ const idx = i + j;
556
+ if (idx >= 0 && idx < points.length) {
557
+ sumX += points[idx].x;
558
+ sumY += points[idx].y;
559
+ count++;
560
+ }
561
+ }
562
+ result.push({ x: sumX / count, y: sumY / count });
563
+ }
564
+ return result;
565
+ }
566
+ // Interpolate points for smoother brush strokes
567
+ function interpolateBrushPoints(points) {
568
+ if (points.length < 2)
569
+ return points;
570
+ const result = [];
571
+ for (let i = 0; i < points.length - 1; i++) {
572
+ const p1 = points[i];
573
+ const p2 = points[i + 1];
574
+ const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
575
+ result.push(p1);
576
+ // Add interpolated points if distance is large
577
+ const interpolateCount = Math.floor(dist / 5); // Add point every 5 pixels
578
+ for (let j = 1; j < interpolateCount; j++) {
579
+ const t = j / interpolateCount;
580
+ // Use smoothstep for width interpolation
581
+ const smoothT = t * t * (3 - 2 * t);
582
+ result.push({
583
+ x: p1.x + (p2.x - p1.x) * t,
584
+ y: p1.y + (p2.y - p1.y) * t,
585
+ width: p1.width !== undefined && p2.width !== undefined
586
+ ? p1.width + (p2.width - p1.width) * smoothT
587
+ : undefined
588
+ });
589
+ }
590
+ }
591
+ result.push(points[points.length - 1]);
592
+ return result;
593
+ }
594
+ // Generate a filled SVG path for variable-width brush strokes
595
+ function generateBrushPath(points, baseWidth, scale) {
596
+ // Handle single point - create an elliptical brush mark (点)
597
+ if (points.length === 1) {
598
+ const p = points[0];
599
+ const width = ((p.width ?? baseWidth) * scale) / 2;
600
+ // Create a slightly elongated ellipse for brush-like appearance
601
+ const rx = width * 0.8;
602
+ const ry = width * 1.2;
603
+ return `M ${p.x} ${p.y - ry}
604
+ C ${p.x + rx * 0.55} ${p.y - ry} ${p.x + rx} ${p.y - ry * 0.55} ${p.x + rx} ${p.y}
605
+ C ${p.x + rx} ${p.y + ry * 0.55} ${p.x + rx * 0.55} ${p.y + ry} ${p.x} ${p.y + ry}
606
+ C ${p.x - rx * 0.55} ${p.y + ry} ${p.x - rx} ${p.y + ry * 0.55} ${p.x - rx} ${p.y}
607
+ C ${p.x - rx} ${p.y - ry * 0.55} ${p.x - rx * 0.55} ${p.y - ry} ${p.x} ${p.y - ry} Z`;
608
+ }
609
+ // Handle 2 points - create a teardrop/brush stroke shape
610
+ if (points.length === 2) {
611
+ const p1 = points[0];
612
+ const p2 = points[1];
613
+ const w1 = ((p1.width ?? baseWidth * 0.3) * scale) / 2;
614
+ const w2 = ((p2.width ?? baseWidth * 0.5) * scale) / 2;
615
+ // Direction vector
616
+ const dx = p2.x - p1.x;
617
+ const dy = p2.y - p1.y;
618
+ const len = Math.sqrt(dx * dx + dy * dy);
619
+ if (len === 0)
620
+ return '';
621
+ // Perpendicular
622
+ const nx = -dy / len;
623
+ const ny = dx / len;
624
+ // Create teardrop shape
625
+ const startLeft = { x: p1.x + nx * w1, y: p1.y + ny * w1 };
626
+ const startRight = { x: p1.x - nx * w1, y: p1.y - ny * w1 };
627
+ const endLeft = { x: p2.x + nx * w2, y: p2.y + ny * w2 };
628
+ const endRight = { x: p2.x - nx * w2, y: p2.y - ny * w2 };
629
+ // Extend the tip slightly for brush-like appearance
630
+ const tipExtend = w2 * 0.3;
631
+ const tipX = p2.x + (dx / len) * tipExtend;
632
+ const tipY = p2.y + (dy / len) * tipExtend;
633
+ return `M ${startLeft.x} ${startLeft.y}
634
+ Q ${(startLeft.x + endLeft.x) / 2 + nx * w2 * 0.3} ${(startLeft.y + endLeft.y) / 2 + ny * w2 * 0.3} ${endLeft.x} ${endLeft.y}
635
+ Q ${tipX + nx * w2 * 0.2} ${tipY + ny * w2 * 0.2} ${tipX} ${tipY}
636
+ Q ${tipX - nx * w2 * 0.2} ${tipY - ny * w2 * 0.2} ${endRight.x} ${endRight.y}
637
+ Q ${(startRight.x + endRight.x) / 2 - nx * w2 * 0.3} ${(startRight.y + endRight.y) / 2 - ny * w2 * 0.3} ${startRight.x} ${startRight.y}
638
+ Z`;
639
+ }
640
+ // For 3+ points, interpolate for smoother curves
641
+ const interpolated = interpolateBrushPoints(points);
642
+ let leftSide = [];
643
+ let rightSide = [];
644
+ for (let i = 0; i < interpolated.length; i++) {
645
+ const curr = interpolated[i];
646
+ const width = ((curr.width ?? baseWidth) * scale) / 2;
647
+ // Calculate direction using central difference when possible
648
+ let dx, dy;
649
+ if (i === 0) {
650
+ dx = interpolated[1].x - curr.x;
651
+ dy = interpolated[1].y - curr.y;
652
+ }
653
+ else if (i === interpolated.length - 1) {
654
+ dx = curr.x - interpolated[i - 1].x;
655
+ dy = curr.y - interpolated[i - 1].y;
656
+ }
657
+ else {
658
+ // Use wider window for smoother direction calculation
659
+ const lookback = Math.min(i, 3);
660
+ const lookforward = Math.min(interpolated.length - 1 - i, 3);
661
+ dx = interpolated[i + lookforward].x - interpolated[i - lookback].x;
662
+ dy = interpolated[i + lookforward].y - interpolated[i - lookback].y;
663
+ }
664
+ // Normalize
665
+ const len = Math.sqrt(dx * dx + dy * dy);
666
+ if (len === 0)
667
+ continue;
668
+ const nx = -dy / len; // Perpendicular
669
+ const ny = dx / len;
670
+ leftSide.push({ x: curr.x + nx * width, y: curr.y + ny * width });
671
+ rightSide.push({ x: curr.x - nx * width, y: curr.y - ny * width });
672
+ }
673
+ if (leftSide.length < 2)
674
+ return '';
675
+ // Apply smoothing to both sides to reduce zigzag
676
+ leftSide = smoothPoints(leftSide, 5);
677
+ rightSide = smoothPoints(rightSide, 5);
678
+ // Build path: left side forward, right side backward
679
+ let path = `M ${leftSide[0].x} ${leftSide[0].y}`;
680
+ // Smooth left side with quadratic curves
681
+ for (let i = 1; i < leftSide.length - 1; i++) {
682
+ const curr = leftSide[i];
683
+ const next = leftSide[i + 1];
684
+ const endX = (curr.x + next.x) / 2;
685
+ const endY = (curr.y + next.y) / 2;
686
+ path += ` Q ${curr.x} ${curr.y} ${endX} ${endY}`;
687
+ }
688
+ path += ` L ${leftSide[leftSide.length - 1].x} ${leftSide[leftSide.length - 1].y}`;
689
+ // End cap - smooth connection at stroke tip
690
+ const lastLeft = leftSide[leftSide.length - 1];
691
+ const lastRight = rightSide[rightSide.length - 1];
692
+ const lastPoint = interpolated[interpolated.length - 1];
693
+ const lastWidth = ((lastPoint.width ?? baseWidth) * scale) / 2;
694
+ // Create rounded end cap using bezier curve
695
+ const tipExtend = lastWidth * 0.4;
696
+ const lastDx = interpolated.length > 1
697
+ ? interpolated[interpolated.length - 1].x - interpolated[interpolated.length - 2].x
698
+ : 0;
699
+ const lastDy = interpolated.length > 1
700
+ ? interpolated[interpolated.length - 1].y - interpolated[interpolated.length - 2].y
701
+ : 0;
702
+ const lastLen = Math.sqrt(lastDx * lastDx + lastDy * lastDy);
703
+ if (lastLen > 0) {
704
+ const tipX = lastPoint.x + (lastDx / lastLen) * tipExtend;
705
+ const tipY = lastPoint.y + (lastDy / lastLen) * tipExtend;
706
+ path += ` Q ${tipX} ${tipY} ${lastRight.x} ${lastRight.y}`;
707
+ }
708
+ else {
709
+ path += ` L ${lastRight.x} ${lastRight.y}`;
710
+ }
711
+ // Smooth right side backward
712
+ for (let i = rightSide.length - 2; i > 0; i--) {
713
+ const curr = rightSide[i];
714
+ const prev = rightSide[i - 1];
715
+ const endX = (curr.x + prev.x) / 2;
716
+ const endY = (curr.y + prev.y) / 2;
717
+ path += ` Q ${curr.x} ${curr.y} ${endX} ${endY}`;
718
+ }
719
+ path += ` L ${rightSide[0].x} ${rightSide[0].y}`;
720
+ // Start cap - smooth connection at stroke start
721
+ const firstLeft = leftSide[0];
722
+ const firstRight = rightSide[0];
723
+ const firstPoint = interpolated[0];
724
+ const firstWidth = ((firstPoint.width ?? baseWidth) * scale) / 2;
725
+ const startExtend = firstWidth * 0.3;
726
+ const firstDx = interpolated.length > 1
727
+ ? interpolated[0].x - interpolated[1].x
728
+ : 0;
729
+ const firstDy = interpolated.length > 1
730
+ ? interpolated[0].y - interpolated[1].y
731
+ : 0;
732
+ const firstLen = Math.sqrt(firstDx * firstDx + firstDy * firstDy);
733
+ if (firstLen > 0) {
734
+ const startTipX = firstPoint.x + (firstDx / firstLen) * startExtend;
735
+ const startTipY = firstPoint.y + (firstDy / firstLen) * startExtend;
736
+ path += ` Q ${startTipX} ${startTipY} ${firstLeft.x} ${firstLeft.y}`;
737
+ }
738
+ path += ' Z'; // Close path
739
+ return path;
740
+ }
741
+ // Get current annotation canvas coordinates with width data for brush
402
742
  let currentAnnotationCanvas = $derived.by(() => {
403
743
  if (!currentAnnotation)
404
744
  return null;
405
- const points = currentAnnotation.points.map(p => toCanvasCoords(p)).filter(Boolean);
745
+ const points = currentAnnotation.points.map(p => {
746
+ const canvasCoords = toCanvasCoords(p);
747
+ if (!canvasCoords)
748
+ return null;
749
+ return { ...canvasCoords, width: p.width };
750
+ }).filter(Boolean);
406
751
  return { ...currentAnnotation, canvasPoints: points };
407
752
  });
408
753
  </script>
@@ -447,6 +792,18 @@ let currentAnnotationCanvas = $derived.by(() => {
447
792
  stroke-linejoin="round"
448
793
  filter={shadowFilter}
449
794
  />
795
+ {:else if annotation.type === 'brush' && points.length >= 1}
796
+ {@const brushPoints = annotation.points.map(p => {
797
+ const canvasCoords = toCanvasCoords(p);
798
+ if (!canvasCoords) return null;
799
+ return { ...canvasCoords, width: p.width };
800
+ }).filter(Boolean) as { x: number; y: number; width?: number }[]}
801
+ <path
802
+ d={generateBrushPath(brushPoints, annotation.strokeWidth, totalScale)}
803
+ fill={annotation.color}
804
+ stroke="none"
805
+ filter={shadowFilter}
806
+ />
450
807
  {:else if annotation.type === 'arrow' && points.length >= 2}
451
808
  {@const start = points[0]}
452
809
  {@const end = points[1]}
@@ -510,6 +867,13 @@ let currentAnnotationCanvas = $derived.by(() => {
510
867
  stroke-linejoin="round"
511
868
  filter={currentShadowFilter}
512
869
  />
870
+ {:else if currentAnnotationCanvas.type === 'brush' && points.length >= 1}
871
+ <path
872
+ d={generateBrushPath(points, currentAnnotationCanvas.strokeWidth, totalScale)}
873
+ fill={currentAnnotationCanvas.color}
874
+ stroke="none"
875
+ filter={currentShadowFilter}
876
+ />
513
877
  {:else if currentAnnotationCanvas.type === 'arrow' && points.length >= 2}
514
878
  {@const start = points[0]}
515
879
  {@const end = points[1]}
@@ -574,6 +938,14 @@ let currentAnnotationCanvas = $derived.by(() => {
574
938
  >
575
939
  <Pencil size={20} />
576
940
  </button>
941
+ <button
942
+ class="tool-btn"
943
+ class:active={currentTool === 'brush'}
944
+ onclick={() => currentTool = 'brush'}
945
+ title={$_('annotate.brush')}
946
+ >
947
+ <Brush size={20} />
948
+ </button>
577
949
  <button
578
950
  class="tool-btn"
579
951
  class:active={currentTool === 'eraser'}
@@ -633,7 +1005,7 @@ let currentAnnotationCanvas = $derived.by(() => {
633
1005
  id="stroke-width"
634
1006
  type="range"
635
1007
  min="5"
636
- max="50"
1008
+ max="100"
637
1009
  bind:value={strokeWidth}
638
1010
  />
639
1011
  </div>
@@ -6,6 +6,8 @@ interface Props {
6
6
  transform: TransformState;
7
7
  annotations: Annotation[];
8
8
  cropArea?: CropArea | null;
9
+ initialStrokeWidth?: number;
10
+ initialColor?: string;
9
11
  onUpdate: (annotations: Annotation[]) => void;
10
12
  onClose: () => void;
11
13
  onViewportChange?: (viewport: Partial<Viewport>) => void;
@@ -13,7 +13,7 @@ import BlurTool from './BlurTool.svelte';
13
13
  import StampTool from './StampTool.svelte';
14
14
  import AnnotationTool from './AnnotationTool.svelte';
15
15
  import ExportTool from './ExportTool.svelte';
16
- let { initialImage, width = 800, height = 600, isStandalone = false, onComplete, onCancel, onExport } = $props();
16
+ let { initialImage, initialMode = null, initialStrokeWidth, initialColor, width = 800, height = 600, isStandalone = false, onComplete, onCancel, onExport } = $props();
17
17
  let state = $state({
18
18
  mode: null,
19
19
  imageData: {
@@ -86,6 +86,10 @@ $effect(() => {
86
86
  // Reset history and save initial state
87
87
  state.history = createEmptyHistory();
88
88
  saveToHistory();
89
+ // Apply initial mode if specified
90
+ if (initialMode) {
91
+ state.mode = initialMode;
92
+ }
89
93
  };
90
94
  img.onerror = (error) => {
91
95
  console.error('Failed to load initial image:', error);
@@ -127,6 +131,10 @@ async function handleFileUpload(file) {
127
131
  // Reset history and save initial state
128
132
  state.history = createEmptyHistory();
129
133
  saveToHistory();
134
+ // Apply initial mode if specified
135
+ if (initialMode) {
136
+ state.mode = initialMode;
137
+ }
130
138
  }
131
139
  catch (error) {
132
140
  console.error('Failed to load image:', error);
@@ -478,6 +486,8 @@ function handleKeyDown(event) {
478
486
  transform={state.transform}
479
487
  annotations={state.annotations}
480
488
  cropArea={state.cropArea}
489
+ {initialStrokeWidth}
490
+ {initialColor}
481
491
  onUpdate={handleAnnotationsChange}
482
492
  onClose={() => state.mode = null}
483
493
  onViewportChange={handleViewportChange}
@@ -1,6 +1,10 @@
1
1
  import '../i18n';
2
+ import type { EditorMode } from '../types';
2
3
  interface Props {
3
4
  initialImage?: File | string;
5
+ initialMode?: EditorMode;
6
+ initialStrokeWidth?: number;
7
+ initialColor?: string;
4
8
  width?: number;
5
9
  height?: number;
6
10
  isStandalone?: boolean;
@@ -81,6 +81,7 @@
81
81
  "annotate": {
82
82
  "tool": "Tool",
83
83
  "pen": "Pen",
84
+ "brush": "Brush",
84
85
  "eraser": "Eraser",
85
86
  "arrow": "Arrow",
86
87
  "rectangle": "Rectangle",
@@ -81,6 +81,7 @@
81
81
  "annotate": {
82
82
  "tool": "ツール",
83
83
  "pen": "ペン",
84
+ "brush": "毛筆",
84
85
  "eraser": "消しゴム",
85
86
  "arrow": "矢印",
86
87
  "rectangle": "四角形",
package/dist/types.d.ts CHANGED
@@ -37,10 +37,11 @@ export interface StampArea {
37
37
  stampType: StampType;
38
38
  stampContent: string;
39
39
  }
40
- export type AnnotationType = 'pen' | 'arrow' | 'rectangle';
40
+ export type AnnotationType = 'pen' | 'brush' | 'arrow' | 'rectangle';
41
41
  export interface AnnotationPoint {
42
42
  x: number;
43
43
  y: number;
44
+ width?: number;
44
45
  }
45
46
  export interface Annotation {
46
47
  id: string;
@@ -427,6 +427,184 @@ export function applyAnnotations(canvas, img, viewport, annotations, cropArea) {
427
427
  }
428
428
  ctx.stroke();
429
429
  }
430
+ else if (annotation.type === 'brush' && annotation.points.length >= 1) {
431
+ // Draw brush stroke with variable width
432
+ const rawPoints = annotation.points.map(p => ({
433
+ ...toCanvasCoords(p.x, p.y),
434
+ width: p.width ?? annotation.strokeWidth
435
+ }));
436
+ // Handle single point - create an elliptical brush mark (点)
437
+ if (rawPoints.length === 1) {
438
+ const p = rawPoints[0];
439
+ const width = (p.width * totalScale) / 2;
440
+ const rx = width * 0.8;
441
+ const ry = width * 1.2;
442
+ ctx.beginPath();
443
+ ctx.ellipse(p.x, p.y, rx, ry, 0, 0, Math.PI * 2);
444
+ ctx.fill();
445
+ // Handle 2 points - create a teardrop/brush stroke shape
446
+ }
447
+ else if (rawPoints.length === 2) {
448
+ const p1 = rawPoints[0];
449
+ const p2 = rawPoints[1];
450
+ const w1 = ((p1.width ?? annotation.strokeWidth * 0.3) * totalScale) / 2;
451
+ const w2 = ((p2.width ?? annotation.strokeWidth * 0.5) * totalScale) / 2;
452
+ const dx = p2.x - p1.x;
453
+ const dy = p2.y - p1.y;
454
+ const len = Math.sqrt(dx * dx + dy * dy);
455
+ if (len > 0) {
456
+ const nx = -dy / len;
457
+ const ny = dx / len;
458
+ const tipExtend = w2 * 0.3;
459
+ const tipX = p2.x + (dx / len) * tipExtend;
460
+ const tipY = p2.y + (dy / len) * tipExtend;
461
+ ctx.beginPath();
462
+ ctx.moveTo(p1.x + nx * w1, p1.y + ny * w1);
463
+ ctx.quadraticCurveTo((p1.x + nx * w1 + p2.x + nx * w2) / 2 + nx * w2 * 0.3, (p1.y + ny * w1 + p2.y + ny * w2) / 2 + ny * w2 * 0.3, p2.x + nx * w2, p2.y + ny * w2);
464
+ ctx.quadraticCurveTo(tipX + nx * w2 * 0.2, tipY + ny * w2 * 0.2, tipX, tipY);
465
+ ctx.quadraticCurveTo(tipX - nx * w2 * 0.2, tipY - ny * w2 * 0.2, p2.x - nx * w2, p2.y - ny * w2);
466
+ ctx.quadraticCurveTo((p1.x - nx * w1 + p2.x - nx * w2) / 2 - nx * w2 * 0.3, (p1.y - ny * w1 + p2.y - ny * w2) / 2 - ny * w2 * 0.3, p1.x - nx * w1, p1.y - ny * w1);
467
+ ctx.closePath();
468
+ ctx.fill();
469
+ }
470
+ // Handle 3+ points with interpolation for smoother curves
471
+ }
472
+ else {
473
+ // Interpolate points for smoother curves
474
+ const interpolated = [];
475
+ for (let i = 0; i < rawPoints.length - 1; i++) {
476
+ const p1 = rawPoints[i];
477
+ const p2 = rawPoints[i + 1];
478
+ const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
479
+ interpolated.push(p1);
480
+ const interpolateCount = Math.floor(dist / 5);
481
+ for (let j = 1; j < interpolateCount; j++) {
482
+ const t = j / interpolateCount;
483
+ const smoothT = t * t * (3 - 2 * t);
484
+ interpolated.push({
485
+ x: p1.x + (p2.x - p1.x) * t,
486
+ y: p1.y + (p2.y - p1.y) * t,
487
+ width: p1.width + (p2.width - p1.width) * smoothT
488
+ });
489
+ }
490
+ }
491
+ interpolated.push(rawPoints[rawPoints.length - 1]);
492
+ // Generate outline for variable-width path
493
+ let leftSide = [];
494
+ let rightSide = [];
495
+ for (let i = 0; i < interpolated.length; i++) {
496
+ const curr = interpolated[i];
497
+ const width = (curr.width * totalScale) / 2;
498
+ let dx, dy;
499
+ if (i === 0) {
500
+ dx = interpolated[1].x - curr.x;
501
+ dy = interpolated[1].y - curr.y;
502
+ }
503
+ else if (i === interpolated.length - 1) {
504
+ dx = curr.x - interpolated[i - 1].x;
505
+ dy = curr.y - interpolated[i - 1].y;
506
+ }
507
+ else {
508
+ const lookback = Math.min(i, 3);
509
+ const lookforward = Math.min(interpolated.length - 1 - i, 3);
510
+ dx = interpolated[i + lookforward].x - interpolated[i - lookback].x;
511
+ dy = interpolated[i + lookforward].y - interpolated[i - lookback].y;
512
+ }
513
+ const len = Math.sqrt(dx * dx + dy * dy);
514
+ if (len === 0)
515
+ continue;
516
+ const nx = -dy / len;
517
+ const ny = dx / len;
518
+ leftSide.push({ x: curr.x + nx * width, y: curr.y + ny * width });
519
+ rightSide.push({ x: curr.x - nx * width, y: curr.y - ny * width });
520
+ }
521
+ // Smooth the outline points to reduce zigzag
522
+ const smoothOutline = (pts, windowSize = 5) => {
523
+ if (pts.length < 3)
524
+ return pts;
525
+ const result = [];
526
+ const halfWindow = Math.floor(windowSize / 2);
527
+ for (let i = 0; i < pts.length; i++) {
528
+ if (i < halfWindow || i >= pts.length - halfWindow) {
529
+ result.push(pts[i]);
530
+ continue;
531
+ }
532
+ let sumX = 0, sumY = 0, count = 0;
533
+ for (let j = -halfWindow; j <= halfWindow; j++) {
534
+ const idx = i + j;
535
+ if (idx >= 0 && idx < pts.length) {
536
+ sumX += pts[idx].x;
537
+ sumY += pts[idx].y;
538
+ count++;
539
+ }
540
+ }
541
+ result.push({ x: sumX / count, y: sumY / count });
542
+ }
543
+ return result;
544
+ };
545
+ leftSide = smoothOutline(leftSide);
546
+ rightSide = smoothOutline(rightSide);
547
+ if (leftSide.length >= 2) {
548
+ ctx.beginPath();
549
+ ctx.moveTo(leftSide[0].x, leftSide[0].y);
550
+ // Left side with smooth curves
551
+ for (let i = 1; i < leftSide.length - 1; i++) {
552
+ const curr = leftSide[i];
553
+ const next = leftSide[i + 1];
554
+ const endX = (curr.x + next.x) / 2;
555
+ const endY = (curr.y + next.y) / 2;
556
+ ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
557
+ }
558
+ ctx.lineTo(leftSide[leftSide.length - 1].x, leftSide[leftSide.length - 1].y);
559
+ // End cap with rounded connection
560
+ const lastPoint = interpolated[interpolated.length - 1];
561
+ const lastWidth = (lastPoint.width * totalScale) / 2;
562
+ const tipExtend = lastWidth * 0.4;
563
+ const lastDx = interpolated.length > 1
564
+ ? interpolated[interpolated.length - 1].x - interpolated[interpolated.length - 2].x
565
+ : 0;
566
+ const lastDy = interpolated.length > 1
567
+ ? interpolated[interpolated.length - 1].y - interpolated[interpolated.length - 2].y
568
+ : 0;
569
+ const lastLen = Math.sqrt(lastDx * lastDx + lastDy * lastDy);
570
+ if (lastLen > 0) {
571
+ const tipX = lastPoint.x + (lastDx / lastLen) * tipExtend;
572
+ const tipY = lastPoint.y + (lastDy / lastLen) * tipExtend;
573
+ ctx.quadraticCurveTo(tipX, tipY, rightSide[rightSide.length - 1].x, rightSide[rightSide.length - 1].y);
574
+ }
575
+ else {
576
+ ctx.lineTo(rightSide[rightSide.length - 1].x, rightSide[rightSide.length - 1].y);
577
+ }
578
+ // Right side backward with smooth curves
579
+ for (let i = rightSide.length - 2; i > 0; i--) {
580
+ const curr = rightSide[i];
581
+ const prev = rightSide[i - 1];
582
+ const endX = (curr.x + prev.x) / 2;
583
+ const endY = (curr.y + prev.y) / 2;
584
+ ctx.quadraticCurveTo(curr.x, curr.y, endX, endY);
585
+ }
586
+ ctx.lineTo(rightSide[0].x, rightSide[0].y);
587
+ // Start cap with rounded connection
588
+ const firstPoint = interpolated[0];
589
+ const firstWidth = (firstPoint.width * totalScale) / 2;
590
+ const startExtend = firstWidth * 0.3;
591
+ const firstDx = interpolated.length > 1
592
+ ? interpolated[0].x - interpolated[1].x
593
+ : 0;
594
+ const firstDy = interpolated.length > 1
595
+ ? interpolated[0].y - interpolated[1].y
596
+ : 0;
597
+ const firstLen = Math.sqrt(firstDx * firstDx + firstDy * firstDy);
598
+ if (firstLen > 0) {
599
+ const startTipX = firstPoint.x + (firstDx / firstLen) * startExtend;
600
+ const startTipY = firstPoint.y + (firstDy / firstLen) * startExtend;
601
+ ctx.quadraticCurveTo(startTipX, startTipY, leftSide[0].x, leftSide[0].y);
602
+ }
603
+ ctx.closePath();
604
+ ctx.fill();
605
+ }
606
+ }
607
+ }
430
608
  else if (annotation.type === 'arrow' && annotation.points.length >= 2) {
431
609
  // Draw arrow
432
610
  const start = toCanvasCoords(annotation.points[0].x, annotation.points[0].y);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",