375 lines
10 KiB
Svelte
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>
|