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.
- package/dist/components/AdjustTool.svelte +14 -1
- package/dist/components/AnnotationTool.svelte +13 -1
- package/dist/components/BlurTool.svelte +4 -1
- package/dist/components/BottomDock.svelte +7 -1
- package/dist/components/CropTool.svelte +164 -84
- package/dist/components/ExportTool.svelte +3 -1
- package/dist/components/ImageEditor.svelte +8 -1
- package/dist/components/QuickDrawEditor.svelte +3 -1
- package/dist/components/RotateTool.svelte +4 -1
- package/dist/components/Sheet.svelte +1 -1
- package/dist/components/StampTool.svelte +4 -1
- package/dist/components/ToneCurveTool.svelte +1 -1
- package/package.json +2 -2
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
<script lang="ts">import { _ } from 'svelte-i18n';
|
|
2
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
118
|
+
// Touch pinch zoom state — pinch changes cropArea (not viewport.zoom) for crop-contextual zoom.
|
|
109
119
|
let initialPinchDistance = $state(0);
|
|
110
|
-
let
|
|
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
|
-
//
|
|
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
|
-
//
|
|
241
|
-
//
|
|
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 —
|
|
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 —
|
|
436
|
-
//
|
|
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
|
-
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
const
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|