From 1518f340703330d94d199b64b68cba835916d921 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 14 May 2026 07:09:47 -0400 Subject: [PATCH] fix: improve manual crop resize and add ArrayBuffer caching - Fix corner resize handles to use scale factor instead of direct delta - Store initial region state on resize start to prevent compounding errors - Add ArrayBuffer caching in worker pool to avoid re-reading files Co-Authored-By: Claude Sonnet 4.5 --- src/lib/components/CropOverlay.svelte | 199 ++++++++++++++++---------- src/lib/processing/workerPool.ts | 21 ++- 2 files changed, 143 insertions(+), 77 deletions(-) diff --git a/src/lib/components/CropOverlay.svelte b/src/lib/components/CropOverlay.svelte index d06f4e2..fad58ce 100644 --- a/src/lib/components/CropOverlay.svelte +++ b/src/lib/components/CropOverlay.svelte @@ -15,12 +15,14 @@ let isDragging = $state(false); let isResizing = $state(false); let resizeHandle = $state(null); - let dragStart = $state({ x: 0, y: 0, regionX: 0, regionY: 0 }); + + // Store initial state when drag/resize starts + let startMouseX = $state(0); + let startMouseY = $state(0); + let startRegion = $state({ x: 0, y: 0, width: 0, height: 0 }); // Initialize region if not set - const currentRegion = $derived(() => { - if (region) return region; - // Default: centered crop that fills as much as possible while maintaining aspect ratio + function getDefaultRegion(): CropRegion { const imageAspect = imageWidth / imageHeight; let width: number, height: number; @@ -38,17 +40,16 @@ width, height }; - }); + } + + const currentRegion = $derived(region ?? getDefaultRegion()); // Convert image coordinates to percentage for display - const regionStyle = $derived(() => { - const r = currentRegion(); - return { - left: `${(r.x / imageWidth) * 100}%`, - top: `${(r.y / imageHeight) * 100}%`, - width: `${(r.width / imageWidth) * 100}%`, - height: `${(r.height / imageHeight) * 100}%` - }; + const regionStyle = $derived({ + left: `${(currentRegion.x / imageWidth) * 100}%`, + top: `${(currentRegion.y / imageHeight) * 100}%`, + width: `${(currentRegion.width / imageWidth) * 100}%`, + height: `${(currentRegion.height / imageHeight) * 100}%` }); function getMousePosition(e: MouseEvent | TouchEvent): { x: number; y: number } { @@ -68,8 +69,9 @@ e.stopPropagation(); isDragging = true; const pos = getMousePosition(e); - const r = currentRegion(); - dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y }; + startMouseX = pos.x; + startMouseY = pos.y; + startRegion = { ...currentRegion }; addGlobalListeners(); } @@ -79,8 +81,9 @@ isResizing = true; resizeHandle = handle; const pos = getMousePosition(e); - const r = currentRegion(); - dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y }; + startMouseX = pos.x; + startMouseY = pos.y; + startRegion = { ...currentRegion }; addGlobalListeners(); } @@ -89,68 +92,113 @@ e.preventDefault(); const pos = getMousePosition(e); - const dx = pos.x - dragStart.x; - const dy = pos.y - dragStart.y; - const r = currentRegion(); + const dx = pos.x - startMouseX; + const dy = pos.y - startMouseY; if (isDragging) { // Move the crop region - let newX = dragStart.regionX + dx; - let newY = dragStart.regionY + dy; + let newX = startRegion.x + dx; + let newY = startRegion.y + dy; // Clamp to image bounds - newX = Math.max(0, Math.min(imageWidth - r.width, newX)); - newY = Math.max(0, Math.min(imageHeight - r.height, newY)); + newX = Math.max(0, Math.min(imageWidth - startRegion.width, newX)); + newY = Math.max(0, Math.min(imageHeight - startRegion.height, newY)); - onRegionChange({ ...r, x: newX, y: newY }); + onRegionChange({ ...startRegion, x: newX, y: newY }); } else if (isResizing && resizeHandle) { - // Resize while maintaining aspect ratio - let newRegion = { ...r }; - - if (resizeHandle.includes('e')) { - newRegion.width = Math.max(50, r.width + dx); - newRegion.height = newRegion.width / targetAspect; - } else if (resizeHandle.includes('w')) { - const newWidth = Math.max(50, r.width - dx); - newRegion.x = r.x + (r.width - newWidth); - newRegion.width = newWidth; - newRegion.height = newWidth / targetAspect; - } - - if (resizeHandle.includes('s')) { - newRegion.height = Math.max(50, r.height + dy); - newRegion.width = newRegion.height * targetAspect; - } else if (resizeHandle.includes('n')) { - const newHeight = Math.max(50, r.height - dy); - newRegion.y = r.y + (r.height - newHeight); - newRegion.height = newHeight; - newRegion.width = newHeight * targetAspect; - } - - // Clamp to image bounds - if (newRegion.x < 0) { - newRegion.width += newRegion.x; - newRegion.height = newRegion.width / targetAspect; - newRegion.x = 0; - } - if (newRegion.y < 0) { - newRegion.height += newRegion.y; - newRegion.width = newRegion.height * targetAspect; - newRegion.y = 0; - } - if (newRegion.x + newRegion.width > imageWidth) { - newRegion.width = imageWidth - newRegion.x; - newRegion.height = newRegion.width / targetAspect; - } - if (newRegion.y + newRegion.height > imageHeight) { - newRegion.height = imageHeight - newRegion.y; - newRegion.width = newRegion.height * targetAspect; - } - - onRegionChange(newRegion); + resizeRegion(dx, dy); } } + function resizeRegion(dx: number, dy: number): void { + if (!resizeHandle) return; + + let newX = startRegion.x; + let newY = startRegion.y; + let newWidth = startRegion.width; + let newHeight = startRegion.height; + + // For corners, use the larger movement to determine scale + // This keeps aspect ratio and feels more natural + const isCorner = resizeHandle.length === 2; + + if (isCorner) { + // Determine scale based on diagonal movement + let scale = 1; + + if (resizeHandle === 'se') { + // Southeast: positive dx/dy = larger + const dw = dx; + const dh = dy; + // Use the dimension that changed more (proportionally) + const scaleByWidth = (startRegion.width + dw) / startRegion.width; + const scaleByHeight = (startRegion.height + dh) / startRegion.height; + scale = Math.max(0.1, (scaleByWidth + scaleByHeight) / 2); + } else if (resizeHandle === 'sw') { + // Southwest: negative dx = larger, positive dy = larger + const dw = -dx; + const dh = dy; + const scaleByWidth = (startRegion.width + dw) / startRegion.width; + const scaleByHeight = (startRegion.height + dh) / startRegion.height; + scale = Math.max(0.1, (scaleByWidth + scaleByHeight) / 2); + } else if (resizeHandle === 'ne') { + // Northeast: positive dx = larger, negative dy = larger + const dw = dx; + const dh = -dy; + const scaleByWidth = (startRegion.width + dw) / startRegion.width; + const scaleByHeight = (startRegion.height + dh) / startRegion.height; + scale = Math.max(0.1, (scaleByWidth + scaleByHeight) / 2); + } else if (resizeHandle === 'nw') { + // Northwest: negative dx/dy = larger + const dw = -dx; + const dh = -dy; + const scaleByWidth = (startRegion.width + dw) / startRegion.width; + const scaleByHeight = (startRegion.height + dh) / startRegion.height; + scale = Math.max(0.1, (scaleByWidth + scaleByHeight) / 2); + } + + newWidth = startRegion.width * scale; + newHeight = newWidth / targetAspect; + + // Adjust position based on which corner + if (resizeHandle.includes('w')) { + newX = startRegion.x + startRegion.width - newWidth; + } + if (resizeHandle.includes('n')) { + newY = startRegion.y + startRegion.height - newHeight; + } + } + + // Enforce minimum size + const minSize = 50; + if (newWidth < minSize) { + newWidth = minSize; + newHeight = newWidth / targetAspect; + } + if (newHeight < minSize) { + newHeight = minSize; + newWidth = newHeight * targetAspect; + } + + // Clamp to image bounds + if (newX < 0) { + newX = 0; + } + if (newY < 0) { + newY = 0; + } + if (newX + newWidth > imageWidth) { + newWidth = imageWidth - newX; + newHeight = newWidth / targetAspect; + } + if (newY + newHeight > imageHeight) { + newHeight = imageHeight - newY; + newWidth = newHeight * targetAspect; + } + + onRegionChange({ x: newX, y: newY, width: newWidth, height: newHeight }); + } + function handleEnd(): void { isDragging = false; isResizing = false; @@ -179,22 +227,22 @@
@@ -202,7 +250,7 @@
@@ -214,8 +262,7 @@
- - +
| null = null; private poolSize: number; + private arrayBufferCache: Map = new Map(); constructor(poolSize = navigator.hardwareConcurrency || 4) { this.poolSize = Math.max(2, Math.min(poolSize, 6)); @@ -90,7 +91,16 @@ class PhotonWorkerPool { await this.init(); const id = crypto.randomUUID(); - const imageData = await file.arrayBuffer(); + + // Cache ArrayBuffer to avoid re-reading file + let cachedBuffer = this.arrayBufferCache.get(file); + if (!cachedBuffer) { + cachedBuffer = await file.arrayBuffer(); + this.arrayBufferCache.set(file, cachedBuffer); + } + + // Clone the buffer since transfer detaches it + const imageData = cachedBuffer.slice(0); // Strip Svelte proxies before sending to worker const plainDevice = JSON.parse(JSON.stringify(device)); @@ -114,12 +124,21 @@ class PhotonWorkerPool { }); } + clearCache(file?: File): void { + if (file) { + this.arrayBufferCache.delete(file); + } else { + this.arrayBufferCache.clear(); + } + } + terminate(): void { for (const worker of this.workers) { worker.terminate(); } this.workers = []; this.availableWorkers = []; + this.arrayBufferCache.clear(); this.initialized = false; this.initPromise = null; }