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}
|
config={localConfig}
|
||||||
onPreviewUpdate={handlePreviewUpdate}
|
onPreviewUpdate={handlePreviewUpdate}
|
||||||
onProcessingChange={handleProcessingChange}
|
onProcessingChange={handleProcessingChange}
|
||||||
|
onConfigChange={handleConfigChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Loader2 } from 'lucide-svelte';
|
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 { processImageWithPipeline } from '$lib/processing/pipeline';
|
||||||
|
import CropOverlay from './CropOverlay.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
image: ImageEntry;
|
image: ImageEntry;
|
||||||
@@ -9,18 +10,27 @@
|
|||||||
config: PipelineConfig;
|
config: PipelineConfig;
|
||||||
onPreviewUpdate: (dataUrl: string) => void;
|
onPreviewUpdate: (dataUrl: string) => void;
|
||||||
onProcessingChange: (processing: boolean) => 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 previewDataUrl = $state<string | null>(null);
|
||||||
let isProcessing = $state(false);
|
let isProcessing = $state(false);
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
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
|
// Track config changes and trigger debounced processing
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Access config to create dependency
|
// Access config to create dependency (but skip processing in manual crop mode without region)
|
||||||
const _ = JSON.stringify(config);
|
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
|
// Clear existing timer
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
@@ -63,12 +73,35 @@
|
|||||||
previewDataUrl = image.processedDataUrl;
|
previewDataUrl = image.processedDataUrl;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleCropRegionChange(region: CropRegion): void {
|
||||||
|
if (onConfigChange) {
|
||||||
|
onConfigChange({
|
||||||
|
...config,
|
||||||
|
crop: { ...config.crop, region }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<!-- Preview container -->
|
<!-- Preview container -->
|
||||||
<div class="relative w-full max-w-md aspect-[3/4] bg-grey-100 rounded-lg overflow-hidden shadow-inner">
|
<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
|
<img
|
||||||
src={previewDataUrl}
|
src={previewDataUrl}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
@@ -103,11 +136,8 @@
|
|||||||
{device.width} × {device.height}
|
{device.width} × {device.height}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isManualCrop}
|
||||||
|
<p class="text-xs text-accent mt-1">Drag to position, drag corners to resize</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
{ value: 'center', label: 'Center' },
|
{ value: 'center', label: 'Center' },
|
||||||
{ value: 'top', label: 'Top' },
|
{ value: 'top', label: 'Top' },
|
||||||
{ value: 'bottom', label: 'Bottom' },
|
{ value: 'bottom', label: 'Bottom' },
|
||||||
{ value: 'manual', label: 'Manual (coming soon)' }
|
{ value: 'manual', label: 'Manual' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const resizeModes: { value: ResizeMode; label: string }[] = [
|
const resizeModes: { value: ResizeMode; label: string }[] = [
|
||||||
|
|||||||
@@ -199,24 +199,47 @@ async function processImage(
|
|||||||
const width = img.get_width();
|
const width = img.get_width();
|
||||||
const height = img.get_height();
|
const height = img.get_height();
|
||||||
const targetAspect = device.width / device.height;
|
const targetAspect = device.width / device.height;
|
||||||
const currentAspect = width / height;
|
|
||||||
|
|
||||||
let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
|
let cropX1: number, cropY1: number, cropX2: number, cropY2: number;
|
||||||
|
|
||||||
if (currentAspect > targetAspect) {
|
if (config.crop.mode === 'manual' && config.crop.region) {
|
||||||
const newWidth = Math.max(1, Math.floor(height * targetAspect));
|
// Manual crop: use the provided region
|
||||||
const xOffset = Math.floor((width - newWidth) / 2);
|
const region = config.crop.region;
|
||||||
cropX1 = Math.max(0, xOffset);
|
cropX1 = Math.max(0, Math.round(region.x));
|
||||||
cropY1 = 0;
|
cropY1 = Math.max(0, Math.round(region.y));
|
||||||
cropX2 = Math.min(width, cropX1 + newWidth);
|
cropX2 = Math.min(width, Math.round(region.x + region.width));
|
||||||
cropY2 = height;
|
cropY2 = Math.min(height, Math.round(region.y + region.height));
|
||||||
} else {
|
} else {
|
||||||
const newHeight = Math.max(1, Math.floor(width / targetAspect));
|
// Auto crop modes: center, top, bottom
|
||||||
const yOffset = Math.floor((height - newHeight) / 2);
|
const currentAspect = width / height;
|
||||||
cropX1 = 0;
|
|
||||||
cropY1 = Math.max(0, yOffset);
|
if (currentAspect > targetAspect) {
|
||||||
cropX2 = width;
|
const newWidth = Math.max(1, Math.floor(height * targetAspect));
|
||||||
cropY2 = Math.min(height, cropY1 + newHeight);
|
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) {
|
if (cropX2 > cropX1 && cropY2 > cropY1) {
|
||||||
|
|||||||
Reference in New Issue
Block a user