mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor(web): crop area tool (#26843)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user