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:
2026-05-14 06:14:47 -04:00
parent ef1ba9f97e
commit 62a218f0cb
5 changed files with 324 additions and 26 deletions

View 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>

View File

@@ -167,6 +167,7 @@
config={localConfig}
onPreviewUpdate={handlePreviewUpdate}
onProcessingChange={handleProcessingChange}
onConfigChange={handleConfigChange}
/>
</div>

View File

@@ -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>

View File

@@ -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 }[] = [

View File

@@ -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) {