tokimeki-image-editor 0.4.15 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,18 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { Sun, Contrast, Cloud, Moon, SunMedium, Palette, Thermometer, Aperture, Waves, Sparkles, Focus, AudioWaveform, Spline, Droplets } from 'lucide-svelte';
2
+ import Sun from '@lucide/svelte/icons/sun';
3
+ import Contrast from '@lucide/svelte/icons/contrast';
4
+ import Cloud from '@lucide/svelte/icons/cloud';
5
+ import Moon from '@lucide/svelte/icons/moon';
6
+ import SunMedium from '@lucide/svelte/icons/sun-medium';
7
+ import Palette from '@lucide/svelte/icons/palette';
8
+ import Thermometer from '@lucide/svelte/icons/thermometer';
9
+ import Aperture from '@lucide/svelte/icons/aperture';
10
+ import Waves from '@lucide/svelte/icons/waves';
11
+ import Sparkles from '@lucide/svelte/icons/sparkles';
12
+ import Focus from '@lucide/svelte/icons/focus';
13
+ import AudioWaveform from '@lucide/svelte/icons/audio-waveform';
14
+ import Spline from '@lucide/svelte/icons/spline';
15
+ import Droplets from '@lucide/svelte/icons/droplets';
3
16
  import ToolPanel from './ToolPanel.svelte';
4
17
  import Slider from './Slider.svelte';
5
18
  import ToneCurveTool from './ToneCurveTool.svelte';
@@ -7,7 +7,19 @@ import { initWasm } from '../wasm/stroke-processor';
7
7
  import { getEventCoords, createPenAnnotation, createBrushAnnotation, createFillAnnotation, createEraserStrokeAnnotation, addPointToPen, addPointToBrush, finalizeBrushStroke, performFloodFill, getCompositeImageData, getAnnotationOnlyImageData, generateSmoothPath, generateSmoothPathWithLOD, smoothPoints, interpolateBrushPoints, generateBrushPath, generateBrushPathWithLOD, MIN_POINT_DISTANCE } from '../utils/drawing';
8
8
  import { createEditorInteractionState, handleOverlayKeyDown, handleOverlayKeyUp, calculatePanOffset, calculateZoomViewport, shouldPan } from '../utils/editor-interaction';
9
9
  import { screenToImageCoords as sharedScreenToImageCoords, imageToCanvasCoords as sharedImageToCanvasCoords } from '../utils/coordinates';
10
- import { Pencil, Eraser, ArrowRight, Square, Brush, Type, PaintBucket, X, Trash2, Sparkles, Layers, ImageOff, EyeOff } from 'lucide-svelte';
10
+ import Pencil from '@lucide/svelte/icons/pencil';
11
+ import Eraser from '@lucide/svelte/icons/eraser';
12
+ import ArrowRight from '@lucide/svelte/icons/arrow-right';
13
+ import Square from '@lucide/svelte/icons/square';
14
+ import Brush from '@lucide/svelte/icons/brush';
15
+ import Type from '@lucide/svelte/icons/type';
16
+ import PaintBucket from '@lucide/svelte/icons/paint-bucket';
17
+ import X from '@lucide/svelte/icons/x';
18
+ import Trash2 from '@lucide/svelte/icons/trash-2';
19
+ import Sparkles from '@lucide/svelte/icons/sparkles';
20
+ import Layers from '@lucide/svelte/icons/layers';
21
+ import ImageOff from '@lucide/svelte/icons/image-off';
22
+ import EyeOff from '@lucide/svelte/icons/eye-off';
11
23
  import FloatingRail from './FloatingRail.svelte';
12
24
  import RailButton from './RailButton.svelte';
13
25
  import Popover from './Popover.svelte';
@@ -1,6 +1,9 @@
1
1
  <script lang="ts">import { onMount } from 'svelte';
2
2
  import { _ } from 'svelte-i18n';
