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
@@ -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">
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 { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
@@ -11,6 +14,9 @@
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 imageSrc = $derived(
@@ -30,16 +36,23 @@
return transforms.join(' ');
});
$effect(() => {
if (!canvasContainer) {
return;
}
const edges = [ResizeBoundary.Top, ResizeBoundary.Right, ResizeBoundary.Bottom, ResizeBoundary.Left];
const corners = [
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(() => {
transformManager.resizeCanvas();
});
resizeObserver.observe(canvasContainer);
resizeObserver.observe(canvasContainer!);
return () => {
resizeObserver.disconnect();
@@ -47,153 +60,144 @@
});
</script>
<div class="canvas-container" bind:this={canvasContainer}>
<button
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`}
style={`rotate:${transformManager.imageRotation}deg`}
<div class="flex flex-col items-center justify-center w-full h-full p-8" bind:this={canvasContainer}>
<div
class="crop-area max-w-full max-h-full transition-transform motion-reduce:transition-none"
class:rotated={transformManager.normalizedRotation % 180 > 0}
style:rotate={transformManager.imageRotation + 'deg'}
bind:this={transformManager.cropAreaEl}
onmousedown={(e) => transformManager.handleMouseDown(e)}
onmouseup={() => transformManager.handleMouseUp()}
aria-label="Crop area"
type="button"
>
<img
draggable="false"
src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))}
style={imageTransform ? `transform: ${imageTransform}` : ''}
class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
/>
<div
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
bind:this={transformManager.cropFrame}
>
<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`}
class={[
'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',
]}
bind:this={transformManager.overlayEl}
></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>
<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 {
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 {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
--color: white;
--shadow: #00000057;
background-image:
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);
background-size: calc(100% / 3) calc(100% / 3);
opacity: 0;
transition: opacity 0.1s ease;
}
.crop-frame.resizing .grid {
opacity: 1;
}
.crop-area img {
display: block;
max-width: 100%;
.left,
.right {
top: 0;
width: 20px;
height: 100%;
user-select: none;
transition: transform 0.15s ease;
cursor: ew-resize;
}
.crop-frame {
position: absolute;
border: 2px solid white;
box-sizing: border-box;
pointer-events: none;
.top,
.bottom {
width: 100%;
height: 20px;
cursor: ns-resize;
}
.corner {
position: absolute;
width: 20px;
height: 20px;
--size: 5.2px;
--mSize: calc(-0.5 * var(--size));
border: var(--size) solid white;
box-sizing: border-box;
--offset: -15px;
}
.top-left {
top: var(--mSize);
left: var(--mSize);
border-right: none;
border-bottom: none;
top: var(--offset);
left: var(--offset);
cursor: nwse-resize;
}
.top-right {
top: var(--mSize);
right: var(--mSize);
border-left: none;
border-bottom: none;
}
.bottom-left {
bottom: var(--mSize);
left: var(--mSize);
border-right: none;
border-top: none;
top: var(--offset);
right: var(--offset);
cursor: nesw-resize;
rotate: 90deg;
}
.bottom-right {
bottom: var(--mSize);
right: var(--mSize);
border-left: none;
border-top: none;
bottom: var(--offset);
right: var(--offset);
cursor: nwse-resize;
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>