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:
@@ -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,68 +92,113 @@
|
|||||||
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')) {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function handleEnd(): void {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
isResizing = false;
|
isResizing = false;
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user