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 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 07:09:47 -04:00
parent 62a218f0cb
commit 1518f34070
2 changed files with 143 additions and 77 deletions

View File

@@ -15,12 +15,14 @@
let isDragging = $state(false); let isDragging = $state(false);
let isResizing = $state(false); let isResizing = $state(false);
let resizeHandle = $state<string | null>(null); let resizeHandle = $state<string | null>(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<CropRegion>({ x: 0, y: 0, width: 0, height: 0 });
// Initialize region if not set // Initialize region if not set
const currentRegion = $derived<CropRegion>(() => { function getDefaultRegion(): CropRegion {
if (region) return region;
// Default: centered crop that fills as much as possible while maintaining aspect ratio
const imageAspect = imageWidth / imageHeight; const imageAspect = imageWidth / imageHeight;
let width: number, height: number; let width: number, height: number;
@@ -38,17 +40,16 @@
width, width,
height height
}; };
}); }
const currentRegion = $derived<CropRegion>(region ?? getDefaultRegion());
// Convert image coordinates to percentage for display // Convert image coordinates to percentage for display
const regionStyle = $derived(() => { const regionStyle = $derived({
const r = currentRegion(); left: `${(currentRegion.x / imageWidth) * 100}%`,
return { top: `${(currentRegion.y / imageHeight) * 100}%`,
left: `${(r.x / imageWidth) * 100}%`, width: `${(currentRegion.width / imageWidth) * 100}%`,
top: `${(r.y / imageHeight) * 100}%`, height: `${(currentRegion.height / imageHeight) * 100}%`
width: `${(r.width / imageWidth) * 100}%`,
height: `${(r.height / imageHeight) * 100}%`
};
}); });
function getMousePosition(e: MouseEvent | TouchEvent): { x: number; y: number } { function getMousePosition(e: MouseEvent | TouchEvent): { x: number; y: number } {
@@ -68,8 +69,9 @@
e.stopPropagation(); e.stopPropagation();
isDragging = true; isDragging = true;
const pos = getMousePosition(e); const pos = getMousePosition(e);
const r = currentRegion(); startMouseX = pos.x;
dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y }; startMouseY = pos.y;
startRegion = { ...currentRegion };
addGlobalListeners(); addGlobalListeners();
} }
@@ -79,8 +81,9 @@
isResizing = true; isResizing = true;
resizeHandle = handle; resizeHandle = handle;
const pos = getMousePosition(e); const pos = getMousePosition(e);
const r = currentRegion(); startMouseX = pos.x;
dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y }; startMouseY = pos.y;
startRegion = { ...currentRegion };
addGlobalListeners(); addGlobalListeners();
} }
@@ -89,66 +92,111 @@
e.preventDefault(); e.preventDefault();
const pos = getMousePosition(e); const pos = getMousePosition(e);
const dx = pos.x - dragStart.x; const dx = pos.x - startMouseX;
const dy = pos.y - dragStart.y; const dy = pos.y - startMouseY;
const r = currentRegion();
if (isDragging) { if (isDragging) {
// Move the crop region // Move the crop region
let newX = dragStart.regionX + dx; let newX = startRegion.x + dx;
let newY = dragStart.regionY + dy; let newY = startRegion.y + dy;
// Clamp to image bounds // Clamp to image bounds
newX = Math.max(0, Math.min(imageWidth - r.width, newX)); newX = Math.max(0, Math.min(imageWidth - startRegion.width, newX));
newY = Math.max(0, Math.min(imageHeight - r.height, newY)); 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) { } else if (isResizing && resizeHandle) {
// Resize while maintaining aspect ratio resizeRegion(dx, dy);
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')) { function resizeRegion(dx: number, dy: number): void {
newRegion.height = Math.max(50, r.height + dy); if (!resizeHandle) return;
newRegion.width = newRegion.height * targetAspect;
} else if (resizeHandle.includes('n')) { let newX = startRegion.x;
const newHeight = Math.max(50, r.height - dy); let newY = startRegion.y;
newRegion.y = r.y + (r.height - newHeight); let newWidth = startRegion.width;
newRegion.height = newHeight; let newHeight = startRegion.height;
newRegion.width = newHeight * targetAspect;
// 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 // Clamp to image bounds
if (newRegion.x < 0) { if (newX < 0) {
newRegion.width += newRegion.x; newX = 0;
newRegion.height = newRegion.width / targetAspect;
newRegion.x = 0;
} }
if (newRegion.y < 0) { if (newY < 0) {
newRegion.height += newRegion.y; newY = 0;
newRegion.width = newRegion.height * targetAspect;
newRegion.y = 0;
} }
if (newRegion.x + newRegion.width > imageWidth) { if (newX + newWidth > imageWidth) {
newRegion.width = imageWidth - newRegion.x; newWidth = imageWidth - newX;
newRegion.height = newRegion.width / targetAspect; newHeight = newWidth / targetAspect;
} }
if (newRegion.y + newRegion.height > imageHeight) { if (newY + newHeight > imageHeight) {
newRegion.height = imageHeight - newRegion.y; newHeight = imageHeight - newY;
newRegion.width = newRegion.height * targetAspect; newWidth = newHeight * targetAspect;
} }
onRegionChange(newRegion); onRegionChange({ x: newX, y: newY, width: newWidth, height: newHeight });
}
} }
function handleEnd(): void { function handleEnd(): void {
@@ -179,22 +227,22 @@
<!-- Top --> <!-- Top -->
<div <div
class="absolute left-0 right-0 top-0 bg-black/50" class="absolute left-0 right-0 top-0 bg-black/50"
style="height: {regionStyle().top}" style="height: {regionStyle.top}"
></div> ></div>
<!-- Bottom --> <!-- Bottom -->
<div <div
class="absolute left-0 right-0 bottom-0 bg-black/50" class="absolute left-0 right-0 bottom-0 bg-black/50"
style="height: calc(100% - {regionStyle().top} - {regionStyle().height})" style="height: calc(100% - {regionStyle.top} - {regionStyle.height})"
></div> ></div>
<!-- Left --> <!-- Left -->
<div <div
class="absolute left-0 bg-black/50" class="absolute left-0 bg-black/50"
style="top: {regionStyle().top}; width: {regionStyle().left}; height: {regionStyle().height}" style="top: {regionStyle.top}; width: {regionStyle.left}; height: {regionStyle.height}"
></div> ></div>
<!-- Right --> <!-- Right -->
<div <div
class="absolute right-0 bg-black/50" class="absolute right-0 bg-black/50"
style="top: {regionStyle().top}; width: calc(100% - {regionStyle().left} - {regionStyle().width}); height: {regionStyle().height}" style="top: {regionStyle.top}; width: calc(100% - {regionStyle.left} - {regionStyle.width}); height: {regionStyle.height}"
></div> ></div>
</div> </div>
@@ -202,7 +250,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="absolute border-2 border-white shadow-lg cursor-move" class="absolute border-2 border-white shadow-lg cursor-move"
style="left: {regionStyle().left}; top: {regionStyle().top}; width: {regionStyle().width}; height: {regionStyle().height}" style="left: {regionStyle.left}; top: {regionStyle.top}; width: {regionStyle.width}; height: {regionStyle.height}"
onmousedown={handleDragStart} onmousedown={handleDragStart}
ontouchstart={handleDragStart} ontouchstart={handleDragStart}
> >
@@ -214,8 +262,7 @@
<div class="absolute top-2/3 left-0 right-0 h-px bg-white/40"></div> <div class="absolute top-2/3 left-0 right-0 h-px bg-white/40"></div>
</div> </div>
<!-- Resize handles --> <!-- Resize handles - Corners -->
<!-- Corners -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="absolute -top-2 -left-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-nw-resize" class="absolute -top-2 -left-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-nw-resize"

View File

@@ -21,6 +21,7 @@ class PhotonWorkerPool {
private initialized = false; private initialized = false;
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private poolSize: number; private poolSize: number;
private arrayBufferCache: Map<File, ArrayBuffer> = new Map();
constructor(poolSize = navigator.hardwareConcurrency || 4) { constructor(poolSize = navigator.hardwareConcurrency || 4) {
this.poolSize = Math.max(2, Math.min(poolSize, 6)); this.poolSize = Math.max(2, Math.min(poolSize, 6));
@@ -90,7 +91,16 @@ class PhotonWorkerPool {
await this.init(); await this.init();
const id = crypto.randomUUID(); 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 // Strip Svelte proxies before sending to worker
const plainDevice = JSON.parse(JSON.stringify(device)); 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 { terminate(): void {
for (const worker of this.workers) { for (const worker of this.workers) {
worker.terminate(); worker.terminate();
} }
this.workers = []; this.workers = [];
this.availableWorkers = []; this.availableWorkers = [];
this.arrayBufferCache.clear();
this.initialized = false; this.initialized = false;
this.initPromise = null; this.initPromise = null;
} }