immich/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts
2024-12-18 14:19:48 +00:00

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);
}