tokimeki-image-editor 0.2.3 → 0.2.5

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 } from 'lucide-svelte';
4
+ import { Pencil, Eraser, ArrowRight, Square, Brush } from 'lucide-svelte';
5
5
  import ToolPanel from './ToolPanel.svelte';
6
6
  let { canvas, image, viewport, transform, annotations, cropArea, onUpdate, onClose, onViewportChange } = $props();
7
7
  let containerElement = $state(null);
@@ -15,10 +15,16 @@ const colorPresets = ['#FF6B6B', '#FFA94D', '#FFD93D', '#6BCB77', '#4D96FF', '#9
15
15
  // Drawing state
16
16
  let isDrawing = $state(false);
17
17
  let currentAnnotation = $state(null);
18
- // Panning state (Space + drag)
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
23
+ // Panning state (Space + drag on desktop, 2-finger drag on mobile)
19
24
  let isSpaceHeld = $state(false);
20
25
  let isPanning = $state(false);
21
26
  let panStart = $state(null);
27
+ let isTwoFingerTouch = $state(false);
22
28
  // Helper to get coordinates from mouse or touch event
23
29
  function getEventCoords(event) {
24
30
  if ('touches' in event && event.touches.length > 0) {
@@ -151,6 +157,25 @@ function handleMouseDown(event) {
151
157
  shadow: shadowEnabled
152
158
  };
153
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
+ }
154
179
  else if (currentTool === 'arrow' || currentTool === 'rectangle') {
155
180
  currentAnnotation = {
156
181
  id: `annotation-${Date.now()}`,
@@ -198,6 +223,56 @@ function handleMouseMove(event) {
198
223
  };
199
224
  }
200
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
+ }
201
276
  else if (currentTool === 'arrow' || currentTool === 'rectangle') {
202
277
  currentAnnotation = {
203
278
  ...currentAnnotation,
@@ -216,11 +291,73 @@ function handleMouseUp(event) {
216
291
  isDrawing = false;
217
292
  return;
218
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
+ }
219
355
  // Only save if annotation has enough points
220
356
  if (currentAnnotation.points.length >= 2 ||
221
- (currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1)) {
357
+ (currentAnnotation.type === 'pen' && currentAnnotation.points.length >= 1) ||
358
+ (currentAnnotation.type === 'brush' && currentAnnotation.points.length >= 1)) {
222
359
  // For arrow/rectangle, ensure start and end are different
223
- if (currentAnnotation.type !== 'pen') {
360
+ if (currentAnnotation.type !== 'pen' && currentAnnotation.type !== 'brush') {
224
361
  const start = currentAnnotation.points[0];
225
362
  const end = currentAnnotation.points[1];
226
363
  const distance = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
@@ -234,6 +371,10 @@ function handleMouseUp(event) {
234
371
  }
235
372
  isDrawing = false;
236
373
  currentAnnotation = null;
374
+ lastPointTime = 0;
375
+ lastPointPos = null;
376
+ recentSpeeds = [];
377
+ strokeStartTime = 0;
237
378
  }
238
379
  function findAnnotationAtPoint(canvasX, canvasY) {
239
380
  const hitRadius = 10;
@@ -289,12 +430,79 @@ function pointToSegmentDistance(px, py, a, b) {
289
430
  function handleClearAll() {
290
431
  onUpdate([]);
291
432
  }
292
- const handleTouchStart = handleMouseDown;
293
- const handleTouchMove = handleMouseMove;
433
+ function handleTouchStart(event) {
434
+ // Two-finger touch starts panning
435
+ if (event.touches.length === 2) {
436
+ event.preventDefault();
437
+ isTwoFingerTouch = true;
438
+ // Use the midpoint of the two touches
439
+ const touch1 = event.touches[0];
440
+ const touch2 = event.touches[1];
441
+ const midX = (touch1.clientX + touch2.clientX) / 2;
442
+ const midY = (touch1.clientY + touch2.clientY) / 2;
443
+ // Cancel any current drawing
444
+ if (isDrawing) {
445
+ isDrawing = false;
446
+ currentAnnotation = null;
447
+ }
448
+ isPanning = true;
449
+ panStart = {
450
+ x: midX,
451
+ y: midY,
452
+ offsetX: viewport.offsetX,
453
+ offsetY: viewport.offsetY
454
+ };
455
+ return;
456
+ }
457
+ // Single finger - normal drawing (only if not already in two-finger mode)
458
+ if (event.touches.length === 1 && !isTwoFingerTouch) {
459
+ handleMouseDown(event);
460
+ }
461
+ }
462
+ function handleTouchMove(event) {
463
+ // Two-finger panning
464
+ if (event.touches.length === 2 && isPanning && panStart && onViewportChange) {
465
+ event.preventDefault();
466
+ const touch1 = event.touches[0];
467
+ const touch2 = event.touches[1];
468
+ const midX = (touch1.clientX + touch2.clientX) / 2;
469
+ const midY = (touch1.clientY + touch2.clientY) / 2;
470
+ const dx = midX - panStart.x;
471
+ const dy = midY - panStart.y;
472
+ onViewportChange({
473
+ offsetX: panStart.offsetX + dx,
474
+ offsetY: panStart.offsetY + dy
475
+ });
476
+ return;
477
+ }
478
+ // Single finger drawing (only if not in two-finger mode)
479
+ if (event.touches.length === 1 && !isTwoFingerTouch) {
480
+ handleMouseMove(event);
481
+ }
482
+ }
294
483
  function handleTouchEnd(event) {
484
+ // When all fingers are lifted
295
485
  if (event.touches.length === 0) {
486
+ if (isPanning) {
487
+ isPanning = false;
488
+ panStart = null;
489
+ }
490
+ isTwoFingerTouch = false;
296
491
  handleMouseUp();
297
492
  }
493
+ // When going from 2 fingers to 1, stay in pan mode but don't draw
494
+ else if (event.touches.length === 1 && isTwoFingerTouch) {
495
+ // Update pan start to the remaining finger position
496
+ if (isPanning && onViewportChange) {
497
+ const touch = event.touches[0];
498
+ panStart = {
499
+ x: touch.clientX,
500
+ y: touch.clientY,
501
+ offsetX: viewport.offsetX,
502
+ offsetY: viewport.offsetY
503
+ };
504
+ }
505
+ }
298
506
  }
299
507
  // Generate smooth SVG path using quadratic bezier curves
300
508
  function generateSmoothPath(points) {
@@ -330,11 +538,216 @@ function generateSmoothPath(points) {
330
538
  path += ` L ${lastPoint.x} ${lastPoint.y}`;
331
539
  return path;
332
540
  }
333
- // 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
334
742
  let currentAnnotationCanvas = $derived.by(() => {
335
743
  if (!currentAnnotation)
336
744
  return null;
337
- 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);
338
751
  return { ...currentAnnotation, canvasPoints: points };
339
752
  });
340
753
  </script>
@@ -350,7 +763,7 @@ let currentAnnotationCanvas = $derived.by(() => {
350
763
  <div
351
764
  bind:this={containerElement}
352
765
  class="annotation-tool-overlay"
353
- class:panning={isSpaceHeld}
766
+ class:panning={isSpaceHeld || isTwoFingerTouch}
354
767
  onmousedown={handleMouseDown}
355
768
  role="button"
356
769
  tabindex="-1"
@@ -379,6 +792,18 @@ let currentAnnotationCanvas = $derived.by(() => {
379
792
  stroke-linejoin="round"
380
793
  filter={shadowFilter}
381
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
+ />
382
807
  {:else if annotation.type === 'arrow' && points.length >= 2}
383
808
  {@const start = points[0]}
384
809
  {@const end = points[1]}
@@ -442,6 +867,13 @@ let currentAnnotationCanvas = $derived.by(() => {
442
867
  stroke-linejoin="round"
443
868
  filter={currentShadowFilter}
444
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
+ />
445
877
  {:else if currentAnnotationCanvas.type === 'arrow' && points.length >= 2}
446
878
  {@const start = points[0]}
447
879
  {@const end = points[1]}
@@ -506,6 +938,14 @@ let currentAnnotationCanvas = $derived.by(() => {
506
938
  >
507
939
  <Pencil size={20} />
508
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>
509
949
  <button
510
950
  class="tool-btn"
511
951
  class:active={currentTool === 'eraser'}
@@ -565,7 +1005,7 @@ let currentAnnotationCanvas = $derived.by(() => {
565
1005
  id="stroke-width"
566
1006
  type="range"
567
1007
  min="5"
568
- max="50"
1008
+ max="100"
569
1009
  bind:value={strokeWidth}
570
1010
  />
571
1011
  </div>
@@ -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, 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);
@@ -1,6 +1,8 @@
1
1
  import '../i18n';
2
+ import type { EditorMode } from '../types';
2
3
  interface Props {
3
4
  initialImage?: File | string;
5
+ initialMode?: EditorMode;
4
6
  width?: number;
5
7
  height?: number;
6
8
  isStandalone?: boolean;
@@ -103,7 +103,7 @@ function handleSheetDragEnd() {
103
103
 
104
104
  @media (max-width: 767px) {
105
105
  .tool-panel {
106
- position: absolute;
106
+ position: fixed;
107
107
  left: 0;
108
108
  right: 0;
109
109
  top: auto;
@@ -112,10 +112,11 @@ function handleSheetDragEnd() {
112
112
  min-width: auto;
113
113
  height: var(--sheet-max-height, 400px);
114
114
  border-radius: 16px 16px 0 0;
115
- z-index: 1001;
115
+ z-index: 9999;
116
116
  overflow-y: auto;
117
117
  overscroll-behavior: contain;
118
118
  padding-top: 0;
119
+ padding-bottom: 1.5rem;
119
120
  transform: translateY(var(--sheet-offset, 0px));
120
121
  will-change: transform;
121
122
  backdrop-filter: none
@@ -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.3",
3
+ "version": "0.2.5",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",