mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
1102 lines
32 KiB
TypeScript
1102 lines
32 KiB
TypeScript
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
|
|
import { getAssetMediaUrl } from '$lib/utils';
|
|
import { getDimensions } from '$lib/utils/asset-utils';
|
|
import { normalizeTransformEdits } from '$lib/utils/editor';
|
|
import { handleError } from '$lib/utils/handle-error';
|
|
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
|
|
import { tick } from 'svelte';
|
|
|
|
export type CropAspectRatio =
|
|
| '1:1'
|
|
| '16:9'
|
|
| '4:3'
|
|
| '3:2'
|
|
| '7:5'
|
|
| '9:16'
|
|
| '3:4'
|
|
| '2:3'
|
|
| '5:7'
|
|
| 'free'
|
|
| 'reset';
|
|
|
|
type Region = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type ImageDimensions = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type RegionConvertParams = {
|
|
region: Region;
|
|
from: ImageDimensions;
|
|
to: ImageDimensions;
|
|
};
|
|
|
|
class TransformManager implements EditToolManager {
|
|
hasChanges: boolean = $derived.by(() => this.checkEdits());
|
|
|
|
darkenLevel = $state(0.65);
|
|
isInteracting = $state(false);
|
|
isDragging = $state(false);
|
|
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
|
|
canvasCursor = $state('default');
|
|
dragOffset = $state({ x: 0, y: 0 });
|
|
resizeSide = $state('');
|
|
imgElement = $state<HTMLImageElement | null>(null);
|
|
cropAreaEl = $state<HTMLElement | null>(null);
|
|
overlayEl = $state<HTMLElement | null>(null);
|
|
cropFrame = $state<HTMLElement | null>(null);
|
|
cropImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
|
|
cropImageScale = $state(1);
|
|
cropAspectRatio = $state('free');
|
|
originalImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
|
|
region = $state({ x: 0, y: 0, width: 100, height: 100 });
|
|
preveiwImgSize = $derived({
|
|
width: this.cropImageSize.width * this.cropImageScale,
|
|
height: this.cropImageSize.height * this.cropImageScale,
|
|
});
|
|
|
|
imageRotation = $state(0);
|
|
mirrorHorizontal = $state(false);
|
|
mirrorVertical = $state(false);
|
|
normalizedRotation = $derived.by(() => {
|
|
const newAngle = this.imageRotation % 360;
|
|
return newAngle < 0 ? newAngle + 360 : newAngle;
|
|
});
|
|
orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0);
|
|
|
|
edits = $derived.by(() => this.getEdits());
|
|
|
|
setAspectRatio(aspectRatio: string) {
|
|
this.cropAspectRatio = aspectRatio;
|
|
|
|
if (!this.imgElement || !this.cropAreaEl) {
|
|
return;
|
|
}
|
|
|
|
const newCrop = transformManager.recalculateCrop(aspectRatio);
|
|
if (newCrop) {
|
|
transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop);
|
|
this.region = newCrop;
|
|
}
|
|
}
|
|
|
|
checkEdits() {
|
|
return (
|
|
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
|
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
|
|
this.mirrorHorizontal ||
|
|
this.mirrorVertical ||
|
|
this.normalizedRotation !== 0
|
|
);
|
|
}
|
|
|
|
checkCropEdits() {
|
|
return (
|
|
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
|
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
|
|
);
|
|
}
|
|
|
|
getEdits(): EditActions {
|
|
const edits: EditActions = [];
|
|
|
|
if (this.checkCropEdits()) {
|
|
// Convert from display coordinates to loaded preview image coordinates
|
|
let cropRegion = this.getRegionInPreviewCoords(this.region);
|
|
|
|
// Transform crop coordinates to account for mirroring
|
|
// The preview shows the mirrored image, but crop is applied before mirror on the server
|
|
// So we need to "unmirror" the crop coordinates
|
|
cropRegion = this.applyMirrorToCoords(cropRegion, this.cropImageSize);
|
|
|
|
// Convert from preview image coordinates to original image coordinates
|
|
cropRegion = this.convertRegion({
|
|
region: cropRegion,
|
|
from: this.cropImageSize,
|
|
to: this.originalImageSize,
|
|
});
|
|
|
|
// Constrain to original image bounds (fixes possible rounding errors)
|
|
cropRegion = this.constrainToBounds(cropRegion, this.originalImageSize);
|
|
|
|
edits.push({
|
|
action: AssetEditAction.Crop,
|
|
parameters: cropRegion,
|
|
});
|
|
}
|
|
|
|
// Mirror edits come before rotate in array so that compose applies rotate first, then mirror
|
|
// This matches CSS where parent has rotate and child img has mirror transforms
|
|
if (this.mirrorHorizontal) {
|
|
edits.push({
|
|
action: AssetEditAction.Mirror,
|
|
parameters: {
|
|
axis: MirrorAxis.Horizontal,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (this.mirrorVertical) {
|
|
edits.push({
|
|
action: AssetEditAction.Mirror,
|
|
parameters: {
|
|
axis: MirrorAxis.Vertical,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (this.normalizedRotation !== 0) {
|
|
edits.push({
|
|
action: AssetEditAction.Rotate,
|
|
parameters: {
|
|
angle: this.normalizedRotation,
|
|
},
|
|
});
|
|
}
|
|
|
|
return edits;
|
|
}
|
|
|
|
async resetAllChanges() {
|
|
this.imageRotation = 0;
|
|
this.mirrorHorizontal = false;
|
|
this.mirrorVertical = false;
|
|
await tick();
|
|
|
|
this.onImageLoad([]);
|
|
}
|
|
|
|
async onActivate(asset: AssetResponseDto, edits: EditActions): Promise<void> {
|
|
const originalSize = getDimensions(asset.exifInfo!);
|
|
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
|
|
|
|
this.imgElement = new Image();
|
|
|
|
const imageURL = getAssetMediaUrl({
|
|
id: asset.id,
|
|
cacheKey: asset.thumbhash,
|
|
edited: false,
|
|
size: AssetMediaSize.Preview,
|
|
});
|
|
|
|
this.imgElement.src = imageURL;
|
|
this.imgElement.addEventListener('load', () => transformManager.onImageLoad(edits), { passive: true });
|
|
this.imgElement.addEventListener('error', (error) => handleError(error, 'ErrorLoadingImage'), {
|
|
passive: true,
|
|
});
|
|
|
|
globalThis.addEventListener('mousemove', (e) => transformManager.handleMouseMove(e), { passive: true });
|
|
|
|
const transformEdits = edits.filter((e) => e.action === 'rotate' || e.action === 'mirror');
|
|
|
|
// Normalize rotation and mirror edits to single rotation and mirror state
|
|
// This allows edits to be imported in any order and still produce correct state
|
|
const normalizedTransfromation = normalizeTransformEdits(transformEdits);
|
|
this.imageRotation = normalizedTransfromation.rotation;
|
|
this.mirrorHorizontal = normalizedTransfromation.mirrorHorizontal;
|
|
this.mirrorVertical = normalizedTransfromation.mirrorVertical;
|
|
|
|
await tick();
|
|
|
|
this.resizeCanvas();
|
|
}
|
|
|
|
onDeactivate() {
|
|
globalThis.removeEventListener('mousemove', transformManager.handleMouseMove);
|
|
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
this.darkenLevel = 0.65;
|
|
this.isInteracting = false;
|
|
this.animationFrame = null;
|
|
this.canvasCursor = 'default';
|
|
this.dragOffset = { x: 0, y: 0 };
|
|
this.resizeSide = '';
|
|
this.imgElement = null;
|
|
this.cropAreaEl = null;
|
|
this.isDragging = false;
|
|
this.overlayEl = null;
|
|
this.imageRotation = 0;
|
|
this.mirrorHorizontal = false;
|
|
this.mirrorVertical = false;
|
|
this.region = { x: 0, y: 0, width: 100, height: 100 };
|
|
this.cropImageSize = { width: 1000, height: 1000 };
|
|
this.originalImageSize = { width: 1000, height: 1000 };
|
|
this.cropImageScale = 1;
|
|
this.cropAspectRatio = 'free';
|
|
}
|
|
|
|
mirror(axis: 'horizontal' | 'vertical') {
|
|
if (this.imageRotation % 180 !== 0) {
|
|
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
|
|
}
|
|
|
|
if (axis === 'horizontal') {
|
|
this.mirrorHorizontal = !this.mirrorHorizontal;
|
|
} else {
|
|
this.mirrorVertical = !this.mirrorVertical;
|
|
}
|
|
}
|
|
|
|
async rotate(angle: number) {
|
|
this.imageRotation += angle;
|
|
await tick();
|
|
this.onImageLoad();
|
|
}
|
|
|
|
recalculateCrop(aspectRatio: string = this.cropAspectRatio): Region {
|
|
if (!this.cropAreaEl) {
|
|
return this.region;
|
|
}
|
|
|
|
const canvasW = this.cropAreaEl.clientWidth;
|
|
const canvasH = this.cropAreaEl.clientHeight;
|
|
|
|
// Calculate new dimensions with aspect ratio
|
|
const { newWidth: w, newHeight: h } = this.keepAspectRatio(this.region.width, this.region.height, aspectRatio);
|
|
|
|
// Scale down if needed to fit in canvas
|
|
let newWidth = w;
|
|
let newHeight = h;
|
|
|
|
if (w > canvasW) {
|
|
newWidth = canvasW;
|
|
newHeight = canvasW / (w / h);
|
|
} else if (h > canvasH) {
|
|
newHeight = canvasH;
|
|
newWidth = canvasH * (w / h);
|
|
}
|
|
|
|
// Constrain position to keep crop area within canvas
|
|
return {
|
|
x: Math.max(0, Math.min(this.region.x, canvasW - newWidth)),
|
|
y: Math.max(0, Math.min(this.region.y, canvasH - newHeight)),
|
|
width: newWidth,
|
|
height: newHeight,
|
|
};
|
|
}
|
|
|
|
animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) {
|
|
const cropFrame = element.querySelector('.crop-frame') as HTMLElement;
|
|
if (!cropFrame) {
|
|
return;
|
|
}
|
|
|
|
const startTime = performance.now();
|
|
const initialCrop = { ...from };
|
|
|
|
const animate = (currentTime: number) => {
|
|
const elapsedTime = currentTime - startTime;
|
|
const progress = Math.min(elapsedTime / duration, 1);
|
|
|
|
from.x = initialCrop.x + (to.x - initialCrop.x) * progress;
|
|
from.y = initialCrop.y + (to.y - initialCrop.y) * progress;
|
|
from.width = initialCrop.width + (to.width - initialCrop.width) * progress;
|
|
from.height = initialCrop.height + (to.height - initialCrop.height) * progress;
|
|
|
|
this.draw(from);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: string = this.cropAspectRatio) {
|
|
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
|
|
|
|
if (widthRatio && heightRatio) {
|
|
const calculatedWidth = (newHeight * widthRatio) / heightRatio;
|
|
return { newWidth: calculatedWidth, newHeight };
|
|
}
|
|
|
|
return { newWidth, newHeight };
|
|
}
|
|
|
|
// Calculate constrained dimensions based on aspect ratio and limits
|
|
getConstrainedDimensions(
|
|
desiredWidth: number,
|
|
desiredHeight: number,
|
|
maxWidth: number,
|
|
maxHeight: number,
|
|
minSize = 50,
|
|
) {
|
|
const { newWidth, newHeight } = this.adjustDimensions(
|
|
desiredWidth,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
maxWidth,
|
|
maxHeight,
|
|
minSize,
|
|
);
|
|
return {
|
|
width: Math.max(minSize, Math.min(newWidth, maxWidth)),
|
|
height: Math.max(minSize, Math.min(newHeight, maxHeight)),
|
|
};
|
|
}
|
|
|
|
adjustDimensions(
|
|
newWidth: number,
|
|
newHeight: number,
|
|
aspectRatio: string,
|
|
xLimit: number,
|
|
yLimit: number,
|
|
minSize: number,
|
|
) {
|
|
let w = newWidth;
|
|
let h = newHeight;
|
|
|
|
let aspectMultiplier: number;
|
|
|
|
if (aspectRatio === 'free') {
|
|
aspectMultiplier = newWidth / newHeight;
|
|
} else {
|
|
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
|
|
aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight;
|
|
}
|
|
|
|
if (aspectRatio !== 'free') {
|
|
h = w / aspectMultiplier;
|
|
}
|
|
|
|
if (w > xLimit) {
|
|
w = xLimit;
|
|
if (aspectRatio !== 'free') {
|
|
h = w / aspectMultiplier;
|
|
}
|
|
}
|
|
if (h > yLimit) {
|
|
h = yLimit;
|
|
if (aspectRatio !== 'free') {
|
|
w = h * aspectMultiplier;
|
|
}
|
|
}
|
|
|
|
if (w < minSize) {
|
|
w = minSize;
|
|
if (aspectRatio !== 'free') {
|
|
h = w / aspectMultiplier;
|
|
}
|
|
}
|
|
if (h < minSize) {
|
|
h = minSize;
|
|
if (aspectRatio !== 'free') {
|
|
w = h * aspectMultiplier;
|
|
}
|
|
}
|
|
|
|
if (aspectRatio !== 'free' && w / h !== aspectMultiplier) {
|
|
if (w < minSize) {
|
|
h = w / aspectMultiplier;
|
|
}
|
|
if (h < minSize) {
|
|
w = h * aspectMultiplier;
|
|
}
|
|
}
|
|
|
|
return { newWidth: w, newHeight: h };
|
|
}
|
|
|
|
draw(crop: Region = this.region) {
|
|
if (!this.cropFrame) {
|
|
return;
|
|
}
|
|
|
|
this.cropFrame.style.left = `${crop.x}px`;
|
|
this.cropFrame.style.top = `${crop.y}px`;
|
|
this.cropFrame.style.width = `${crop.width}px`;
|
|
this.cropFrame.style.height = `${crop.height}px`;
|
|
|
|
this.drawOverlay(crop);
|
|
}
|
|
|
|
drawOverlay(crop: Region) {
|
|
if (!this.overlayEl) {
|
|
return;
|
|
}
|
|
|
|
this.overlayEl.style.clipPath = `
|
|
polygon(
|
|
0% 0%,
|
|
0% 100%,
|
|
100% 100%,
|
|
100% 0%,
|
|
0% 0%,
|
|
${crop.x}px ${crop.y}px,
|
|
${crop.x + crop.width}px ${crop.y}px,
|
|
${crop.x + crop.width}px ${crop.y + crop.height}px,
|
|
${crop.x}px ${crop.y + crop.height}px,
|
|
${crop.x}px ${crop.y}px
|
|
)
|
|
`;
|
|
}
|
|
|
|
onImageLoad(edits: EditActions | null = null) {
|
|
const img = this.imgElement;
|
|
if (!this.cropAreaEl || !img) {
|
|
return;
|
|
}
|
|
|
|
this.cropImageSize = { width: img.width, height: img.height };
|
|
const scale = this.calculateScale();
|
|
|
|
if (edits === null) {
|
|
const cropFrameEl = this.cropFrame;
|
|
cropFrameEl?.classList.add('transition');
|
|
this.region = this.normalizeCropArea(scale);
|
|
cropFrameEl?.classList.add('transition');
|
|
cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
|
|
passive: true,
|
|
});
|
|
} else {
|
|
const cropEdit = edits.find((e) => e.action === AssetEditAction.Crop);
|
|
|
|
if (cropEdit) {
|
|
const params = cropEdit.parameters as CropParameters;
|
|
|
|
// convert from original image coordinates to loaded preview image coordinates
|
|
// eslint-disable-next-line prefer-const
|
|
let { x, y, width, height } = this.convertRegion({
|
|
region: params,
|
|
from: this.originalImageSize,
|
|
to: this.cropImageSize,
|
|
});
|
|
|
|
// Transform crop coordinates to account for mirroring
|
|
// The stored coordinates are for the original image, but we display mirrored
|
|
// So we need to mirror the crop coordinates to match the preview
|
|
if (this.mirrorHorizontal) {
|
|
x = img.width - x - width;
|
|
}
|
|
if (this.mirrorVertical) {
|
|
y = img.height - y - height;
|
|
}
|
|
|
|
// Convert from absolute pixel coordinates to display coordinates
|
|
this.region = {
|
|
x: x * scale,
|
|
y: y * scale,
|
|
width: width * scale,
|
|
height: height * scale,
|
|
};
|
|
} else {
|
|
this.region = {
|
|
x: 0,
|
|
y: 0,
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
};
|
|
}
|
|
}
|
|
this.cropImageScale = scale;
|
|
|
|
img.style.width = `${img.width * scale}px`;
|
|
img.style.height = `${img.height * scale}px`;
|
|
|
|
this.draw();
|
|
}
|
|
|
|
calculateScale(): number {
|
|
const img = this.imgElement;
|
|
const cropArea = this.cropAreaEl;
|
|
|
|
if (!cropArea || !img) {
|
|
return 1;
|
|
}
|
|
|
|
const containerWidth = cropArea.clientWidth;
|
|
const containerHeight = cropArea.clientHeight;
|
|
|
|
// Fit image to container while maintaining aspect ratio
|
|
let scale = containerWidth / img.width;
|
|
if (img.height * scale > containerHeight) {
|
|
scale = containerHeight / img.height;
|
|
}
|
|
|
|
return scale;
|
|
}
|
|
|
|
normalizeCropArea(scale: number) {
|
|
const img = this.imgElement;
|
|
if (!img) {
|
|
return { ...this.region };
|
|
}
|
|
|
|
const scaleRatio = scale / this.cropImageScale;
|
|
const scaledRegion = {
|
|
x: this.region.x * scaleRatio,
|
|
y: this.region.y * scaleRatio,
|
|
width: this.region.width * scaleRatio,
|
|
height: this.region.height * scaleRatio,
|
|
};
|
|
|
|
// Constrain to scaled image bounds
|
|
return this.constrainToBounds(scaledRegion, {
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
});
|
|
}
|
|
|
|
resizeCanvas() {
|
|
const img = this.imgElement;
|
|
const cropArea = this.cropAreaEl;
|
|
|
|
if (!cropArea || !img) {
|
|
return;
|
|
}
|
|
|
|
const scale = this.calculateScale();
|
|
this.region = this.normalizeCropArea(scale);
|
|
this.cropImageScale = scale;
|
|
|
|
img.style.width = `${img.width * scale}px`;
|
|
img.style.height = `${img.height * scale}px`;
|
|
|
|
this.draw();
|
|
}
|
|
|
|
handleMouseDown(e: MouseEvent) {
|
|
const canvas = this.cropAreaEl;
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const { mouseX, mouseY } = this.getMousePosition(e);
|
|
|
|
const {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = this.isOnCropBoundary(mouseX, mouseY);
|
|
|
|
if (
|
|
onTopLeftCorner ||
|
|
onTopRightCorner ||
|
|
onBottomLeftCorner ||
|
|
onBottomRightCorner ||
|
|
onLeftBoundary ||
|
|
onRightBoundary ||
|
|
onTopBoundary ||
|
|
onBottomBoundary
|
|
) {
|
|
this.setResizeSide(mouseX, mouseY);
|
|
} else if (this.isInCropArea(mouseX, mouseY)) {
|
|
this.startDragging(mouseX, mouseY);
|
|
}
|
|
|
|
document.body.style.userSelect = 'none';
|
|
globalThis.addEventListener('mouseup', () => this.handleMouseUp(), { passive: true });
|
|
}
|
|
|
|
handleMouseMove(e: MouseEvent) {
|
|
const canvas = this.cropAreaEl;
|
|
if (!canvas) {
|
|
return;
|
|
}
|
|
|
|
const resizeSideValue = this.resizeSide;
|
|
const { mouseX, mouseY } = this.getMousePosition(e);
|
|
|
|
if (this.isDragging) {
|
|
this.moveCrop(mouseX, mouseY);
|
|
} else if (resizeSideValue) {
|
|
this.resizeCrop(mouseX, mouseY);
|
|
} else {
|
|
this.updateCursor(mouseX, mouseY);
|
|
}
|
|
}
|
|
|
|
handleMouseUp() {
|
|
globalThis.removeEventListener('mouseup', this.handleMouseUp);
|
|
document.body.style.userSelect = '';
|
|
|
|
this.isInteracting = false;
|
|
this.isDragging = false;
|
|
this.resizeSide = '';
|
|
this.fadeOverlay(true); // Darken the background
|
|
}
|
|
|
|
getMousePosition(e: MouseEvent) {
|
|
let offsetX = e.clientX;
|
|
let offsetY = e.clientY;
|
|
const clienRect = this.cropAreaEl?.getBoundingClientRect();
|
|
const rotateDeg = this.normalizedRotation;
|
|
|
|
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 };
|
|
}
|
|
|
|
// Boundary detection helpers
|
|
private isInRange(value: number, target: number, sensitivity: number): boolean {
|
|
return value >= target - sensitivity && value <= target + sensitivity;
|
|
}
|
|
|
|
private isWithinBounds(value: number, min: number, max: number): boolean {
|
|
return value >= min && value <= max;
|
|
}
|
|
|
|
isOnCropBoundary(mouseX: number, mouseY: number) {
|
|
const { x, y, width, height } = this.region;
|
|
const sensitivity = 10;
|
|
const cornerSensitivity = 15;
|
|
const { width: imgWidth, height: imgHeight } = this.cropImageSize;
|
|
|
|
const outOfBound = mouseX > imgWidth || mouseY > imgHeight || 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 = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height);
|
|
const onRightBoundary =
|
|
this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height);
|
|
const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width);
|
|
const onBottomBoundary =
|
|
this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width);
|
|
|
|
const onTopLeftCorner =
|
|
this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity);
|
|
const onTopRightCorner =
|
|
this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity);
|
|
const onBottomLeftCorner =
|
|
this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity);
|
|
const onBottomRightCorner =
|
|
this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity);
|
|
|
|
return {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
};
|
|
}
|
|
|
|
isInCropArea(mouseX: number, mouseY: number) {
|
|
const { x, y, width, height } = this.region;
|
|
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
|
|
}
|
|
|
|
setResizeSide(mouseX: number, mouseY: number) {
|
|
const {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = this.isOnCropBoundary(mouseX, mouseY);
|
|
|
|
if (onTopLeftCorner) {
|
|
this.resizeSide = 'top-left';
|
|
} else if (onTopRightCorner) {
|
|
this.resizeSide = 'top-right';
|
|
} else if (onBottomLeftCorner) {
|
|
this.resizeSide = 'bottom-left';
|
|
} else if (onBottomRightCorner) {
|
|
this.resizeSide = 'bottom-right';
|
|
} else if (onLeftBoundary) {
|
|
this.resizeSide = 'left';
|
|
} else if (onRightBoundary) {
|
|
this.resizeSide = 'right';
|
|
} else if (onTopBoundary) {
|
|
this.resizeSide = 'top';
|
|
} else if (onBottomBoundary) {
|
|
this.resizeSide = 'bottom';
|
|
}
|
|
}
|
|
|
|
startDragging(mouseX: number, mouseY: number) {
|
|
this.isDragging = true;
|
|
const crop = this.region;
|
|
this.isInteracting = true;
|
|
this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y };
|
|
this.fadeOverlay(false);
|
|
}
|
|
|
|
moveCrop(mouseX: number, mouseY: number) {
|
|
const cropArea = this.cropAreaEl;
|
|
if (!cropArea) {
|
|
return;
|
|
}
|
|
|
|
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
|
|
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
|
|
|
|
this.region = {
|
|
...this.region,
|
|
x: newX,
|
|
y: newY,
|
|
};
|
|
|
|
this.draw();
|
|
}
|
|
|
|
resizeCrop(mouseX: number, mouseY: number) {
|
|
const canvas = this.cropAreaEl;
|
|
const crop = this.region;
|
|
const resizeSideValue = this.resizeSide;
|
|
if (!canvas || !resizeSideValue) {
|
|
return;
|
|
}
|
|
this.fadeOverlay(false);
|
|
|
|
const { x, y, width, height } = crop;
|
|
const minSize = 50;
|
|
let newRegion = { ...crop };
|
|
|
|
switch (resizeSideValue) {
|
|
case 'left': {
|
|
const desiredWidth = width + (x - mouseX);
|
|
if (desiredWidth >= minSize && mouseX >= 0) {
|
|
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
|
|
const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth));
|
|
const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight));
|
|
newRegion = {
|
|
x: Math.max(0, x + width - finalWidth),
|
|
y,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
case 'right': {
|
|
const desiredWidth = mouseX - x;
|
|
if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) {
|
|
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
|
|
newRegion = {
|
|
...newRegion,
|
|
width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)),
|
|
height: Math.max(minSize, Math.min(h, canvas.clientHeight)),
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
case 'top': {
|
|
const desiredHeight = height + (y - mouseY);
|
|
if (desiredHeight >= minSize && mouseY >= 0) {
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
width,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
x,
|
|
y: Math.max(0, y + height - h),
|
|
width: w,
|
|
height: h,
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
case 'bottom': {
|
|
const desiredHeight = mouseY - y;
|
|
if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) {
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
width,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
...newRegion,
|
|
width: w,
|
|
height: h,
|
|
};
|
|
}
|
|
break;
|
|
}
|
|
case 'top-left': {
|
|
const desiredWidth = width + (x - Math.max(mouseX, 0));
|
|
const desiredHeight = height + (y - Math.max(mouseY, 0));
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
desiredWidth,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth,
|
|
canvas.clientHeight,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
x: Math.max(0, x + width - w),
|
|
y: Math.max(0, y + height - h),
|
|
width: w,
|
|
height: h,
|
|
};
|
|
break;
|
|
}
|
|
case 'top-right': {
|
|
const desiredWidth = Math.max(mouseX, 0) - x;
|
|
const desiredHeight = height + (y - Math.max(mouseY, 0));
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
desiredWidth,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth - x,
|
|
y + height,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
x,
|
|
y: Math.max(0, y + height - h),
|
|
width: w,
|
|
height: h,
|
|
};
|
|
break;
|
|
}
|
|
case 'bottom-left': {
|
|
const desiredWidth = width + (x - Math.max(mouseX, 0));
|
|
const desiredHeight = Math.max(mouseY, 0) - y;
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
desiredWidth,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
x: Math.max(0, x + width - w),
|
|
y,
|
|
width: w,
|
|
height: h,
|
|
};
|
|
break;
|
|
}
|
|
case 'bottom-right': {
|
|
const desiredWidth = Math.max(mouseX, 0) - x;
|
|
const desiredHeight = Math.max(mouseY, 0) - y;
|
|
const { newWidth: w, newHeight: h } = this.adjustDimensions(
|
|
desiredWidth,
|
|
desiredHeight,
|
|
this.cropAspectRatio,
|
|
canvas.clientWidth - x,
|
|
canvas.clientHeight - y,
|
|
minSize,
|
|
);
|
|
newRegion = {
|
|
...newRegion,
|
|
width: w,
|
|
height: h,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Constrain the region to canvas bounds
|
|
this.region = {
|
|
...newRegion,
|
|
x: Math.max(0, Math.min(newRegion.x, canvas.clientWidth - newRegion.width)),
|
|
y: Math.max(0, Math.min(newRegion.y, canvas.clientHeight - newRegion.height)),
|
|
};
|
|
|
|
this.draw();
|
|
}
|
|
|
|
updateCursor(mouseX: number, mouseY: number) {
|
|
if (!this.cropAreaEl) {
|
|
return;
|
|
}
|
|
|
|
let {
|
|
onLeftBoundary,
|
|
onRightBoundary,
|
|
onTopBoundary,
|
|
onBottomBoundary,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomLeftCorner,
|
|
onBottomRightCorner,
|
|
} = this.isOnCropBoundary(mouseX, mouseY);
|
|
|
|
if (this.normalizedRotation == 90) {
|
|
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
|
onLeftBoundary,
|
|
onTopBoundary,
|
|
onRightBoundary,
|
|
onBottomBoundary,
|
|
];
|
|
|
|
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
|
onBottomLeftCorner,
|
|
onTopLeftCorner,
|
|
onTopRightCorner,
|
|
onBottomRightCorner,
|
|
];
|
|
} else if (this.normalizedRotation == 180) {
|
|
[onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
|
|
[onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
|
|
|
|
[onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
|
|
[onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
|
|
} else if (this.normalizedRotation == 270) {
|
|
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
|
onRightBoundary,
|
|
onBottomBoundary,
|
|
onLeftBoundary,
|
|
onTopBoundary,
|
|
];
|
|
|
|
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
|
onTopRightCorner,
|
|
onBottomRightCorner,
|
|
onBottomLeftCorner,
|
|
onTopLeftCorner,
|
|
];
|
|
}
|
|
|
|
let cursorName = '';
|
|
if (onTopLeftCorner || onBottomRightCorner) {
|
|
cursorName = 'nwse-resize';
|
|
} else if (onTopRightCorner || onBottomLeftCorner) {
|
|
cursorName = 'nesw-resize';
|
|
} else if (onLeftBoundary || onRightBoundary) {
|
|
cursorName = 'ew-resize';
|
|
} else if (onTopBoundary || onBottomBoundary) {
|
|
cursorName = 'ns-resize';
|
|
} else if (this.isInCropArea(mouseX, mouseY)) {
|
|
cursorName = 'move';
|
|
} else {
|
|
cursorName = 'default';
|
|
}
|
|
|
|
if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) {
|
|
this.canvasCursor = cursorName;
|
|
document.body.style.cursor = cursorName;
|
|
this.cropAreaEl.style.cursor = cursorName;
|
|
}
|
|
}
|
|
|
|
fadeOverlay(toDark: boolean) {
|
|
const overlay = this.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');
|
|
}
|
|
|
|
this.isInteracting = !toDark;
|
|
}
|
|
|
|
resetCrop() {
|
|
this.cropAspectRatio = 'free';
|
|
this.region = {
|
|
x: 0,
|
|
y: 0,
|
|
width: this.cropImageSize.width * this.cropImageScale - 1,
|
|
height: this.cropImageSize.height * this.cropImageScale - 1,
|
|
};
|
|
}
|
|
|
|
rotateAspectRatio() {
|
|
const aspectRatio = this.cropAspectRatio;
|
|
if (aspectRatio === 'free' || aspectRatio === 'reset') {
|
|
return;
|
|
}
|
|
|
|
const [widthRatio, heightRatio] = aspectRatio.split(':');
|
|
this.setAspectRatio(`${heightRatio}:${widthRatio}`);
|
|
}
|
|
|
|
// Coordinate conversion helpers
|
|
convertRegion(settings: RegionConvertParams) {
|
|
const { region, from: fromSize, to: toSize } = settings;
|
|
const scaleX = toSize.width / fromSize.width;
|
|
const scaleY = toSize.height / fromSize.height;
|
|
|
|
return {
|
|
x: Math.round(region.x * scaleX),
|
|
y: Math.round(region.y * scaleY),
|
|
width: Math.round(region.width * scaleX),
|
|
height: Math.round(region.height * scaleY),
|
|
};
|
|
}
|
|
|
|
getRegionInPreviewCoords(region: Region) {
|
|
return {
|
|
x: Math.round(region.x / this.cropImageScale),
|
|
y: Math.round(region.y / this.cropImageScale),
|
|
width: Math.round(region.width / this.cropImageScale),
|
|
height: Math.round(region.height / this.cropImageScale),
|
|
};
|
|
}
|
|
|
|
// Apply mirror transformation to coordinates
|
|
applyMirrorToCoords(region: Region, imageSize: ImageDimensions): Region {
|
|
let { x, y } = region;
|
|
const { width, height } = region;
|
|
|
|
if (this.mirrorHorizontal) {
|
|
x = imageSize.width - x - width;
|
|
}
|
|
if (this.mirrorVertical) {
|
|
y = imageSize.height - y - height;
|
|
}
|
|
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
// Constrain region to bounds
|
|
constrainToBounds(region: Region, bounds: ImageDimensions): Region {
|
|
const { x, y, width, height } = region;
|
|
return {
|
|
x: Math.max(0, Math.min(x, bounds.width - width)),
|
|
y: Math.max(0, Math.min(y, bounds.height - height)),
|
|
width: Math.min(width, bounds.width - Math.max(0, x)),
|
|
height: Math.min(height, bounds.height - Math.max(0, y)),
|
|
};
|
|
}
|
|
}
|
|
|
|
export const transformManager = new TransformManager();
|