diff --git a/i18n/en.json b/i18n/en.json index fa2fb345ef..c3b998ec13 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1007,6 +1007,8 @@ "editor_edits_applied_success": "Edits applied successfully", "editor_flip_horizontal": "Flip horizontal", "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_reset_all_changes": "Reset changes", "editor_rotate_left": "Rotate 90° counterclockwise", diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts deleted file mode 100644 index d0d7f99ad3..0000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 7a84612fe8..011f30d445 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -1,9 +1,12 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 77290d3e6d..652cd0bee9 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -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 { 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 { clamp } from 'lodash-es'; import { tick } from 'svelte'; export type CropAspectRatio = @@ -37,17 +38,27 @@ type RegionConvertParams = { 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 { canReset: boolean = $derived.by(() => this.checkEdits()); hasChanges: boolean = $state(false); - darkenLevel = $state(0.65); isInteracting = $state(false); isDragging = $state(false); animationFrame = $state | null>(null); - canvasCursor = $state('default'); - dragOffset = $state({ x: 0, y: 0 }); - resizeSide = $state(''); + dragAnchor = $state({ x: 0, y: 0 }); + resizeSide = $state(ResizeBoundary.None); imgElement = $state(null); cropAreaEl = $state(null); overlayEl = $state(null); @@ -69,7 +80,6 @@ class TransformManager implements EditToolManager { const newAngle = this.imageRotation % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); - orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); edits = $derived.by(() => this.getEdits()); @@ -81,9 +91,9 @@ class TransformManager implements EditToolManager { return; } - const newCrop = transformManager.recalculateCrop(aspectRatio); + const newCrop = this.recalculateCrop(aspectRatio); if (newCrop) { - transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.animateCropChange(newCrop); this.region = newCrop; } } @@ -216,17 +226,11 @@ class TransformManager implements EditToolManager { } reset() { - this.darkenLevel = 0.65; this.isInteracting = false; this.animationFrame = null; - this.canvasCursor = 'default'; - this.dragOffset = { x: 0, y: 0 }; - this.resizeSide = ''; + this.dragAnchor = { x: 0, y: 0 }; + this.resizeSide = ResizeBoundary.None; this.imgElement = null; - if (this.cropAreaEl) { - this.cropAreaEl.style.cursor = ''; - } - document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -295,12 +299,12 @@ class TransformManager implements EditToolManager { }; } - animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { - const cropFrame = element.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { + animateCropChange(to: Region, duration = 100) { + if (!this.cropFrame) { return; } + const from = this.region; const startTime = performance.now(); const initialCrop = { ...from }; @@ -334,28 +338,6 @@ class TransformManager implements EditToolManager { 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, @@ -364,49 +346,45 @@ class TransformManager implements EditToolManager { yLimit: number, minSize: number, ) { + if (aspectRatio === 'free') { + return { + newWidth: clamp(newWidth, minSize, xLimit), + newHeight: clamp(newHeight, minSize, yLimit), + }; + } + let w = newWidth; let h = newHeight; - let aspectMultiplier: number; + const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number); + const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h; - 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; + h = w / aspectMultiplier; + // When dragging a corner, use the biggest region that fits 'inside' the mouse location. + if (h < newHeight) { + h = newHeight; + w = h * aspectMultiplier; } if (w > xLimit) { w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h > yLimit) { h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } if (w < minSize) { w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (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) { h = w / aspectMultiplier; } @@ -428,10 +406,6 @@ class TransformManager implements EditToolManager { this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.height = `${crop.height}px`; - this.drawOverlay(crop); - } - - drawOverlay(crop: Region) { if (!this.overlayEl) { return; } @@ -465,7 +439,6 @@ class TransformManager implements EditToolManager { 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, }); @@ -540,7 +513,7 @@ class TransformManager implements EditToolManager { normalizeCropArea(scale: number) { const img = this.imgElement; if (!img) { - return { ...this.region }; + return this.region; } const scaleRatio = scale / this.cropImageScale; @@ -576,38 +549,17 @@ class TransformManager implements EditToolManager { this.draw(); } - handleMouseDown(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) { + if (e.button !== 0) { 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); + this.isInteracting = true; + this.resizeSide = resizeBoundary; + if (resizeBoundary === ResizeBoundary.None) { + this.isDragging = true; + const { mouseX, mouseY } = this.getMousePosition(e); + this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y }; } document.body.style.userSelect = 'none'; @@ -615,20 +567,16 @@ class TransformManager implements EditToolManager { } handleMouseMove(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + if (!this.cropAreaEl) { return; } - const resizeSideValue = this.resizeSide; const { mouseX, mouseY } = this.getMousePosition(e); if (this.isDragging) { this.moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { + } else if (this.resizeSide !== ResizeBoundary.None) { this.resizeCrop(mouseX, mouseY); - } else { - this.updateCursor(mouseX, mouseY); } } @@ -638,131 +586,42 @@ class TransformManager implements EditToolManager { this.isInteracting = false; this.isDragging = false; - this.resizeSide = ''; - this.fadeOverlay(true); // Darken the background + this.resizeSide = ResizeBoundary.None; } 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; + if (!this.cropAreaEl) { + throw new Error('Crop area is undefined'); } - return { mouseX: offsetX, mouseY: offsetY }; - } + const clientRect = this.cropAreaEl.getBoundingClientRect(); - // 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.previewImageSize; - - 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, - }; + switch (this.normalizedRotation) { + case 90: { + return { + mouseX: e.clientY - clientRect.top, + mouseY: -e.clientX + clientRect.right, + }; + } + case 180: { + return { + mouseX: -e.clientX + clientRect.right, + mouseY: -e.clientY + clientRect.bottom, + }; + } + case 270: { + return { + mouseX: -e.clientY + clientRect.bottom, + mouseY: e.clientX - clientRect.left, + }; + } + // also case 0: + default: { + return { + mouseX: e.clientX - clientRect.left, + mouseY: e.clientY - clientRect.top, + }; + } } - - 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) { @@ -772,102 +631,116 @@ class TransformManager implements EditToolManager { } this.hasChanges = true; - 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.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width); + this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height); this.draw(); } resizeCrop(mouseX: number, mouseY: number) { const canvas = this.cropAreaEl; - const crop = this.region; - const resizeSideValue = this.resizeSide; - if (!canvas || !resizeSideValue) { + const currentCrop = this.region; + if (!canvas) { return; } - this.fadeOverlay(false); + this.isInteracting = true; this.hasChanges = true; - const { x, y, width, height } = crop; + const { x, y, width, height } = currentCrop; const minSize = 50; - let newRegion = { ...crop }; + let newRegion = { ...currentCrop }; - 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, - }; - } + let desiredWidth = width; + let desiredHeight = height; + + // Width + switch (this.resizeSide) { + case ResizeBoundary.Left: + case ResizeBoundary.TopLeft: + case ResizeBoundary.BottomLeft: { + desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0))); 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)), - }; - } + case ResizeBoundary.Right: + case ResizeBoundary.TopRight: + case ResizeBoundary.BottomRight: { + desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x); 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, - }; - } + } + + // Height + switch (this.resizeSide) { + case ResizeBoundary.Top: + case ResizeBoundary.TopLeft: + case ResizeBoundary.TopRight: { + desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0))); 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, - }; - } + case ResizeBoundary.Bottom: + case ResizeBoundary.BottomLeft: + case ResizeBoundary.BottomRight: { + desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y); 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( desiredWidth, desiredHeight, @@ -884,9 +757,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'top-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.TopRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -903,9 +774,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -922,9 +791,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -952,95 +819,6 @@ class TransformManager implements EditToolManager { 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() { this.cropAspectRatio = 'free'; this.region = {