refactor(web): crop area tool (#26843)

This commit is contained in:
Mees Frensel
2026-03-11 18:58:26 +01:00
committed by GitHub
parent 0a79dd1228
commit 9996ee12d0
4 changed files with 293 additions and 588 deletions
+2
View File
@@ -1007,6 +1007,8 @@
"editor_edits_applied_success": "Edits applied successfully", "editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal", "editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical", "editor_flip_vertical": "Flip vertical",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
"editor_orientation": "Orientation", "editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes", "editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise", "editor_rotate_left": "Rotate 90° counterclockwise",
@@ -1,79 +0,0 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory';
import { render } from '@testing-library/svelte';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/utils');
describe('CropArea', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg');
});
afterEach(() => {
transformManager.reset();
});
it('clears cursor styles on reset', () => {
const asset = assetFactory.build();
const { getByRole } = render(CropArea, { asset });
const cropArea = getByRole('button', { name: 'Crop area' });
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 1000, height: 1000 };
transformManager.cropImageScale = 1;
transformManager.updateCursor(100, 150);
expect(document.body.style.cursor).toBe('ew-resize');
expect(cropArea.style.cursor).toBe('ew-resize');
transformManager.reset();
expect(document.body.style.cursor).toBe('');
expect(cropArea.style.cursor).toBe('');
});
it('sets cursor style at x: $x, y: $y to be $cursor', () => {
const data = [
{ x: 299, y: 84, cursor: '' },
{ x: 299, y: 85, cursor: 'nesw-resize' },
{ x: 299, y: 115, cursor: 'nesw-resize' },
{ x: 299, y: 116, cursor: 'ew-resize' },
{ x: 299, y: 284, cursor: 'ew-resize' },
{ x: 299, y: 285, cursor: 'nwse-resize' },
{ x: 299, y: 300, cursor: 'nwse-resize' },
{ x: 299, y: 301, cursor: '' },
{ x: 300, y: 84, cursor: '' },
{ x: 300, y: 85, cursor: 'nesw-resize' },
{ x: 300, y: 86, cursor: 'nesw-resize' },
{ x: 300, y: 114, cursor: 'nesw-resize' },
{ x: 300, y: 115, cursor: 'nesw-resize' },
{ x: 300, y: 116, cursor: 'ew-resize' },
{ x: 300, y: 284, cursor: 'ew-resize' },
{ x: 300, y: 285, cursor: 'nwse-resize' },
{ x: 300, y: 286, cursor: 'nwse-resize' },
{ x: 300, y: 300, cursor: 'nwse-resize' },
{ x: 300, y: 301, cursor: '' },
{ x: 301, y: 300, cursor: '' },
{ x: 301, y: 301, cursor: '' },
];
const element = document.createElement('div');
for (const { x, y, cursor } of data) {
const message = `x: ${x}, y: ${y} - ${cursor}`;
transformManager.reset();
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 600, height: 600 };
transformManager.cropAreaEl = element;
transformManager.cropImageScale = 0.5;
transformManager.updateCursor(x, y);
expect(element.style.cursor, message).toBe(cursor);
expect(document.body.style.cursor, message).toBe(cursor);
}
});
});
@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte'; import { ResizeBoundary, transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -11,6 +14,9 @@
let { asset }: Props = $props(); let { asset }: Props = $props();
// viewBox 0 0 24 24 is assumed. Without rotation this icon is top-left.
const cornerIcon = 'M 12 24 L 12 12 L 24 12';
let canvasContainer = $state<HTMLElement | null>(null); let canvasContainer = $state<HTMLElement | null>(null);
let imageSrc = $derived( let imageSrc = $derived(
@@ -30,16 +36,23 @@
return transforms.join(' '); return transforms.join(' ');
}); });
$effect(() => { const edges = [ResizeBoundary.Top, ResizeBoundary.Right, ResizeBoundary.Bottom, ResizeBoundary.Left];
if (!canvasContainer) { const corners = [
return; ResizeBoundary.TopLeft,
} ResizeBoundary.TopRight,
ResizeBoundary.BottomRight,
ResizeBoundary.BottomLeft,
];
function rotateBoundary(arr: ResizeBoundary[], input: ResizeBoundary, times: number) {
return arr[(arr.indexOf(input) + times) % 4];
}
onMount(() => {
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
transformManager.resizeCanvas(); transformManager.resizeCanvas();
}); });
resizeObserver.observe(canvasContainer); resizeObserver.observe(canvasContainer!);
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
@@ -47,153 +60,144 @@
}); });
</script> </script>
<div class="canvas-container" bind:this={canvasContainer}> <div class="flex flex-col items-center justify-center w-full h-full p-8" bind:this={canvasContainer}>
<button <div
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`} class="crop-area max-w-full max-h-full transition-transform motion-reduce:transition-none"
style={`rotate:${transformManager.imageRotation}deg`} class:rotated={transformManager.normalizedRotation % 180 > 0}
style:rotate={transformManager.imageRotation + 'deg'}
bind:this={transformManager.cropAreaEl} bind:this={transformManager.cropAreaEl}
onmousedown={(e) => transformManager.handleMouseDown(e)}
onmouseup={() => transformManager.handleMouseUp()}
aria-label="Crop area" aria-label="Crop area"
type="button"
> >
<img <img
draggable="false" draggable="false"
src={imageSrc} src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
style={imageTransform ? `transform: ${imageTransform}` : ''} class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
/> />
<div <div
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`} class={[
bind:this={transformManager.cropFrame} 'overlay w-full h-full absolute top-0 transition-colors motion-reduce:transition-none pointer-events-none',
> transformManager.isInteracting ? 'bg-black/30' : 'bg-black/56',
<div class="grid"></div> ]}
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
<div
class={`${transformManager.isInteracting ? 'light' : ''} overlay`}
bind:this={transformManager.overlayEl} bind:this={transformManager.overlayEl}
></div> ></div>
</button> <div class="crop-frame absolute border-2 border-white" bind:this={transformManager.cropFrame}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'grid w-full h-full cursor-move transition-opacity motion-reduce:transition-none',
transformManager.isInteracting ? 'opacity-100' : 'opacity-0',
]}
onmousedown={(e) => transformManager.handleMouseDownOn(e, ResizeBoundary.None)}
></div>
{#each edges as edge (edge)}
{@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)}
<button
class={['absolute', edge]}
style={`${edge}: -10px`}
onmousedown={(e) => transformManager.handleMouseDownOn(e, edge)}
type="button"
aria-label={$t('editor_handle_edge', { values: { edge: rotatedEdge } })}
></button>
{/each}
{#each corners as corner (corner)}
{@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)}
<button
class={['corner', corner]}
onmousedown={(e) => transformManager.handleMouseDownOn(e, corner)}
type="button"
aria-label={$t('editor_handle_corner', { values: { corner: rotatedCorner.replace('-', '_') } })}
>
<Icon icon={cornerIcon} size="30" strokeWidth={4} strokeColor="white" color="transparent" />
</button>
{/each}
</div>
</div>
</div> </div>
<style> <style>
.canvas-container {
width: calc(100% - 4rem);
margin: auto;
margin-top: 2rem;
height: calc(100% - 4rem);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.crop-area {
position: relative;
display: inline-block;
outline: none;
transition: rotate 0.15s ease;
max-height: 100%;
max-width: 100%;
width: max-content;
}
.crop-area.changedOriention {
max-width: 92vh;
max-height: calc(100vw - 400px - 1.5rem);
}
.crop-frame.transition { .crop-frame.transition {
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.56);
pointer-events: none;
transition: background 0.1s;
}
.overlay.light {
background: rgba(0, 0, 0, 0.3);
}
.grid { .grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
--color: white; --color: white;
--shadow: #00000057; --shadow: #00000057;
background-image: background-image:
linear-gradient(var(--color) 1px, transparent 0), linear-gradient(90deg, var(--color) 1px, transparent 0), linear-gradient(var(--color) 1px, transparent 0), linear-gradient(90deg, var(--color) 1px, transparent 0),
linear-gradient(var(--shadow) 3px, transparent 0), linear-gradient(90deg, var(--shadow) 3px, transparent 0); linear-gradient(var(--shadow) 3px, transparent 0), linear-gradient(90deg, var(--shadow) 3px, transparent 0);
background-size: calc(100% / 3) calc(100% / 3); background-size: calc(100% / 3) calc(100% / 3);
opacity: 0;
transition: opacity 0.1s ease;
} }
.crop-frame.resizing .grid { .left,
opacity: 1; .right {
} top: 0;
width: 20px;
.crop-area img {
display: block;
max-width: 100%;
height: 100%; height: 100%;
user-select: none; cursor: ew-resize;
transition: transform 0.15s ease;
} }
.crop-frame { .top,
position: absolute; .bottom {
border: 2px solid white; width: 100%;
box-sizing: border-box; height: 20px;
pointer-events: none; cursor: ns-resize;
} }
.corner { .corner {
position: absolute; position: absolute;
width: 20px; --offset: -15px;
height: 20px;
--size: 5.2px;
--mSize: calc(-0.5 * var(--size));
border: var(--size) solid white;
box-sizing: border-box;
} }
.top-left { .top-left {
top: var(--mSize); top: var(--offset);
left: var(--mSize); left: var(--offset);
border-right: none; cursor: nwse-resize;
border-bottom: none;
} }
.top-right { .top-right {
top: var(--mSize); top: var(--offset);
right: var(--mSize); right: var(--offset);
border-left: none; cursor: nesw-resize;
border-bottom: none; rotate: 90deg;
}
.bottom-left {
bottom: var(--mSize);
left: var(--mSize);
border-right: none;
border-top: none;
} }
.bottom-right { .bottom-right {
bottom: var(--mSize); bottom: var(--offset);
right: var(--mSize); right: var(--offset);
border-left: none; cursor: nwse-resize;
border-top: none; rotate: 180deg;
}
.bottom-left {
bottom: var(--offset);
left: var(--offset);
cursor: nesw-resize;
rotate: 270deg;
}
.crop-area.rotated {
max-width: calc(100vh - 16 * var(--spacing));
max-height: calc(100vw - 400px - 16 * var(--spacing));
.left,
.right {
cursor: ns-resize;
}
.top,
.bottom {
cursor: ew-resize;
}
.top-left,
.bottom-right {
cursor: nesw-resize;
}
.top-right,
.bottom-left {
cursor: nwse-resize;
}
} }
</style> </style>
@@ -1,9 +1,10 @@
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils'; import { getDimensions } from '$lib/utils/asset-utils';
import { normalizeTransformEdits } from '$lib/utils/editor'; import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk'; import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
import { clamp } from 'lodash-es';
import { tick } from 'svelte'; import { tick } from 'svelte';
export type CropAspectRatio = export type CropAspectRatio =
@@ -37,17 +38,27 @@ type RegionConvertParams = {
to: ImageDimensions; to: ImageDimensions;
}; };
export enum ResizeBoundary {
None = 'none',
TopLeft = 'top-left',
TopRight = 'top-right',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Left = 'left',
Right = 'right',
Top = 'top',
Bottom = 'bottom',
}
class TransformManager implements EditToolManager { class TransformManager implements EditToolManager {
canReset: boolean = $derived.by(() => this.checkEdits()); canReset: boolean = $derived.by(() => this.checkEdits());
hasChanges: boolean = $state(false); hasChanges: boolean = $state(false);
darkenLevel = $state(0.65);
isInteracting = $state(false); isInteracting = $state(false);
isDragging = $state(false); isDragging = $state(false);
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null); animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
canvasCursor = $state('default'); dragAnchor = $state({ x: 0, y: 0 });
dragOffset = $state({ x: 0, y: 0 }); resizeSide = $state(ResizeBoundary.None);
resizeSide = $state('');
imgElement = $state<HTMLImageElement | null>(null); imgElement = $state<HTMLImageElement | null>(null);
cropAreaEl = $state<HTMLElement | null>(null); cropAreaEl = $state<HTMLElement | null>(null);
overlayEl = $state<HTMLElement | null>(null); overlayEl = $state<HTMLElement | null>(null);
@@ -69,7 +80,6 @@ class TransformManager implements EditToolManager {
const newAngle = this.imageRotation % 360; const newAngle = this.imageRotation % 360;
return newAngle < 0 ? newAngle + 360 : newAngle; return newAngle < 0 ? newAngle + 360 : newAngle;
}); });
orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0);
edits = $derived.by(() => this.getEdits()); edits = $derived.by(() => this.getEdits());
@@ -81,9 +91,9 @@ class TransformManager implements EditToolManager {
return; return;
} }
const newCrop = transformManager.recalculateCrop(aspectRatio); const newCrop = this.recalculateCrop(aspectRatio);
if (newCrop) { if (newCrop) {
transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); this.animateCropChange(newCrop);
this.region = newCrop; this.region = newCrop;
} }
} }
@@ -216,17 +226,11 @@ class TransformManager implements EditToolManager {
} }
reset() { reset() {
this.darkenLevel = 0.65;
this.isInteracting = false; this.isInteracting = false;
this.animationFrame = null; this.animationFrame = null;
this.canvasCursor = 'default'; this.dragAnchor = { x: 0, y: 0 };
this.dragOffset = { x: 0, y: 0 }; this.resizeSide = ResizeBoundary.None;
this.resizeSide = '';
this.imgElement = null; this.imgElement = null;
if (this.cropAreaEl) {
this.cropAreaEl.style.cursor = '';
}
document.body.style.cursor = '';
this.cropAreaEl = null; this.cropAreaEl = null;
this.isDragging = false; this.isDragging = false;
this.overlayEl = null; this.overlayEl = null;
@@ -295,12 +299,12 @@ class TransformManager implements EditToolManager {
}; };
} }
animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { animateCropChange(to: Region, duration = 100) {
const cropFrame = element.querySelector('.crop-frame') as HTMLElement; if (!this.cropFrame) {
if (!cropFrame) {
return; return;
} }
const from = this.region;
const startTime = performance.now(); const startTime = performance.now();
const initialCrop = { ...from }; const initialCrop = { ...from };
@@ -334,28 +338,6 @@ class TransformManager implements EditToolManager {
return { newWidth, 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( adjustDimensions(
newWidth: number, newWidth: number,
newHeight: number, newHeight: number,
@@ -364,49 +346,45 @@ class TransformManager implements EditToolManager {
yLimit: number, yLimit: number,
minSize: number, minSize: number,
) { ) {
if (aspectRatio === 'free') {
return {
newWidth: clamp(newWidth, minSize, xLimit),
newHeight: clamp(newHeight, minSize, yLimit),
};
}
let w = newWidth; let w = newWidth;
let h = newHeight; let h = newHeight;
let aspectMultiplier: number; const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number);
const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h;
if (aspectRatio === 'free') { h = w / aspectMultiplier;
aspectMultiplier = newWidth / newHeight; // When dragging a corner, use the biggest region that fits 'inside' the mouse location.
} else { if (h < newHeight) {
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); h = newHeight;
aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; w = h * aspectMultiplier;
}
if (aspectRatio !== 'free') {
h = w / aspectMultiplier;
} }
if (w > xLimit) { if (w > xLimit) {
w = xLimit; w = xLimit;
if (aspectRatio !== 'free') { h = w / aspectMultiplier;
h = w / aspectMultiplier;
}
} }
if (h > yLimit) { if (h > yLimit) {
h = yLimit; h = yLimit;
if (aspectRatio !== 'free') { w = h * aspectMultiplier;
w = h * aspectMultiplier;
}
} }
if (w < minSize) { if (w < minSize) {
w = minSize; w = minSize;
if (aspectRatio !== 'free') { h = w / aspectMultiplier;
h = w / aspectMultiplier;
}
} }
if (h < minSize) { if (h < minSize) {
h = minSize; h = minSize;
if (aspectRatio !== 'free') { w = h * aspectMultiplier;
w = h * aspectMultiplier;
}
} }
if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { if (w / h !== aspectMultiplier) {
if (w < minSize) { if (w < minSize) {
h = w / aspectMultiplier; h = w / aspectMultiplier;
} }
@@ -428,10 +406,6 @@ class TransformManager implements EditToolManager {
this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.width = `${crop.width}px`;
this.cropFrame.style.height = `${crop.height}px`; this.cropFrame.style.height = `${crop.height}px`;
this.drawOverlay(crop);
}
drawOverlay(crop: Region) {
if (!this.overlayEl) { if (!this.overlayEl) {
return; return;
} }
@@ -465,7 +439,6 @@ class TransformManager implements EditToolManager {
const cropFrameEl = this.cropFrame; const cropFrameEl = this.cropFrame;
cropFrameEl?.classList.add('transition'); cropFrameEl?.classList.add('transition');
this.region = this.normalizeCropArea(scale); this.region = this.normalizeCropArea(scale);
cropFrameEl?.classList.add('transition');
cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
passive: true, passive: true,
}); });
@@ -540,7 +513,7 @@ class TransformManager implements EditToolManager {
normalizeCropArea(scale: number) { normalizeCropArea(scale: number) {
const img = this.imgElement; const img = this.imgElement;
if (!img) { if (!img) {
return { ...this.region }; return this.region;
} }
const scaleRatio = scale / this.cropImageScale; const scaleRatio = scale / this.cropImageScale;
@@ -576,38 +549,17 @@ class TransformManager implements EditToolManager {
this.draw(); this.draw();
} }
handleMouseDown(e: MouseEvent) { handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) {
const canvas = this.cropAreaEl; if (e.button !== 0) {
if (!canvas) {
return; return;
} }
const { mouseX, mouseY } = this.getMousePosition(e); this.isInteracting = true;
this.resizeSide = resizeBoundary;
const { if (resizeBoundary === ResizeBoundary.None) {
onLeftBoundary, this.isDragging = true;
onRightBoundary, const { mouseX, mouseY } = this.getMousePosition(e);
onTopBoundary, this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y };
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'; document.body.style.userSelect = 'none';
@@ -615,20 +567,16 @@ class TransformManager implements EditToolManager {
} }
handleMouseMove(e: MouseEvent) { handleMouseMove(e: MouseEvent) {
const canvas = this.cropAreaEl; if (!this.cropAreaEl) {
if (!canvas) {
return; return;
} }
const resizeSideValue = this.resizeSide;
const { mouseX, mouseY } = this.getMousePosition(e); const { mouseX, mouseY } = this.getMousePosition(e);
if (this.isDragging) { if (this.isDragging) {
this.moveCrop(mouseX, mouseY); this.moveCrop(mouseX, mouseY);
} else if (resizeSideValue) { } else if (this.resizeSide !== ResizeBoundary.None) {
this.resizeCrop(mouseX, mouseY); this.resizeCrop(mouseX, mouseY);
} else {
this.updateCursor(mouseX, mouseY);
} }
} }
@@ -638,131 +586,42 @@ class TransformManager implements EditToolManager {
this.isInteracting = false; this.isInteracting = false;
this.isDragging = false; this.isDragging = false;
this.resizeSide = ''; this.resizeSide = ResizeBoundary.None;
this.fadeOverlay(true); // Darken the background
} }
getMousePosition(e: MouseEvent) { getMousePosition(e: MouseEvent) {
let offsetX = e.clientX; if (!this.cropAreaEl) {
let offsetY = e.clientY; throw new Error('Crop area is undefined');
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 }; const clientRect = this.cropAreaEl.getBoundingClientRect();
}
// Boundary detection helpers switch (this.normalizedRotation) {
private isInRange(value: number, target: number, sensitivity: number): boolean { case 90: {
return value >= target - sensitivity && value <= target + sensitivity; return {
} mouseX: e.clientY - clientRect.top,
mouseY: -e.clientX + clientRect.right,
private isWithinBounds(value: number, min: number, max: number): boolean { };
return value >= min && value <= max; }
} case 180: {
return {
isOnCropBoundary(mouseX: number, mouseY: number) { mouseX: -e.clientX + clientRect.right,
const { x, y, width, height } = this.region; mouseY: -e.clientY + clientRect.bottom,
const sensitivity = 10; };
const cornerSensitivity = 15; }
const { width: imgWidth, height: imgHeight } = this.previewImageSize; case 270: {
return {
const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; mouseX: -e.clientY + clientRect.bottom,
if (outOfBound) { mouseY: e.clientX - clientRect.left,
return { };
onLeftBoundary: false, }
onRightBoundary: false, // also case 0:
onTopBoundary: false, default: {
onBottomBoundary: false, return {
onTopLeftCorner: false, mouseX: e.clientX - clientRect.left,
onTopRightCorner: false, mouseY: e.clientY - clientRect.top,
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) { moveCrop(mouseX: number, mouseY: number) {
@@ -772,102 +631,116 @@ class TransformManager implements EditToolManager {
} }
this.hasChanges = true; this.hasChanges = true;
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width);
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height);
this.region = {
...this.region,
x: newX,
y: newY,
};
this.draw(); this.draw();
} }
resizeCrop(mouseX: number, mouseY: number) { resizeCrop(mouseX: number, mouseY: number) {
const canvas = this.cropAreaEl; const canvas = this.cropAreaEl;
const crop = this.region; const currentCrop = this.region;
const resizeSideValue = this.resizeSide; if (!canvas) {
if (!canvas || !resizeSideValue) {
return; return;
} }
this.fadeOverlay(false); this.isInteracting = true;
this.hasChanges = true; this.hasChanges = true;
const { x, y, width, height } = crop; const { x, y, width, height } = currentCrop;
const minSize = 50; const minSize = 50;
let newRegion = { ...crop }; let newRegion = { ...currentCrop };
switch (resizeSideValue) { let desiredWidth = width;
case 'left': { let desiredHeight = height;
const desiredWidth = width + (x - mouseX);
if (desiredWidth >= minSize && mouseX >= 0) { // Width
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); switch (this.resizeSide) {
const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); case ResizeBoundary.Left:
const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); case ResizeBoundary.TopLeft:
newRegion = { case ResizeBoundary.BottomLeft: {
x: Math.max(0, x + width - finalWidth), desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0)));
y,
width: finalWidth,
height: finalHeight,
};
}
break; break;
} }
case 'right': { case ResizeBoundary.Right:
const desiredWidth = mouseX - x; case ResizeBoundary.TopRight:
if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { case ResizeBoundary.BottomRight: {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x);
newRegion = {
...newRegion,
width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)),
height: Math.max(minSize, Math.min(h, canvas.clientHeight)),
};
}
break; break;
} }
case 'top': { }
const desiredHeight = height + (y - mouseY);
if (desiredHeight >= minSize && mouseY >= 0) { // Height
const { newWidth: w, newHeight: h } = this.adjustDimensions( switch (this.resizeSide) {
width, case ResizeBoundary.Top:
desiredHeight, case ResizeBoundary.TopLeft:
this.cropAspectRatio, case ResizeBoundary.TopRight: {
canvas.clientWidth, desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0)));
canvas.clientHeight,
minSize,
);
newRegion = {
x,
y: Math.max(0, y + height - h),
width: w,
height: h,
};
}
break; break;
} }
case 'bottom': { case ResizeBoundary.Bottom:
const desiredHeight = mouseY - y; case ResizeBoundary.BottomLeft:
if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { case ResizeBoundary.BottomRight: {
const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y);
width,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
newRegion = {
...newRegion,
width: w,
height: h,
};
}
break; break;
} }
case 'top-left': { }
const desiredWidth = width + (x - Math.max(mouseX, 0));
const desiredHeight = height + (y - Math.max(mouseY, 0)); // Old
switch (this.resizeSide) {
case ResizeBoundary.Left: {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
const finalWidth = clamp(w, minSize, canvas.clientWidth);
newRegion = {
x: Math.max(0, x + width - finalWidth),
y,
width: finalWidth,
height: clamp(h, minSize, canvas.clientHeight),
};
break;
}
case ResizeBoundary.Right: {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
newRegion = {
...newRegion,
width: clamp(w, minSize, canvas.clientWidth - x),
height: clamp(h, minSize, canvas.clientHeight),
};
break;
}
case ResizeBoundary.Top: {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight,
minSize,
);
newRegion = {
x,
y: Math.max(0, y + height - h),
width: w,
height: h,
};
break;
}
case ResizeBoundary.Bottom: {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
newRegion = {
...newRegion,
width: w,
height: h,
};
break;
}
case ResizeBoundary.TopLeft: {
const { newWidth: w, newHeight: h } = this.adjustDimensions( const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth, desiredWidth,
desiredHeight, desiredHeight,
@@ -884,9 +757,7 @@ class TransformManager implements EditToolManager {
}; };
break; break;
} }
case 'top-right': { case ResizeBoundary.TopRight: {
const desiredWidth = Math.max(mouseX, 0) - x;
const desiredHeight = height + (y - Math.max(mouseY, 0));
const { newWidth: w, newHeight: h } = this.adjustDimensions( const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth, desiredWidth,
desiredHeight, desiredHeight,
@@ -903,9 +774,7 @@ class TransformManager implements EditToolManager {
}; };
break; break;
} }
case 'bottom-left': { case ResizeBoundary.BottomLeft: {
const desiredWidth = width + (x - Math.max(mouseX, 0));
const desiredHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = this.adjustDimensions( const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth, desiredWidth,
desiredHeight, desiredHeight,
@@ -922,9 +791,7 @@ class TransformManager implements EditToolManager {
}; };
break; break;
} }
case 'bottom-right': { case ResizeBoundary.BottomRight: {
const desiredWidth = Math.max(mouseX, 0) - x;
const desiredHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = this.adjustDimensions( const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth, desiredWidth,
desiredHeight, desiredHeight,
@@ -952,95 +819,6 @@ class TransformManager implements EditToolManager {
this.draw(); 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: string;
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() { resetCrop() {
this.cropAspectRatio = 'free'; this.cropAspectRatio = 'free';
this.region = { this.region = {