Files
card-forge/src/lib/components/PhotoCrop.svelte
2025-07-18 09:11:17 +02:00

375 lines
10 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
let { imageUrl, personName, initialCropData, onCropUpdated, onClose } = $props<{
imageUrl: string;
personName: string;
initialCropData?: { x: number; y: number; width: number; height: number };
onCropUpdated: (detail: {
cropData: { x: number; y: number; width: number; height: number };
}) => void;
onClose: () => void;
}>();
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let image: HTMLImageElement;
let isImageLoaded = false;
// Crop rectangle state
let crop = {
x: 0,
y: 0,
width: 200,
height: 200
};
// Interaction state
let isDragging = false;
let isResizing = false;
let dragStart = { x: 0, y: 0 };
let resizeHandle = '';
// Canvas dimensions
let canvasWidth = 600;
let canvasHeight = 400;
// Get crop ratio from environment
const cropRatio = parseFloat(import.meta.env.VITE_CROP_RATIO || '1.0');
onMount(() => {
ctx = canvas.getContext('2d')!;
loadImage();
});
async function loadImage() {
image = new Image();
image.onload = () => {
isImageLoaded = true;
// Calculate canvas size to fit image while maintaining aspect ratio
const maxWidth = 600;
const maxHeight = 400;
const imageAspect = image.width / image.height;
if (imageAspect > maxWidth / maxHeight) {
canvasWidth = maxWidth;
canvasHeight = maxWidth / imageAspect;
} else {
canvasHeight = maxHeight;
canvasWidth = maxHeight * imageAspect;
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Initialize crop rectangle
if (initialCropData) {
// Scale initial crop to canvas dimensions
const scaleX = canvasWidth / image.width;
const scaleY = canvasHeight / image.height;
crop = {
x: initialCropData.x * scaleX,
y: initialCropData.y * scaleY,
width: initialCropData.width * scaleX,
height: initialCropData.height * scaleY
};
} else {
// Default crop: centered with correct aspect ratio
const maxSize = Math.min(canvasWidth, canvasHeight) * 0.6;
const cropWidth = maxSize;
const cropHeight = cropWidth / cropRatio;
// If height exceeds canvas, scale down proportionally
if (cropHeight > canvasHeight * 0.8) {
const scale = (canvasHeight * 0.8) / cropHeight;
crop = {
x: (canvasWidth - (cropWidth * scale)) / 2,
y: (canvasHeight - (cropHeight * scale)) / 2,
width: cropWidth * scale,
height: cropHeight * scale
};
} else {
crop = {
x: (canvasWidth - cropWidth) / 2,
y: (canvasHeight - cropHeight) / 2,
width: cropWidth,
height: cropHeight
};
}
}
drawCanvas();
};
image.src = imageUrl;
}
function drawCanvas() {
if (!ctx || !isImageLoaded) return;
// Clear canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw image
ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
// Draw overlay (darken non-crop area)
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(crop.x, crop.y, crop.width, crop.height);
ctx.globalCompositeOperation = 'source-over';
// Draw crop rectangle border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x, crop.y, crop.width, crop.height);
// Draw resize handles
const handleSize = 12; // Increased from 8 for easier grabbing
ctx.fillStyle = '#3b82f6';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
// Corner handles with white borders for better visibility
const handles = [
{ x: crop.x - handleSize/2, y: crop.y - handleSize/2, cursor: 'nw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y - handleSize/2, cursor: 'ne-resize' },
{ x: crop.x - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'sw-resize' },
{ x: crop.x + crop.width - handleSize/2, y: crop.y + crop.height - handleSize/2, cursor: 'se-resize' },
];
handles.forEach(handle => {
ctx.fillRect(handle.x, handle.y, handleSize, handleSize);
ctx.strokeRect(handle.x, handle.y, handleSize, handleSize);
});
}
function getMousePos(e: MouseEvent) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function isInCropArea(x: number, y: number) {
return x >= crop.x && x <= crop.x + crop.width &&
y >= crop.y && y <= crop.y + crop.height;
}
function getResizeHandle(x: number, y: number) {
const handleSize = 12; // Match the drawing size
const tolerance = handleSize;
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'nw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - crop.y) <= tolerance) return 'ne';
if (Math.abs(x - crop.x) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'sw';
if (Math.abs(x - (crop.x + crop.width)) <= tolerance && Math.abs(y - (crop.y + crop.height)) <= tolerance) return 'se';
return '';
}
function handleMouseDown(e: MouseEvent) {
const pos = getMousePos(e);
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
isResizing = true;
resizeHandle = handle;
dragStart = pos;
} else if (isInCropArea(pos.x, pos.y)) {
isDragging = true;
dragStart = { x: pos.x - crop.x, y: pos.y - crop.y };
}
}
function handleMouseMove(e: MouseEvent) {
const pos = getMousePos(e);
if (isResizing) {
const dx = pos.x - dragStart.x;
const dy = pos.y - dragStart.y;
const newCrop = { ...crop };
// Use primary axis movement for more predictable resizing
switch (resizeHandle) {
case 'nw':
// Use the dominant movement direction
const primaryDelta = Math.abs(dx) > Math.abs(dy) ? dx : dy * cropRatio;
const newWidth = Math.max(20, crop.width - primaryDelta);
const newHeight = newWidth / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidth);
newCrop.y = Math.max(0, crop.y + crop.height - newHeight);
newCrop.width = newWidth;
newCrop.height = newHeight;
break;
case 'ne':
// For NE, primarily follow horizontal movement
const newWidthNE = Math.max(20, crop.width + dx);
const newHeightNE = newWidthNE / cropRatio;
newCrop.width = newWidthNE;
newCrop.height = newHeightNE;
newCrop.y = Math.max(0, crop.y + crop.height - newHeightNE);
break;
case 'sw':
// For SW, primarily follow horizontal movement
const newWidthSW = Math.max(20, crop.width - dx);
const newHeightSW = newWidthSW / cropRatio;
newCrop.x = Math.max(0, crop.x + crop.width - newWidthSW);
newCrop.width = newWidthSW;
newCrop.height = newHeightSW;
break;
case 'se':
// For SE, primarily follow horizontal movement
const newWidthSE = Math.max(20, crop.width + dx);
const newHeightSE = newWidthSE / cropRatio;
newCrop.width = newWidthSE;
newCrop.height = newHeightSE;
break;
}
// Ensure crop stays within canvas bounds
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.width = canvasWidth - newCrop.x;
newCrop.height = newCrop.width / cropRatio;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.height = canvasHeight - newCrop.y;
newCrop.width = newCrop.height * cropRatio;
}
// Adjust position if crop extends beyond bounds after resizing
if (newCrop.x + newCrop.width > canvasWidth) {
newCrop.x = canvasWidth - newCrop.width;
}
if (newCrop.y + newCrop.height > canvasHeight) {
newCrop.y = canvasHeight - newCrop.height;
}
crop = newCrop;
drawCanvas();
} else if (isDragging) {
crop.x = Math.max(0, Math.min(canvasWidth - crop.width, pos.x - dragStart.x));
crop.y = Math.max(0, Math.min(canvasHeight - crop.height, pos.y - dragStart.y));
drawCanvas();
} else {
// Update cursor based on hover state
const handle = getResizeHandle(pos.x, pos.y);
if (handle) {
canvas.style.cursor = handle + '-resize';
} else if (isInCropArea(pos.x, pos.y)) {
canvas.style.cursor = 'move';
} else {
canvas.style.cursor = 'default';
}
}
}
function handleMouseUp() {
isDragging = false;
isResizing = false;
resizeHandle = '';
canvas.style.cursor = 'default';
}
function handleSave() {
// Scale crop rectangle back to original image dimensions
const scaleX = image.width / canvasWidth;
const scaleY = image.height / canvasHeight;
const finalCrop = {
x: Math.round(crop.x * scaleX),
y: Math.round(crop.y * scaleY),
width: Math.round(crop.width * scaleX),
height: Math.round(crop.height * scaleY)
};
onCropUpdated({ cropData: finalCrop });
onClose();
}
function handleCancel() {
onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onclick={handleOverlayClick}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
>
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4" role="document">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="dialog-title" class="text-lg font-semibold text-gray-800">
Crop Photo: {personName}
</h3>
<button onclick={onClose} class="text-gray-400 hover:text-gray-600" aria-label="Close">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="mb-4 p-2 rounded-md text-center">
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
class="mx-auto cursor-move"
style="max-width: 100%; height: auto;"
></canvas>
</div>
<div class="flex justify-end space-x-3">
<button
onclick={handleCancel}
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700"
>
Save Crop
</button>
</div>
</div>
</div>
</div>