feat: add manual crop mode
- CropOverlay component with draggable/resizable region - Maintains target device aspect ratio - Rule of thirds grid overlay - Dark mask outside crop area - Updates ImagePreview to show overlay in manual mode - Updates photonWorker to handle manual crop regions - Also fixes top/bottom crop modes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
244
src/lib/components/CropOverlay.svelte
Normal file
244
src/lib/components/CropOverlay.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import type { CropRegion } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
targetAspect: number;
|
||||
region: CropRegion | undefined;
|
||||
onRegionChange: (region: CropRegion) => void;
|
||||
}
|
||||
|
||||
let { imageWidth, imageHeight, targetAspect, region, onRegionChange }: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement;
|
||||
let isDragging = $state(false);
|
||||
let isResizing = $state(false);
|
||||
let resizeHandle = $state<string | null>(null);
|
||||
let dragStart = $state({ x: 0, y: 0, regionX: 0, regionY: 0 });
|
||||
|
||||
// Initialize region if not set
|
||||
const currentRegion = $derived<CropRegion>(() => {
|
||||
if (region) return region;
|
||||
// Default: centered crop that fills as much as possible while maintaining aspect ratio
|
||||
const imageAspect = imageWidth / imageHeight;
|
||||
let width: number, height: number;
|
||||
|
||||
if (imageAspect > targetAspect) {
|
||||
height = imageHeight;
|
||||
width = height * targetAspect;
|
||||
} else {
|
||||
width = imageWidth;
|
||||
height = width / targetAspect;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (imageWidth - width) / 2,
|
||||
y: (imageHeight - height) / 2,
|
||||
width,
|
||||
height
|
||||
};
|
||||
});
|
||||
|
||||
// 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}%`
|
||||
};
|
||||
});
|
||||
|
||||
function getMousePosition(e: MouseEvent | TouchEvent): { x: number; y: number } {
|
||||
if (!containerRef) return { x: 0, y: 0 };
|
||||
const rect = containerRef.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
// Convert to image coordinates
|
||||
const x = ((clientX - rect.left) / rect.width) * imageWidth;
|
||||
const y = ((clientY - rect.top) / rect.height) * imageHeight;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragging = true;
|
||||
const pos = getMousePosition(e);
|
||||
const r = currentRegion();
|
||||
dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y };
|
||||
addGlobalListeners();
|
||||
}
|
||||
|
||||
function handleResizeStart(e: MouseEvent | TouchEvent, handle: string): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
resizeHandle = handle;
|
||||
const pos = getMousePosition(e);
|
||||
const r = currentRegion();
|
||||
dragStart = { x: pos.x, y: pos.y, regionX: r.x, regionY: r.y };
|
||||
addGlobalListeners();
|
||||
}
|
||||
|
||||
function handleMove(e: MouseEvent | TouchEvent): void {
|
||||
if (!isDragging && !isResizing) return;
|
||||
e.preventDefault();
|
||||
|
||||
const pos = getMousePosition(e);
|
||||
const dx = pos.x - dragStart.x;
|
||||
const dy = pos.y - dragStart.y;
|
||||
const r = currentRegion();
|
||||
|
||||
if (isDragging) {
|
||||
// Move the crop region
|
||||
let newX = dragStart.regionX + dx;
|
||||
let newY = dragStart.regionY + 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));
|
||||
|
||||
onRegionChange({ ...r, 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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd(): void {
|
||||
isDragging = false;
|
||||
isResizing = false;
|
||||
resizeHandle = null;
|
||||
removeGlobalListeners();
|
||||
}
|
||||
|
||||
function addGlobalListeners(): void {
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
window.addEventListener('touchmove', handleMove);
|
||||
window.addEventListener('touchend', handleEnd);
|
||||
}
|
||||
|
||||
function removeGlobalListeners(): void {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleEnd);
|
||||
window.removeEventListener('touchmove', handleMove);
|
||||
window.removeEventListener('touchend', handleEnd);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={containerRef} class="absolute inset-0">
|
||||
<!-- Darkened overlay outside crop area -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<!-- Top -->
|
||||
<div
|
||||
class="absolute left-0 right-0 top-0 bg-black/50"
|
||||
style="height: {regionStyle().top}"
|
||||
></div>
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="absolute left-0 right-0 bottom-0 bg-black/50"
|
||||
style="height: calc(100% - {regionStyle().top} - {regionStyle().height})"
|
||||
></div>
|
||||
<!-- Left -->
|
||||
<div
|
||||
class="absolute left-0 bg-black/50"
|
||||
style="top: {regionStyle().top}; width: {regionStyle().left}; height: {regionStyle().height}"
|
||||
></div>
|
||||
<!-- Right -->
|
||||
<div
|
||||
class="absolute right-0 bg-black/50"
|
||||
style="top: {regionStyle().top}; width: calc(100% - {regionStyle().left} - {regionStyle().width}); height: {regionStyle().height}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Crop region -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute border-2 border-white shadow-lg cursor-move"
|
||||
style="left: {regionStyle().left}; top: {regionStyle().top}; width: {regionStyle().width}; height: {regionStyle().height}"
|
||||
onmousedown={handleDragStart}
|
||||
ontouchstart={handleDragStart}
|
||||
>
|
||||
<!-- Grid lines -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute left-1/3 top-0 bottom-0 w-px bg-white/40"></div>
|
||||
<div class="absolute left-2/3 top-0 bottom-0 w-px bg-white/40"></div>
|
||||
<div class="absolute top-1/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>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<!-- Corners -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute -top-2 -left-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-nw-resize"
|
||||
onmousedown={(e) => handleResizeStart(e, 'nw')}
|
||||
ontouchstart={(e) => handleResizeStart(e, 'nw')}
|
||||
></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute -top-2 -right-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-ne-resize"
|
||||
onmousedown={(e) => handleResizeStart(e, 'ne')}
|
||||
ontouchstart={(e) => handleResizeStart(e, 'ne')}
|
||||
></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute -bottom-2 -left-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-sw-resize"
|
||||
onmousedown={(e) => handleResizeStart(e, 'sw')}
|
||||
ontouchstart={(e) => handleResizeStart(e, 'sw')}
|
||||
></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute -bottom-2 -right-2 w-4 h-4 bg-white border border-grey-400 rounded-sm cursor-se-resize"
|
||||
onmousedown={(e) => handleResizeStart(e, 'se')}
|
||||
ontouchstart={(e) => handleResizeStart(e, 'se')}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,6 +167,7 @@
|
||||
config={localConfig}
|
||||
onPreviewUpdate={handlePreviewUpdate}
|
||||
onProcessingChange={handleProcessingChange}
|
||||
onConfigChange={handleConfigChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import type { ImageEntry, Device, PipelineConfig } from '$lib/types';
|
||||
import type { ImageEntry, Device, PipelineConfig, CropRegion } from '$lib/types';
|
||||
import { processImageWithPipeline } from '$lib/processing/pipeline';
|
||||
import CropOverlay from './CropOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
image: ImageEntry;
|
||||
@@ -9,18 +10,27 @@
|
||||
config: PipelineConfig;
|
||||
onPreviewUpdate: (dataUrl: string) => void;
|
||||
onProcessingChange: (processing: boolean) => void;
|
||||
onConfigChange?: (config: PipelineConfig) => void;
|
||||
}
|
||||
|
||||
let { image, device, config, onPreviewUpdate, onProcessingChange }: Props = $props();
|
||||
let { image, device, config, onPreviewUpdate, onProcessingChange, onConfigChange }: Props = $props();
|
||||
|
||||
let previewDataUrl = $state<string | null>(null);
|
||||
let isProcessing = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const isManualCrop = $derived(config.crop.enabled && config.crop.mode === 'manual');
|
||||
const targetAspect = $derived(device.width / device.height);
|
||||
|
||||
// Track config changes and trigger debounced processing
|
||||
$effect(() => {
|
||||
// Access config to create dependency
|
||||
const _ = JSON.stringify(config);
|
||||
// Access config to create dependency (but skip processing in manual crop mode without region)
|
||||
const configStr = JSON.stringify(config);
|
||||
|
||||
// Don't process if in manual crop mode - wait for region to be set
|
||||
if (isManualCrop && !config.crop.region) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimer) {
|
||||
@@ -63,12 +73,35 @@
|
||||
previewDataUrl = image.processedDataUrl;
|
||||
}
|
||||
});
|
||||
|
||||
function handleCropRegionChange(region: CropRegion): void {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...config,
|
||||
crop: { ...config.crop, region }
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Preview container -->
|
||||
<div class="relative w-full max-w-md aspect-[3/4] bg-grey-100 rounded-lg overflow-hidden shadow-inner">
|
||||
{#if previewDataUrl}
|
||||
{#if isManualCrop && image.originalDataUrl && image.originalDimensions}
|
||||
<!-- Show original image with crop overlay in manual mode -->
|
||||
<img
|
||||
src={image.originalDataUrl}
|
||||
alt="Original"
|
||||
class="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<CropOverlay
|
||||
imageWidth={image.originalDimensions.width}
|
||||
imageHeight={image.originalDimensions.height}
|
||||
{targetAspect}
|
||||
region={config.crop.region}
|
||||
onRegionChange={handleCropRegionChange}
|
||||
/>
|
||||
{:else if previewDataUrl}
|
||||
<img
|
||||
src={previewDataUrl}
|
||||
alt="Preview"
|
||||
@@ -103,11 +136,8 @@
|
||||
{device.width} × {device.height}
|
||||
</p>
|
||||
{/if}
|
||||
{#if isManualCrop}
|
||||
<p class="text-xs text-accent mt-1">Drag to position, drag corners to resize</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Original vs Preview toggle (future enhancement) -->
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<button class="text-sm text-grey-500 hover:text-ink">Original</button>
|
||||
<button class="text-sm text-accent font-medium">Preview</button>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
{ value: 'manual', label: 'Manual (coming soon)' }
|
||||
{ value: 'manual', label: 'Manual' }
|
||||
];
|
||||
|
||||
const resizeModes: { value: ResizeMode; label: string }[] = [
|
||||
|
||||
@@ -199,24 +199,47 @@ async function processImage(
|
||||
const width = img.get_width();
|
||||
const height = img.get_height();
|
||||
const targetAspect = device.width / device.height;
|
||||
const currentAspect = width / height;
|
||||
|
||||
let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
|
||||
|
||||
if (currentAspect > targetAspect) {
|
||||
const newWidth = Math.max(1, Math.floor(height * targetAspect));
|
||||
const xOffset = Math.floor((width - newWidth) / 2);
|
||||
cropX1 = Math.max(0, xOffset);
|
||||
cropY1 = 0;
|
||||
cropX2 = Math.min(width, cropX1 + newWidth);
|
||||
cropY2 = height;
|
||||
if (config.crop.mode === 'manual' && config.crop.region) {
|
||||
// Manual crop: use the provided region
|
||||
const region = config.crop.region;
|
||||
cropX1 = Math.max(0, Math.round(region.x));
|
||||
cropY1 = Math.max(0, Math.round(region.y));
|
||||
cropX2 = Math.min(width, Math.round(region.x + region.width));
|
||||
cropY2 = Math.min(height, Math.round(region.y + region.height));
|
||||
} else {
|
||||
const newHeight = Math.max(1, Math.floor(width / targetAspect));
|
||||
const yOffset = Math.floor((height - newHeight) / 2);
|
||||
cropX1 = 0;
|
||||
cropY1 = Math.max(0, yOffset);
|
||||
cropX2 = width;
|
||||
cropY2 = Math.min(height, cropY1 + newHeight);
|
||||
// Auto crop modes: center, top, bottom
|
||||
const currentAspect = width / height;
|
||||
|
||||
if (currentAspect > targetAspect) {
|
||||
const newWidth = Math.max(1, Math.floor(height * targetAspect));
|
||||
let xOffset: number;
|
||||
if (config.crop.mode === 'top' || config.crop.mode === 'bottom') {
|
||||
xOffset = Math.floor((width - newWidth) / 2);
|
||||
} else {
|
||||
xOffset = Math.floor((width - newWidth) / 2);
|
||||
}
|
||||
cropX1 = Math.max(0, xOffset);
|
||||
cropY1 = 0;
|
||||
cropX2 = Math.min(width, cropX1 + newWidth);
|
||||
cropY2 = height;
|
||||
} else {
|
||||
const newHeight = Math.max(1, Math.floor(width / targetAspect));
|
||||
let yOffset: number;
|
||||
if (config.crop.mode === 'top') {
|
||||
yOffset = 0;
|
||||
} else if (config.crop.mode === 'bottom') {
|
||||
yOffset = height - newHeight;
|
||||
} else {
|
||||
yOffset = Math.floor((height - newHeight) / 2);
|
||||
}
|
||||
cropX1 = 0;
|
||||
cropY1 = Math.max(0, yOffset);
|
||||
cropX2 = width;
|
||||
cropY2 = Math.min(height, cropY1 + newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (cropX2 > cropX1 && cropY2 > cropY1) {
|
||||
|
||||
Reference in New Issue
Block a user