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

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