- 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>
144 lines
4.0 KiB
Svelte
144 lines
4.0 KiB
Svelte
<script lang="ts">
|
||
import { Loader2 } from 'lucide-svelte';
|
||
import type { ImageEntry, Device, PipelineConfig, CropRegion } from '$lib/types';
|
||
import { processImageWithPipeline } from '$lib/processing/pipeline';
|
||
import CropOverlay from './CropOverlay.svelte';
|
||
|
||
interface Props {
|
||
image: ImageEntry;
|
||
device: Device;
|
||
config: PipelineConfig;
|
||
onPreviewUpdate: (dataUrl: string) => void;
|
||
onProcessingChange: (processing: boolean) => void;
|
||
onConfigChange?: (config: PipelineConfig) => void;
|
||
}
|
||
|
||
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 (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) {
|
||
clearTimeout(debounceTimer);
|
||
}
|
||
|
||
// Debounce processing
|
||
debounceTimer = setTimeout(() => {
|
||
processPreview();
|
||
}, 150);
|
||
|
||
return () => {
|
||
if (debounceTimer) {
|
||
clearTimeout(debounceTimer);
|
||
}
|
||
};
|
||
});
|
||
|
||
async function processPreview(): Promise<void> {
|
||
if (!image.file) return;
|
||
|
||
isProcessing = true;
|
||
onProcessingChange(true);
|
||
|
||
try {
|
||
const { dataUrl } = await processImageWithPipeline(image.file, device, config);
|
||
previewDataUrl = dataUrl;
|
||
onPreviewUpdate(dataUrl);
|
||
} catch (err) {
|
||
console.error('Preview processing failed:', err);
|
||
} finally {
|
||
isProcessing = false;
|
||
onProcessingChange(false);
|
||
}
|
||
}
|
||
|
||
// Initialize with current processed image
|
||
$effect(() => {
|
||
if (image.processedDataUrl && !previewDataUrl) {
|
||
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 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"
|
||
class="absolute inset-0 w-full h-full object-contain"
|
||
/>
|
||
{:else if image.originalDataUrl}
|
||
<img
|
||
src={image.originalDataUrl}
|
||
alt="Original"
|
||
class="absolute inset-0 w-full h-full object-contain opacity-50"
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Processing overlay -->
|
||
{#if isProcessing}
|
||
<div class="absolute inset-0 flex items-center justify-center bg-white/70">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<Loader2 class="w-8 h-8 text-accent animate-spin" />
|
||
<span class="text-sm text-grey-500">Processing...</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Image info -->
|
||
<div class="text-center">
|
||
<p class="text-sm font-medium text-ink">{image.filename}</p>
|
||
{#if image.originalDimensions}
|
||
<p class="text-xs text-grey-400 font-mono mt-1">
|
||
{image.originalDimensions.width} × {image.originalDimensions.height}
|
||
→
|
||
{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>
|
||
</div>
|