3
- import { X, Trash2, Droplet, Info } from 'lucide-svelte';
3
+ import X from '@lucide/svelte/icons/x';
4
+ import Trash2 from '@lucide/svelte/icons/trash-2';
5
+ import Droplet from '@lucide/svelte/icons/droplet';
6
+ import Info from '@lucide/svelte/icons/info';
4
7
  import { screenToImageCoords, imageToCanvasCoords } from '../utils/canvas';
5
8
  import { calculateZoomViewport, calculatePanOffset } from '../utils/editor-interaction';
6
9
  import FloatingRail from './FloatingRail.svelte';
@@ -1,5 +1,11 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { Crop, SlidersHorizontal, Sparkles, Droplet, Sticker, PenLine, Download } from 'lucide-svelte';
2
+ import Crop from '@lucide/svelte/icons/crop';
3
+ import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
4
+ import Sparkles from '@lucide/svelte/icons/sparkles';
5
+ import Droplet from '@lucide/svelte/icons/droplet';
6
+ import Sticker from '@lucide/svelte/icons/sticker';
7
+ import PenLine from '@lucide/svelte/icons/pen-line';
8
+ import Download from '@lucide/svelte/icons/download';
3
9
  import { haptic } from '../utils/haptics';
4
10
  let { mode, hasImage, isStandalone = false, onModeChange } = $props();
5
11
  const baseTools = [
@@ -1,6 +1,17 @@
1
1
  <script lang="ts">import { onMount } from 'svelte';
2
2
  import { _ } from 'svelte-i18n';
3
- import { RotateCw, RotateCcw, FlipHorizontal, FlipVertical, X, Check, Crop as CropIcon, Lock, Unlock, Repeat2, RotateCcwSquare, Maximize2 } from 'lucide-svelte';
3
+ import RotateCw from '@lucide/svelte/icons/rotate-cw';
4
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
5
+ import FlipHorizontal from '@lucide/svelte/icons/flip-horizontal';
6
+ import FlipVertical from '@lucide/svelte/icons/flip-vertical';
7
+ import X from '@lucide/svelte/icons/x';
8
+ import Check from '@lucide/svelte/icons/check';
9
+ import CropIcon from '@lucide/svelte/icons/crop';
10
+ import Lock from '@lucide/svelte/icons/lock';
11
+ import Unlock from '@lucide/svelte/icons/unlock';
12
+ import Repeat2 from '@lucide/svelte/icons/repeat-2';
13
+ import RotateCcwSquare from '@lucide/svelte/icons/rotate-ccw-square';
14
+ import Maximize2 from '@lucide/svelte/icons/maximize-2';
4
15
  import { screenToImageCoords, imageToCanvasCoords } from '../utils/canvas';
5
16
  import FloatingRail from './FloatingRail.svelte';
6
17
  import RailButton from './RailButton.svelte';
@@ -54,6 +65,7 @@ function swapOrientation() {
54
65
  lockedAspectRatio = 1 / lockedAspectRatio;
55
66
  }
56
67
  haptic('selection');
68
+ autoFit(300);
57
69
  }
