mirror of
https://github.com/immich-app/immich.git
synced 2025-07-15 20:38:26 +02:00
536 lines
15 KiB
TypeScript
536 lines
15 KiB
TypeScript
import {
|
|
cropAspectRatio,
|
|
cropImageScale,
|
|
cropImageSize,
|
|
cropSettings,
|
|
cropSettingsChanged,
|
|
normaizedRorateDegrees,
|
|
rotateDegrees,
|
|
showCancelConfirmDialog,
|
|
type CropSettings,
|
|
} from '$lib/stores/asset-editor.store';
|
|
import { get } from 'svelte/store';
|
|
import { adjustDimensions, keepAspectRatio } from './crop-settings';
|
|
import {
|
|
canvasCursor,
|
|
cropAreaEl,
|
|
dragOffset,
|
|
isDragging,
|
|
isResizingOrDragging,
|
|
overlayEl,
|
|
resizeSide,
|
|
} from './crop-store';
|
|
import { draw } from './drawing';
|
|
|
|
export function handleMouseDown(e: MouseEvent) {
|
|
const canvas = get(cropAreaEl);
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const crop = get(cropSettings);
|
|
const { mouseX, mouseY } = getMousePosition(e);
|
|
|
|
const {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = isOnCropBoundary(mouseX, mouseY, crop);
|
|
|
|
if (
|
|
onTopLeftCorner ||
|
|
onTopRightCorner ||
|
|
onBottomLeftCorner ||
|
|
onBottomRightCorner ||
|
|
onLeftBoundary ||
|
|
onRightBoundary ||
|
|
onTopBoundary ||
|
|
onBottomBoundary
|
|
) {
|
|
setResizeSide(mouseX, mouseY);
|
|
} else if (isInCropArea(mouseX, mouseY, crop)) {
|
|
startDragging(mouseX, mouseY);
|
|
}
|
|
|
|
document.body.style.userSelect = 'none';
|
|
globalThis.addEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
export function handleMouseMove(e: MouseEvent) {
|
|
const canvas = get(cropAreaEl);
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const resizeSideValue = get(resizeSide);
|
|
const { mouseX, mouseY } = getMousePosition(e);
|
|
|
|
if (get(isDragging)) {
|
|
moveCrop(mouseX, mouseY);
|
|
} else if (resizeSideValue) {
|
|
resizeCrop(mouseX, mouseY);
|
|
} else {
|
|
updateCursor(mouseX, mouseY);
|
|
}
|
|
}
|
|
|
|
export function handleMouseUp() {
|
|
globalThis.removeEventListener('mouseup', handleMouseUp);
|
|
document.body.style.userSelect = '';
|
|
stopInteraction();
|
|
}
|
|
|
|
function getMousePosition(e: MouseEvent) {
|
|
let offsetX = e.clientX;
|
|
let offsetY = e.clientY;
|
|
const clienRect = getBoundingClientRectCached(get(cropAreaEl));
|
|
const rotateDeg = get(normaizedRorateDegrees);
|
|
|
|
if (rotateDeg == 90) {
|
|
offsetX = e.clientY - (clienRect?.top ?? 0);
|
|
offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
|
|
} else if (rotateDeg == 180) {
|
|
offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
|
|
offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
|
|
} else if (rotateDeg == 270) {
|
|
offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
|
|
offsetY = e.clientX - (clienRect?.left ?? 0);
|
|
} else if (rotateDeg == 0) {
|
|
offsetX -= clienRect?.left ?? 0;
|
|
offsetY -= clienRect?.top ?? 0;
|
|
}
|
|
return { mouseX: offsetX, mouseY: offsetY };
|
|
}
|
|
|
|
type BoundingClientRect = ReturnType<HTMLElement['getBoundingClientRect']>;
|
|
let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = {
|
|
data: null,
|
|
time: 0,
|
|
};
|
|
rotateDegrees.subscribe(() => {
|
|
getBoundingClientRectCache.time = 0;
|
|
});
|
|
function getBoundingClientRectCached(el: HTMLElement | null) {
|
|
if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) {
|
|
getBoundingClientRectCache = {
|
|
time: Date.now(),
|
|
data: el?.getBoundingClientRect() ?? null,
|
|
};
|
|
}
|
|
return getBoundingClientRectCache.data;
|
|
}
|
|
|
|
function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) {
|
|
const { x, y, width, height } = crop;
|
|
const sensitivity = 10;
|
|
const cornerSensitivity = 15;
|
|
|
|
const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0;
|
|
if (outOfBound) {
|
|
return {
|
|
onLeftBoundary: false,
|
|
onRightBoundary: false,
|
|
onTopBoundary: false,
|
|
onBottomBoundary: false,
|
|
onTopLeftCorner: false,
|
|
onTopRightCorner: false,
|
|
onBottomLeftCorner: false,
|
|
onBottomRightCorner: false,
|
|
};
|
|
}
|
|
|
|
const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height;
|
|
const onRightBoundary =
|
|
mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height;
|
|
const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width;
|
|
const onBottomBoundary =
|
|
mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width;
|
|
|
|
const onTopLeftCorner =
|
|
mouseX >= x - cornerSensitivity &&
|
|
mouseX <= x + cornerSensitivity &&
|
|
mouseY >= y - cornerSensitivity &&
|
|
mouseY <= y + cornerSensitivity;
|
|
const onTopRightCorner =
|
|
mouseX >= x + width - cornerSensitivity &&
|
|
mouseX <= x + width + cornerSensitivity &&
|
|
mouseY >= y - cornerSensitivity &&
|
|
mouseY <= y + cornerSensitivity;
|
|
const onBottomLeftCorner =
|
|
mouseX >= x - cornerSensitivity &&
|
|
mouseX <= x + cornerSensitivity &&
|
|
mouseY >= y + height - cornerSensitivity &&
|
|
mouseY <= y + height + cornerSensitivity;
|
|
const onBottomRightCorner =
|
|
mouseX >= x + width - cornerSensitivity &&
|
|
mouseX <= x + width + cornerSensitivity &&
|
|
mouseY >= y + height - cornerSensitivity &&
|
|
mouseY <= y + height + cornerSensitivity;
|
|
|
|
return {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
};
|
|
}
|
|
|
|
function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) {
|
|
const { x, y, width, height } = crop;
|
|
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
|
|
}
|
|
|
|
function setResizeSide(mouseX: number, mouseY: number) {
|
|
const crop = get(cropSettings);
|
|
const {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = isOnCropBoundary(mouseX, mouseY, crop);
|
|
|
|
if (onTopLeftCorner) {
|
|
resizeSide.set('top-left');
|
|
} else if (onTopRightCorner) {
|
|
resizeSide.set('top-right');
|
|
} else if (onBottomLeftCorner) {
|
|
resizeSide.set('bottom-left');
|
|
} else if (onBottomRightCorner) {
|
|
resizeSide.set('bottom-right');
|
|
} else if (onLeftBoundary) {
|
|
resizeSide.set('left');
|
|
} else if (onRightBoundary) {
|
|
resizeSide.set('right');
|
|
} else if (onTopBoundary) {
|
|
resizeSide.set('top');
|
|
} else if (onBottomBoundary) {
|
|
resizeSide.set('bottom');
|
|
}
|
|
}
|
|
|
|
function startDragging(mouseX: number, mouseY: number) {
|
|
isDragging.set(true);
|
|
const crop = get(cropSettings);
|
|
isResizingOrDragging.set(true);
|
|
dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y });
|
|
fadeOverlay(false);
|
|
}
|
|
|
|
function moveCrop(mouseX: number, mouseY: number) {
|
|
const cropArea = get(cropAreaEl);
|
|
if (!cropArea) {
|
|
return;
|
|
}
|
|
|
|
const crop = get(cropSettings);
|
|
const { x, y } = get(dragOffset);
|
|
|
|
let newX = mouseX - x;
|
|
let newY = mouseY - y;
|
|
|
|
newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX));
|
|
newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY));
|
|
|
|
cropSettings.update((crop) => {
|
|
crop.x = newX;
|
|
crop.y = newY;
|
|
return crop;
|
|
});
|
|
|
|
draw(crop);
|
|
}
|
|
|
|
function resizeCrop(mouseX: number, mouseY: number) {
|
|
const canvas = get(cropAreaEl);
|
|
const crop = get(cropSettings);
|
|
const resizeSideValue = get(resizeSide);
|
|
if (!canvas || !resizeSideValue) {
|
|
return;
|
|
}
|
|
fadeOverlay(false);
|
|
|
|
const { x, y, width, height } = crop;
|
|
const minSize = 50;
|
|
let newWidth = width;
|
|
let newHeight = height;
|
|
switch (resizeSideValue) {
|
|
case 'left': {
|
|
newWidth = width + x - mouseX;
|
|
newHeight = height;
|
|
if (newWidth >= minSize && mouseX >= 0) {
|
|
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
|
|
cropSettings.update((crop) => {
|
|
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth));
|
|
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
|
|
crop.x = Math.max(0, x + width - crop.width);
|
|
return crop;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'right': {
|
|
newWidth = mouseX - x;
|
|
newHeight = height;
|
|
if (newWidth >= minSize && mouseX <= canvas.clientWidth) {
|
|
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
|
|
cropSettings.update((crop) => {
|
|
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x));
|
|
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
|
|
return crop;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'top': {
|
|
newHeight = height + y - mouseY;
|
|
newWidth = width;
|
|
if (newHeight >= minSize && mouseY >= 0) {
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.y = Math.max(0, y + height - h);
|
|
crop.width = w;
|
|
crop.height = h;
|
|
return crop;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'bottom': {
|
|
newHeight = mouseY - y;
|
|
newWidth = width;
|
|
if (newHeight >= minSize && mouseY <= canvas.clientHeight) {
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.width = w;
|
|
crop.height = h;
|
|
return crop;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'top-left': {
|
|
newWidth = width + x - Math.max(mouseX, 0);
|
|
newHeight = height + y - Math.max(mouseY, 0);
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.width = w;
|
|
crop.height = h;
|
|
crop.x = Math.max(0, x + width - crop.width);
|
|
crop.y = Math.max(0, y + height - crop.height);
|
|
return crop;
|
|
});
|
|
break;
|
|
}
|
|
case 'top-right': {
|
|
newWidth = Math.max(mouseX, 0) - x;
|
|
newHeight = height + y - Math.max(mouseY, 0);
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth - x,
|
|
y + height,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.width = w;
|
|
crop.height = h;
|
|
crop.y = Math.max(0, y + height - crop.height);
|
|
return crop;
|
|
});
|
|
break;
|
|
}
|
|
case 'bottom-left': {
|
|
newWidth = width + x - Math.max(mouseX, 0);
|
|
newHeight = Math.max(mouseY, 0) - y;
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.width = w;
|
|
crop.height = h;
|
|
crop.x = Math.max(0, x + width - crop.width);
|
|
return crop;
|
|
});
|
|
break;
|
|
}
|
|
case 'bottom-right': {
|
|
newWidth = Math.max(mouseX, 0) - x;
|
|
newHeight = Math.max(mouseY, 0) - y;
|
|
const { newWidth: w, newHeight: h } = adjustDimensions(
|
|
newWidth,
|
|
newHeight,
|
|
get(cropAspectRatio),
|
|
canvas.clientWidth - x,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
cropSettings.update((crop) => {
|
|
crop.width = w;
|
|
crop.height = h;
|
|
return crop;
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
cropSettings.update((crop) => {
|
|
crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width));
|
|
crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height));
|
|
return crop;
|
|
});
|
|
|
|
draw(crop);
|
|
}
|
|
|
|
function updateCursor(mouseX: number, mouseY: number) {
|
|
const canvas = get(cropAreaEl);
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const crop = get(cropSettings);
|
|
const rotateDeg = get(normaizedRorateDegrees);
|
|
|
|
let {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = isOnCropBoundary(mouseX, mouseY, crop);
|
|
|
|
if (rotateDeg == 90) {
|
|
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
|
onLeftBoundary,
|
|
onTopBoundary,
|
|
onRightBoundary,
|
|
onBottomBoundary,
|
|
];
|
|
|
|
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
|
onBottomLeftCorner,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomRightCorner,
|
|
];
|
|
} else if (rotateDeg == 180) {
|
|
[onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
|
|
[onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
|
|
|
|
[onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
|
|
[onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
|
|
} else if (rotateDeg == 270) {
|
|
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
|
onRightBoundary,
|
|
onBottomBoundary,
|
|
onLeftBoundary,
|
|
onTopBoundary,
|
|
];
|
|
|
|
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
|
onTopRightCorner,
|
|
onBottomRightCorner,
|
|
onBottomLeftCorner,
|
|
onTopLeftCorner,
|
|
];
|
|
}
|
|
if (onTopLeftCorner || onBottomRightCorner) {
|
|
setCursor('nwse-resize');
|
|
} else if (onTopRightCorner || onBottomLeftCorner) {
|
|
setCursor('nesw-resize');
|
|
} else if (onLeftBoundary || onRightBoundary) {
|
|
setCursor('ew-resize');
|
|
} else if (onTopBoundary || onBottomBoundary) {
|
|
setCursor('ns-resize');
|
|
} else if (isInCropArea(mouseX, mouseY, crop)) {
|
|
setCursor('move');
|
|
} else {
|
|
setCursor('default');
|
|
}
|
|
|
|
function setCursor(cursorName: string) {
|
|
if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) {
|
|
canvasCursor.set(cursorName);
|
|
document.body.style.cursor = cursorName;
|
|
canvas.style.cursor = cursorName;
|
|
}
|
|
}
|
|
}
|
|
|
|
function stopInteraction() {
|
|
isResizingOrDragging.set(false);
|
|
isDragging.set(false);
|
|
resizeSide.set('');
|
|
fadeOverlay(true); // Darken the background
|
|
|
|
setTimeout(() => {
|
|
checkEdits();
|
|
}, 1);
|
|
}
|
|
|
|
export function checkEdits() {
|
|
const cropImageSizeParams = get(cropSettings);
|
|
const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale));
|
|
const changed =
|
|
Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 ||
|
|
Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2;
|
|
cropSettingsChanged.set(changed);
|
|
}
|
|
|
|
function fadeOverlay(toDark: boolean) {
|
|
const overlay = get(overlayEl);
|
|
const cropFrame = document.querySelector('.crop-frame');
|
|
|
|
if (toDark) {
|
|
overlay?.classList.remove('light');
|
|
cropFrame?.classList.remove('resizing');
|
|
} else {
|
|
overlay?.classList.add('light');
|
|
cropFrame?.classList.add('resizing');
|
|
}
|
|
|
|
isResizingOrDragging.set(!toDark);
|
|
}
|