58
70
  function resetCrop() {
59
71
  if (!image)
@@ -62,6 +74,7 @@ function resetCrop() {
62
74
  aspectLocked = false;
63
75
  lockedAspectRatio = null;
64
76
  haptic('warning');
77
+ autoFit(300);
65
78
  }
66
79
  // Display helpers
67
80
  let displayWidth = $derived(Math.round(cropArea.width));
@@ -99,17 +112,15 @@ let isResizing = $state(false);
99
112
  let dragStart = $state({ x: 0, y: 0 });
100
113
  let resizeHandle = $state(null);
101
114
  let initialCropArea = $state(null);
102
- // Viewport panning state.
103
- // 'frame' — drag inside frame: image scrolls under stationary frame (iOS Photos)
104
- // 'canvas' — drag outside frame: viewport itself moves (image + frame together)
115
+ // Viewport panning state — all drags move the image under a stationary frame (Lightroom style).
105
116
  let isPanning = $state(false);
106
- let panType = $state('frame');
107
117
  let lastPanPosition = $state({ x: 0, y: 0 });
108
- // Touch pinch zoom state — pinch now zooms the underlying image, not the crop area.
118
+ // Touch pinch zoom state — pinch changes cropArea (not viewport.zoom) for crop-contextual zoom.
109
119
  let initialPinchDistance = $state(0);
110
- let initialPinchZoom = $state(1);
120
+ let initialPinchCropArea = $state(null);
121
+ let initialPinchFocusRel = $state({ x: 0.5, y: 0.5 });
111
122
  // True when any drag-like interaction is in progress (used to brighten the grid)
112
- let isInteracting = $derived(isResizing || isPanning);
123
+ let isInteracting = $derived(isResizing || isPanning || initialPinchDistance > 0);
113
124
  // SVG render constants — placed up here, derived geometry placed after canvasCoords below
114
125
  const cornerLen = 22;
115
126
  const cornerThickness = 4;
@@ -154,6 +165,7 @@ onMount(() => {
154
165
  containerElement.addEventListener('touchend', handleContainerTouchEndUnified, { passive: false });
155
166
  }
156
167
  return () => {
168
+ cancelAnimation();
157
169
  if (containerElement) {
158
170
  containerElement.removeEventListener('touchstart', handleContainerTouchStartUnified);
159
171
  containerElement.removeEventListener('touchmove', handleContainerTouchMoveUnified);
@@ -183,11 +195,14 @@ $effect(() => {
183
195
  height: image.height
184
196
  };
185
197
  }
198
+ // Auto-fit viewport to center and fill the crop frame (after state settles)
199
+ queueMicrotask(() => autoFit(400));
186
200
  }
187
201
  });
188
202
  function handleMouseDown(event, handle) {
189
203
  if (!canvas || !image)
190
204
  return;
205
+ cancelAnimation();
191
206
  event.preventDefault();
192
207
  event.stopPropagation();
193
208
  const coords = getEventCoords(event);
@@ -201,10 +216,9 @@ function handleMouseDown(event, handle) {
201
216
  haptic('selection');
202
217
  }
203
218
  else {
204
- // Frame interior drag → pan the underlying image (iOS Photos / Lightroom style).
219
+ // Drag → pan the underlying image (Lightroom style).
205
220
  // The frame stays put on screen; the image moves beneath it.
206
221
  isPanning = true;
207
- panType = 'frame';
208
222
  isDragging = false;
209
223
  lastPanPosition = { x: coords.clientX, y: coords.clientY };
210
224
  }
@@ -237,15 +251,15 @@ function isNearResizeHandle(mouseX, mouseY, handleRadius) {
237
251
  return false;
238
252
  }
239
253
  // Container mousedown — frame interior and handles stop propagation in their
240
- // own handlers, so this only fires when the user pressed *outside* the frame.
241
- // That case = canvas pan (viewport moves, image and frame both follow the finger).
254
+ // Container mousedown fires when user pressed outside the frame.
255
+ // Same behavior as inside: image scrolls under stationary frame (Lightroom style).
242
256
  function handleContainerMouseDown(event) {
243
257
  if (event.button !== 0)
244
258
  return;
259
+ cancelAnimation();
245
260
  event.preventDefault();
246
261
  event.stopPropagation();
247
262
  isPanning = true;
248
- panType = 'canvas';
249
263
  isResizing = false;
250
264
  resizeHandle = null;
251
265
  initialCropArea = null;
@@ -255,27 +269,11 @@ function handleMouseMove(event) {
255
269
  if (!canvas || !image)
256
270
  return;
257
271
  const coords = getEventCoords(event);
258
- // ── Pan — two flavours depending on where the drag started ──
272
+ // ── Pan — image scrolls under stationary frame (Lightroom style) ──
259
273
  if (isPanning) {
260
274
  const deltaX = coords.clientX - lastPanPosition.x;
261
275
  const deltaY = coords.clientY - lastPanPosition.y;
262
276
  event.preventDefault();
263
- if (panType === 'canvas') {
264
- // Drag started outside the frame → move the viewport itself.
265
- // Both image and frame translate together (cropArea is untouched).
266
- if (onViewportChange) {
267
- onViewportChange({
268
- offsetX: viewport.offsetX + deltaX,
269
- offsetY: viewport.offsetY + deltaY
270
- });
271
- }
272
- lastPanPosition = { x: coords.clientX, y: coords.clientY };
273
- return;
274
- }
275
- // panType === 'frame'
276
- // Drag inside the frame → image scrolls under a stationary frame.
277
- // cropArea moves in image space by the inverse of the finger motion, and
278
- // viewport.offset is counter-adjusted so the frame's canvas position stays put.
279
277
  const scale = viewport.scale * viewport.zoom;
280
278
  const imageDeltaX = deltaX / scale;
281
279
  const imageDeltaY = deltaY / scale;
@@ -394,12 +392,73 @@ function handleMouseMove(event) {
394
392
  cropArea = next;
395
393
  }
396
394
  }
395
+ // ── Auto-fit: calculate viewport that centers and fills the crop frame ──
396
+ const AUTO_FIT_PADDING = 0.80; // crop frame fills 80% of canvas
397
+ function calculateAutoFitViewport(crop, img, cvs, baseScale) {
398
+ const targetW = cvs.width * AUTO_FIT_PADDING;
399
+ const targetH = cvs.height * AUTO_FIT_PADDING;
400
+ const fitZoom = Math.min(targetW / (crop.width * baseScale), targetH / (crop.height * baseScale));
401
+ const totalScale = baseScale * fitZoom;
402
+ const cropCenterX = crop.x + crop.width / 2;
403
+ const cropCenterY = crop.y + crop.height / 2;
404
+ const offsetX = -(cropCenterX - img.width / 2) * totalScale;
405
+ const offsetY = -(cropCenterY - img.height / 2) * totalScale;
406
+ return { zoom: fitZoom, offsetX, offsetY };
407
+ }
408
+ // ── Smooth viewport animation (ease-out cubic) ──
409
+ let animationFrameId = null;
410
+ function cancelAnimation() {
411
+ if (animationFrameId !== null) {
412
+ cancelAnimationFrame(animationFrameId);
413
+ animationFrameId = null;
414
+ }
415
+ }
416
+ function animateViewportTo(target, duration = 300) {
417
+ cancelAnimation();
418
+ if (!onViewportChange)
419
+ return;
420
+ const start = { zoom: viewport.zoom, offsetX: viewport.offsetX, offsetY: viewport.offsetY };
421
+ const startTime = performance.now();
422
+ function tick(now) {
423
+ const elapsed = now - startTime;
424
+ const t = Math.min(1, elapsed / duration);
425
+ const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
426
+ onViewportChange({
427
+ zoom: start.zoom + (target.zoom - start.zoom) * eased,
428
+ offsetX: start.offsetX + (target.offsetX - start.offsetX) * eased,
429
+ offsetY: start.offsetY + (target.offsetY - start.offsetY) * eased
430
+ });
431
+ if (t < 1) {
432
+ animationFrameId = requestAnimationFrame(tick);
433
+ }
434
+ else {
435
+ animationFrameId = null;
436
+ }
437
+ }
438
+ animationFrameId = requestAnimationFrame(tick);
439
+ }
440
+ function autoFit(duration = 300) {
441
+ if (!canvas || !image)
442
+ return;
443
+ const target = calculateAutoFitViewport(cropArea, image, canvas, viewport.scale);
444
+ if (duration > 0) {
445
+ animateViewportTo(target, duration);
446
+ }
447
+ else if (onViewportChange) {
448
+ onViewportChange(target);
449
+ }
450
+ }
397
451
  function handleMouseUp() {
452
+ const wasResizing = isResizing;
398
453
  isDragging = false;
399
454
  isResizing = false;
400
455
  isPanning = false;
401
456
  resizeHandle = null;
402
457
  initialCropArea = null;
458
+ // Snap-back: auto-fit after resize to re-center the new crop frame
459
+ if (wasResizing) {
460
+ autoFit(300);
461
+ }
403
462
  }
404
463
  // These will be defined below after pinch zoom handlers are renamed
405
464
  function apply() {
@@ -431,12 +490,14 @@ function setAspectRatio(ratio) {
431
490
  width: newWidth,
432
491
  height: newHeight
433
492
  };
493
+ autoFit(300);
434
494
  }
435
- // Wheel zoom — exponential (log-scale feel), cursor-focused.
436
- // Normalizes trackpad line-mode / pixel-mode. Shift = coarse step.
495
+ // Wheel zoom — crop-contextual: changes cropArea size, not viewport.zoom.
496
+ // Scroll up = zoom in = cropArea shrinks. Cursor-focused.
437
497
  function handleWheel(event) {
438
498
  if (!canvas || !image)
439
499
  return;
500
+ cancelAnimation();
440
501
  event.preventDefault();
441
502
  event.stopPropagation();
442
503
  let dy = event.deltaY;
@@ -446,11 +507,31 @@ function handleWheel(event) {
446
507
  dy *= 100; // page
447
508
  const coarseness = event.shiftKey ? 0.0048 : 0.002;
448
509
  const factor = Math.exp(-dy * coarseness);
449
- const targetZoom = viewport.zoom * factor;
450
- const rect = canvas.getBoundingClientRect();
451
- const focusX = event.clientX - rect.left;
452
- const focusY = event.clientY - rect.top;
453
- applyCursorFocusedZoom(targetZoom, focusX, focusY);
510
+ // factor > 1 = scroll up = zoom in = crop shrinks
511
+ const inverseRatio = 1 / factor;
512
+ const minSize = 50;
513
+ let newWidth = cropArea.width * inverseRatio;
514
+ let newHeight = cropArea.height * inverseRatio;
515
+ newWidth = Math.max(minSize, Math.min(image.width, newWidth));
516
+ newHeight = Math.max(minSize, Math.min(image.height, newHeight));
517
+ if (lockedAspectRatio !== null) {
518
+ if (newWidth / newHeight > lockedAspectRatio) {
519
+ newWidth = newHeight * lockedAspectRatio;
520
+ }
521
+ else {
522
+ newHeight = newWidth / lockedAspectRatio;
523
+ }
524
+ }
525
+ // Re-center around cursor position in image coords
526
+ const imgCoords = screenToImageCoords(event.clientX, event.clientY, canvas, image, viewport, transform);
527
+ const relX = Math.max(0, Math.min(1, (imgCoords.x - cropArea.x) / cropArea.width));
528
+ const relY = Math.max(0, Math.min(1, (imgCoords.y - cropArea.y) / cropArea.height));
529
+ let newX = imgCoords.x - relX * newWidth;
530
+ let newY = imgCoords.y - relY * newHeight;
531
+ newX = Math.max(0, Math.min(image.width - newWidth, newX));
532
+ newY = Math.max(0, Math.min(image.height - newHeight, newY));
533
+ cropArea = { x: newX, y: newY, width: newWidth, height: newHeight };
534
+ autoFit(0); // instant re-center
454
535
  }
455
536
  // Keyboard shortcuts — Enter / Esc / Arrow keys (1px), Shift+Arrow (10px)
456
537
  function handleKeyDown(event) {
@@ -492,70 +573,72 @@ function handleKeyDown(event) {
492
573
  fitToScreen();
493
574
  }
494
575
  }
495
- // ── Cursor-focused canvas zoom ─────────────────────────────────────────
496
- // This is the *standard* zoom: both the image and the frame scale together
497
- // (the frame is drawn relative to cropArea in image space, so as viewport.zoom
498
- // changes, the frame naturally follows the image on screen). cropArea is
499
- // NOT modified — the "which part of the image is cropped" selection is
500
- // preserved across zooms, only its visual size on canvas changes.
501
- //
502
- // Pan (frame-stationary) is still handled separately in handleMouseMove,
503
- // where both cropArea and viewport.offset update together.
504
- const MIN_ZOOM = 0.25;
505
- const MAX_ZOOM = 10;
506
- function applyCursorFocusedZoom(targetZoom, focusCanvasX, focusCanvasY) {
507
- if (!canvas || !onViewportChange)
508
- return false;
509
- const oldZoom = viewport.zoom;
510
- const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, targetZoom));
511
- if (Math.abs(clampedZoom - oldZoom) < 1e-4)
512
- return false;
513
- // Keep the image point under the focus stationary on screen.
514
- const cxRel = focusCanvasX - canvas.width / 2;
515
- const cyRel = focusCanvasY - canvas.height / 2;
516
- const zoomRatio = clampedZoom / oldZoom;
517
- const newOffsetX = cxRel - (cxRel - viewport.offsetX) * zoomRatio;
518
- const newOffsetY = cyRel - (cyRel - viewport.offsetY) * zoomRatio;
519
- onViewportChange({
520
- zoom: clampedZoom,
521
- offsetX: newOffsetX,
522
- offsetY: newOffsetY
523
- });
524
- return true;
525
- }
526
576
  function fitToScreen() {
527
- if (!onViewportChange)
528
- return;
529
577
  haptic('light');
530
- onViewportChange({ zoom: 1, offsetX: 0, offsetY: 0 });
578
+ autoFit(300);
531
579
  }
532
580
  function handlePinchZoomStart(event) {
533
- if (event.touches.length !== 2)
581
+ if (event.touches.length !== 2 || !canvas || !image)
534
582
  return;
583
+ cancelAnimation();
535
584
  event.preventDefault();
536
585
  const t1 = event.touches[0];
537
586
  const t2 = event.touches[1];
538
587
  initialPinchDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
539
- initialPinchZoom = viewport.zoom;
588
+ initialPinchCropArea = { ...cropArea };
589
+ // Calculate pinch midpoint's relative position within the crop area
590
+ const midX = (t1.clientX + t2.clientX) / 2;
591
+ const midY = (t1.clientY + t2.clientY) / 2;
592
+ const imgCoords = screenToImageCoords(midX, midY, canvas, image, viewport, transform);
593
+ initialPinchFocusRel = {
594
+ x: Math.max(0, Math.min(1, (imgCoords.x - cropArea.x) / cropArea.width)),
595
+ y: Math.max(0, Math.min(1, (imgCoords.y - cropArea.y) / cropArea.height))
596
+ };
540
597
  }
541
598
  function handlePinchZoomMove(event) {
542
- if (event.touches.length !== 2 || initialPinchDistance === 0)
599
+ if (event.touches.length !== 2 || initialPinchDistance === 0 || !initialPinchCropArea)
543
600
  return;
544
- if (!canvas)
601
+ if (!canvas || !image)
545
602
  return;
546
603
  event.preventDefault();
547
604
  const t1 = event.touches[0];
548
605
  const t2 = event.touches[1];
549
606
  const distance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
550
607
  const scaleRatio = distance / initialPinchDistance;
551
- const targetZoom = initialPinchZoom * scaleRatio;
552
- const rect = canvas.getBoundingClientRect();
553
- const focusX = (t1.clientX + t2.clientX) / 2 - rect.left;
554
- const focusY = (t1.clientY + t2.clientY) / 2 - rect.top;
555
- applyCursorFocusedZoom(targetZoom, focusX, focusY);
608
+ // Pinch out (scaleRatio > 1) = zoom in = crop area shrinks
609
+ const inverseRatio = 1 / scaleRatio;
610
+ const minSize = 50;
611
+ let newWidth = initialPinchCropArea.width * inverseRatio;
612
+ let newHeight = initialPinchCropArea.height * inverseRatio;
613
+ // Enforce minimum size
614
+ newWidth = Math.max(minSize, Math.min(image.width, newWidth));
615
+ newHeight = Math.max(minSize, Math.min(image.height, newHeight));
616
+ // If aspect locked, enforce ratio
617
+ if (lockedAspectRatio !== null) {
618
+ if (newWidth / newHeight > lockedAspectRatio) {
619
+ newWidth = newHeight * lockedAspectRatio;
620
+ }
621
+ else {
622
+ newHeight = newWidth / lockedAspectRatio;
623
+ }
624
+ }
625
+ // Re-center around the pinch focus point
626
+ const focusImgX = initialPinchCropArea.x + initialPinchFocusRel.x * initialPinchCropArea.width;
627
+ const focusImgY = initialPinchCropArea.y + initialPinchFocusRel.y * initialPinchCropArea.height;
628
+ let newX = focusImgX - initialPinchFocusRel.x * newWidth;
629
+ let newY = focusImgY - initialPinchFocusRel.y * newHeight;
630
+ // Clamp to image bounds
631
+ newX = Math.max(0, Math.min(image.width - newWidth, newX));
632
+ newY = Math.max(0, Math.min(image.height - newHeight, newY));
633
+ cropArea = { x: newX, y: newY, width: newWidth, height: newHeight };
634
+ // Auto-fit viewport to keep frame centered (instant, no animation during pinch)
635
+ autoFit(0);
556
636
  }
557
637
  function handlePinchZoomEnd() {
558
638
  initialPinchDistance = 0;
639
+ initialPinchCropArea = null;
640
+ // Smooth snap-back after pinch ends
641
+ autoFit(300);
559
642
  }
560
643
  // Unified touch handlers — 1 finger pan (frame or canvas based on start position),
561
644
  // 2 fingers = pinch zoom.
@@ -570,13 +653,10 @@ function handleContainerTouchStartUnified(event) {
570
653
  return;
571
654
  }
572
655
  if (event.touches.length === 1) {
573
- // Child elements (frame border / handles) stopPropagation in their own
574
- // touchstart, so if we reach here the touch is *outside* the frame.
575
- // That maps to canvas pan (move viewport, keep cropArea in place).
656
+ // Outside frame touch same as inside: image scrolls under frame (Lightroom style).
576
657
  event.preventDefault();
577
658
  const touch = event.touches[0];
578
659
  isPanning = true;
579
- panType = 'canvas';
580
660
  isResizing = false;
581
661
  lastPanPosition = { x: touch.clientX, y: touch.clientY };
582
662
  }
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { Download, FileImage, ImageDown } from 'lucide-svelte';
2
+ import Download from '@lucide/svelte/icons/download';
3
+ import FileImage from '@lucide/svelte/icons/file-image';
4
+ import ImageDown from '@lucide/svelte/icons/image-down';
3
5
  import ToolPanel from './ToolPanel.svelte';
4
6
  import Slider from './Slider.svelte';
5
7
  import { haptic } from '../utils/haptics';
@@ -1,7 +1,14 @@
1
1
  <script lang="ts">import '../i18n';
2
2
  import '../styles/tokens.css';
3
3
  import { _ } from 'svelte-i18n';
4
- import { Redo2, Undo2, RotateCcw, ImagePlus, Check, Sparkles, Download, LoaderCircle } from 'lucide-svelte';
4
+ import Redo2 from '@lucide/svelte/icons/redo-2';
5
+ import Undo2 from '@lucide/svelte/icons/undo-2';
6
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
7
+ import ImagePlus from '@lucide/svelte/icons/image-plus';
8
+ import Check from '@lucide/svelte/icons/check';
9
+ import Sparkles from '@lucide/svelte/icons/sparkles';
10
+ import Download from '@lucide/svelte/icons/download';
11
+ import LoaderCircle from '@lucide/svelte/icons/loader-circle';
5
12
  import { createEditorState, loadImageFromFile, loadImageFromUrl, applyImageToState, setMode, applyCrop, applyTransformUpdate, applyAdjustmentsUpdate, applyFilter, setBlurAreas, setStampAreas, setAnnotations, setViewport, resetState, handleZoom as coreHandleZoom, saveToHistory as coreSaveToHistory, handleUndo as coreHandleUndo, handleRedo as coreHandleRedo, canUndo, canRedo, getKeyboardAction, applyKeyboardAction, exportImage, getDroppedFile, getInputFile, handleDragOver } from '../utils/editor-core';
6
13
  import { calculateFitScale, downloadImage } from '../utils/canvas';
7
14
  import { haptic } from '../utils/haptics';
@@ -6,7 +6,9 @@ import { shouldPan } from '../utils/editor-interaction';
6
6
  import { loadImageFromFile, loadImageFromUrl, createBlankImage, createQuickDrawState, applyQuickDrawImage, handleQuickDrawKeyDown, handleQuickDrawKeyUp, handleQuickDrawMouseDown, handleQuickDrawMouseMove, handleQuickDrawMouseUp, handleQuickDrawTouchStart, handleQuickDrawTouchMove, handleQuickDrawTouchEnd, handleQuickDrawWheel, handleQuickDrawViewportChange, handleQuickDrawZoom, quickDrawUndo, exportQuickDraw } from '../utils/editor-core';
7
7
  import { DEFAULT_COLOR_PRESETS, DEFAULT_STROKE_WIDTH } from '../utils/colors';
8
8
  import Canvas from './Canvas.svelte';
9
- import { Pencil, Brush, PaintBucket } from 'lucide-svelte';
9
+ import Pencil from '@lucide/svelte/icons/pencil';
10
+ import Brush from '@lucide/svelte/icons/brush';
11
+ import PaintBucket from '@lucide/svelte/icons/paint-bucket';
10
12
  import { initWasm } from '../wasm/stroke-processor';
11
13
  import { haptic } from '../utils/haptics';
12
14
  let { width = 400, initialImage, colorPresets = DEFAULT_COLOR_PRESETS, initialStrokeWidth, onComplete, onCancel } = $props();
@@ -1,5 +1,8 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { RotateCw, RotateCcw, FlipHorizontal, FlipVertical } from 'lucide-svelte';
2
+ import RotateCw from '@lucide/svelte/icons/rotate-cw';
3
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
4
+ import FlipHorizontal from '@lucide/svelte/icons/flip-horizontal';
5
+ import FlipVertical from '@lucide/svelte/icons/flip-vertical';
3
6
  import ToolPanel from './ToolPanel.svelte';
4
7
  import { haptic } from '../utils/haptics';
5
8
  let { transform, onChange, onClose } = $props();
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">import { onMount } from 'svelte';
2
2
  import { _ } from 'svelte-i18n';
3
- import { X } from 'lucide-svelte';
3
+ import X from '@lucide/svelte/icons/x';
4
4
  import IconButton from './IconButton.svelte';
5
5
  import { haptic } from '../utils/haptics';
6
6
  let { title, onClose, children, actions, collapsible = true, minimal = false } = $props();
@@ -2,7 +2,10 @@
2
2
  import { _ } from 'svelte-i18n';
3
3
  import { STAMP_ASSETS } from '../config/stamps';
4
4
  import { preloadStampImage } from '../utils/canvas';
5
- import { RotateCw, Trash2, X, Sticker } from 'lucide-svelte';
5
+ import RotateCw from '@lucide/svelte/icons/rotate-cw';
6
+ import Trash2 from '@lucide/svelte/icons/trash-2';
7
+ import X from '@lucide/svelte/icons/x';
8
+ import Sticker from '@lucide/svelte/icons/sticker';
6
9
  import FloatingRail from './FloatingRail.svelte';
7
10
  import RailButton from './RailButton.svelte';
8
11
  import Popover from './Popover.svelte';
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">import { _ } from 'svelte-i18n';
2
- import { RotateCcw } from 'lucide-svelte';
2
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
3
3
  import { haptic } from '../utils/haptics';
4
4
  let { toneCurve, onChange } = $props();
5
5
  let activeChannel = $state('rgb');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.4.15",
3
+ "version": "0.5.1",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,8 +24,8 @@
24
24
  "svelte": "^5.55.2"
25
25
  },
26
26
  "dependencies": {
27
+ "@lucide/svelte": "^1.22.0",
27
28
  "@sveltejs/adapter-auto": "^7.0.1",
28
- "lucide-svelte": "^0.548.0",
29
29
  "rbush": "^4.0.1",
30
30
  "runed": "^0.35.1",
31
31
  "svelte-i18n": "^4.0.1",