1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-27 10:58:13 +02:00

feat(web): image editor - panel and cropping (#11074)

* cropping, panel

* fix presets

* types

* prettier

* fix lint

* fix aspect ratio, performance optimization

* improved tool selection, removed placeholder

* fix the mouse's exit from canvas

* fix error

* the "save" button and change tracking

* lint, format

* the mini functionality of the save button

* fix aspect ratio

* hide editor button on mobiles

* strict equality

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* Use the dollar sign syntax for stores inside components

* unobtrusive grid lines, circles at the corners

* more correct image load, handleError

* more strict equality

* fix styles. unused and tailwind

Co-Authored-By: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* dont store isShowEditor

* if showEditor - hide navbar & shortcuts

* crop-canvas decomposition (danger)

I could have accidentally broken something.. but I checked the work and it seems ok.

* fix lint

* fix ts

* callback function as props

* correctly disabling shortcuts

* convenient canvas borders

• you can use the mouse to go beyond the boundaries and freely change the crop.
• the circles on the corners of the canvas are not cut off.

* -the editor button for video files, -save button

* hide editor btn if panoramic || gif || live

* corners instead of circles (preview), fix lint&format

* confirm close editor without save

* vertical aspect ratios

* recovery after merge. editor's closing shortcut

* fix format

* move from canvas to html elements

* fix changes detections

* rotation

* hide detail panel if showing editor

* fix aspect ratios near min size

* fix crop area when changing image size when rotate

* fix of fix

* better layout - grouping

https://github.com/user-attachments/assets/48f15172-9666-4588-acb6-3cb5eda873a8

* hide the button

* fix i18n, format

* hide button

* hide button v2

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
ilyaChuk 2024-08-14 17:54:50 +03:00 committed by GitHub
parent 593f036c0d
commit 7f7fec2cea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1491 additions and 5 deletions

View File

@ -47,12 +47,22 @@
export let onRunJob: (name: AssetJobName) => void;
export let onPlaySlideshow: () => void;
export let onShowDetail: () => void;
// export let showEditorHandler: () => void;
export let onClose: () => void;
const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id;
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
// $: showEditorButton =
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
// !(
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
// ) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
// !asset.livePhotoVideoId;
</script>
<div
@ -98,6 +108,15 @@
{#if isOwner}
<FavoriteAction {asset} {onAction} />
{/if}
<!-- {#if showEditorButton}
<CircleIconButton
color="opaque"
hideMobile={true}
icon={mdiImageEditOutline}
on:click={showEditorHandler}
title={$t('editor')}
/>
{/if} -->
{#if isOwner}
<DeleteAction {asset} {onAction} />

View File

@ -45,7 +45,9 @@
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] = [];
@ -80,6 +82,7 @@
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined;
let isShowActivity = false;
let isShowEditor = false;
let isLiked: ActivityResponseDto | null = null;
let numberOfComments: number;
let fullscreenElement: Element;
@ -272,6 +275,12 @@
await navigate({ targetRoute: 'current', assetId: null });
};
const closeEditor = () => {
closeEditorCofirm(() => {
isShowEditor = false;
});
};
const navigateAssetRandom = async () => {
if (!assetStore) {
return;
@ -315,6 +324,13 @@
dispatch(order);
};
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@ -383,6 +399,12 @@
onAction?.(action);
};
let selectedEditType: string = '';
function handleUpdateSelectedEditType(type: string) {
selectedEditType = type;
}
</script>
<svelte:document bind:fullscreenElement />
@ -393,7 +415,7 @@
use:focusTrap
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None}
{#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
@ -419,7 +441,7 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
@ -487,6 +509,8 @@
.toLowerCase()
.endsWith('.insp'))}
<PanoramaViewer {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} />
{/if}
@ -516,13 +540,13 @@
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
@ -533,6 +557,17 @@
</div>
{/if}
{#if isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
translate="yes"
>
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
</div>
{/if}
{#if stackedAssets.length > 0 && withStacked}
<div
id="stack-slideshow"

View File

@ -0,0 +1,200 @@
<script lang="ts">
import { onMount, afterUpdate, onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAltText } from '$lib/utils/thumbnail-util';
import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store';
import { draw } from './drawing';
import { onImageLoad, resizeCanvas } from './image-loading';
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
import { recalculateCrop, animateCropChange } from './crop-settings';
import {
changedOriention,
cropAspectRatio,
cropSettings,
resetGlobalCropStore,
rotateDegrees,
} from '$lib/stores/asset-editor.store';
export let asset;
let img: HTMLImageElement;
$: imgElement.set(img);
cropAspectRatio.subscribe((value) => {
if (!img || !$cropAreaEl) {
return;
}
const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true);
if (newCrop) {
animateCropChange($cropSettings, newCrop, () => draw($cropSettings));
}
});
onMount(async () => {
resetGlobalCropStore();
img = new Image();
await tick();
img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum });
img.addEventListener('load', () => onImageLoad(true));
img.addEventListener('error', (error) => {
handleError(error, $t('error_loading_image'));
});
window.addEventListener('mousemove', handleMouseMove);
});
onDestroy(() => {
window.removeEventListener('mousemove', handleMouseMove);
resetCropStore();
resetGlobalCropStore();
});
afterUpdate(() => {
resizeCanvas();
});
</script>
<div class="canvas-container">
<button
class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
style={`rotate:${$rotateDegrees}deg`}
bind:this={$cropAreaEl}
on:mousedown={handleMouseDown}
on:mouseup={handleMouseUp}
aria-label="Crop area"
type="button"
>
<img draggable="false" src={img?.src} alt={$getAltText(asset)} />
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$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={`${$isResizingOrDragging ? 'light' : ''} overlay`} bind:this={$overlayEl}></div>
</button>
</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%;
height: 100%;
user-select: none;
}
.crop-frame {
position: absolute;
border: 2px solid white;
box-sizing: border-box;
pointer-events: none;
z-index: 1;
}
.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;
}
.top-left {
top: var(--mSize);
left: var(--mSize);
border-right: none;
border-bottom: none;
}
.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;
}
.bottom-right {
bottom: var(--mSize);
right: var(--mSize);
border-left: none;
border-top: none;
}
</style>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import Button, { type Color } from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
export let size: {
icon: string;
name: CropAspectRatio;
viewBox: string;
rotate?: boolean;
};
export let selectedSize: CropAspectRatio;
export let rotateHorizontal: boolean;
export let selectType: (size: CropAspectRatio) => void;
$: isSelected = selectedSize === size.name;
$: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color;
$: rotatedTitle = (title: string, toRotate: boolean) => {
let sides = title.split(':');
if (toRotate) {
sides.reverse();
}
return sides.join(':');
};
$: toRotate = (def: boolean | undefined) => {
if (def === false) {
return false;
}
return (def && !rotateHorizontal) || (!def && rotateHorizontal);
};
</script>
<li>
<Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}>
<Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
<span>{rotatedTitle(size.name, rotateHorizontal)}</span>
</Button>
</li>

View File

@ -0,0 +1,159 @@
import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store';
import { get } from 'svelte/store';
import { cropAreaEl } from './crop-store';
import { checkEdits } from './mouse-handlers';
export function recalculateCrop(
crop: CropSettings,
canvas: HTMLElement,
aspectRatio: CropAspectRatio,
returnNewCrop = false,
): CropSettings | null {
const canvasW = canvas.clientWidth;
const canvasH = canvas.clientHeight;
let newWidth = crop.width;
let newHeight = crop.height;
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio);
if (w > canvasW) {
newWidth = canvasW;
newHeight = canvasW / (w / h);
} else if (h > canvasH) {
newHeight = canvasH;
newWidth = canvasH * (w / h);
} else {
newWidth = w;
newHeight = h;
}
const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth));
const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight));
const newCrop = {
width: newWidth,
height: newHeight,
x: newX,
y: newY,
};
if (returnNewCrop) {
setTimeout(() => {
checkEdits();
}, 1);
return newCrop;
} else {
crop.width = newWidth;
crop.height = newHeight;
crop.x = newX;
crop.y = newY;
return null;
}
}
export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) {
const cropArea = get(cropAreaEl);
if (!cropArea) {
return;
}
const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
if (!cropFrame) {
return;
}
const startTime = performance.now();
const initialCrop = { ...crop };
const animate = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress;
crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress;
crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress;
crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress;
draw();
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) {
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
if (widthRatio && heightRatio) {
const calculatedWidth = (newHeight * widthRatio) / heightRatio;
return { newWidth: calculatedWidth, newHeight };
}
return { newWidth, newHeight };
}
export function adjustDimensions(
newWidth: number,
newHeight: number,
aspectRatio: CropAspectRatio,
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 };
}

View File

@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
export const darkenLevel = writable(0.65);
export const isResizingOrDragging = writable(false);
export const animationFrame = writable<ReturnType<typeof requestAnimationFrame> | null>(null);
export const canvasCursor = writable('default');
export const dragOffset = writable({ x: 0, y: 0 });
export const resizeSide = writable('');
export const imgElement = writable<HTMLImageElement | null>(null);
export const cropAreaEl = writable<HTMLElement | null>(null);
export const isDragging = writable<boolean>(false);
export const overlayEl = writable<HTMLElement | null>(null);
export const cropFrame = writable<HTMLElement | null>(null);
export function resetCropStore() {
darkenLevel.set(0.65);
isResizingOrDragging.set(false);
animationFrame.set(null);
canvasCursor.set('default');
dragOffset.set({ x: 0, y: 0 });
resizeSide.set('');
imgElement.set(null);
cropAreaEl.set(null);
isDragging.set(false);
overlayEl.set(null);
}

View File

@ -0,0 +1,151 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
cropAspectRatio,
cropImageScale,
cropImageSize,
cropSettings,
cropSettingsChanged,
normaizedRorateDegrees,
rotateDegrees,
type CropAspectRatio,
} from '$lib/stores/asset-editor.store';
import { mdiBackupRestore, mdiCropFree, mdiRotateLeft, mdiRotateRight, mdiSquareOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onImageLoad } from './image-loading';
import { tick } from 'svelte';
import CropPreset from './crop-preset.svelte';
$: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees);
const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
const icon_7_5 = `M200-200q-33 0-56.5-23.5T120-280v-400q0-33 23.5-56.5T200-760h560q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H200Zm0-80h560v-400H200v400Zm0 0v-400 400Z`;
interface Size {
icon: string;
name: CropAspectRatio;
viewBox: string;
rotate?: boolean;
}
let sizes: Size[] = [
{
icon: mdiCropFree,
name: 'free',
viewBox: '0 0 24 24',
rotate: false,
},
{
name: '1:1',
icon: mdiSquareOutline,
viewBox: '0 0 24 24',
rotate: false,
},
{
name: '16:9',
icon: icon_16_9,
viewBox: '50 -700 840 400',
},
{
name: '4:3',
icon: icon_4_3,
viewBox: '0 0 24 24',
},
{
name: '3:2',
icon: icon_3_2,
viewBox: '50 -720 840 480',
},
{
name: '7:5',
icon: icon_7_5,
viewBox: '50 -760 840 560',
},
{
name: '9:16',
icon: icon_16_9,
viewBox: '50 -700 840 400',
rotate: true,
},
{
name: '3:4',
icon: icon_4_3,
viewBox: '0 0 24 24',
rotate: true,
},
{
name: '2:3',
icon: icon_3_2,
viewBox: '50 -720 840 480',
rotate: true,
},
{
name: '5:7',
icon: icon_7_5,
viewBox: '50 -760 840 560',
rotate: true,
},
{
name: 'reset',
icon: mdiBackupRestore,
viewBox: '0 0 24 24',
rotate: false,
},
];
let selectedSize: CropAspectRatio = 'free';
$cropAspectRatio = selectedSize;
$: sizesRows = [
sizes.filter((s) => s.rotate === false),
sizes.filter((s) => s.rotate === undefined),
sizes.filter((s) => s.rotate === true),
];
async function rotate(clock: boolean) {
rotateDegrees.update((v) => {
return v + 90 * (clock ? 1 : -1);
});
await tick();
onImageLoad();
}
function selectType(size: CropAspectRatio) {
if (size === 'reset') {
selectedSize = 'free';
let cropImageSizeM = $cropImageSize;
let cropImageScaleM = $cropImageScale;
$cropSettings = {
x: 0,
y: 0,
width: cropImageSizeM[0] * cropImageScaleM - 1,
height: cropImageSizeM[1] * cropImageScaleM - 1,
};
$cropAspectRatio = selectedSize;
$cropSettingsChanged = false;
return;
}
selectedSize = size;
$cropAspectRatio = size;
}
</script>
<div class="mt-3 px-4 py-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}</h2>
</div>
{#each sizesRows as sizesRow}
<ul class="flex-wrap flex-row flex gap-x-6 py-2 justify-evenly">
{#each sizesRow as size (size.name)}
<CropPreset {size} {selectedSize} {rotateHorizontal} {selectType} />
{/each}
</ul>
{/each}
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2>
</div>
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
<li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li>
<li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li>
</ul>
</div>

View File

@ -0,0 +1,40 @@
import type { CropSettings } from '$lib/stores/asset-editor.store';
import { get } from 'svelte/store';
import { cropFrame, overlayEl } from './crop-store';
export function draw(crop: CropSettings) {
const mCropFrame = get(cropFrame);
if (!mCropFrame) {
return;
}
mCropFrame.style.left = `${crop.x}px`;
mCropFrame.style.top = `${crop.y}px`;
mCropFrame.style.width = `${crop.width}px`;
mCropFrame.style.height = `${crop.height}px`;
drawOverlay(crop);
}
export function drawOverlay(crop: CropSettings) {
const overlay = get(overlayEl);
if (!overlay) {
return;
}
overlay.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
)
`;
}

View File

@ -0,0 +1,117 @@
import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store';
import { get } from 'svelte/store';
import { cropAreaEl, cropFrame, imgElement } from './crop-store';
import { draw } from './drawing';
export function onImageLoad(resetSize: boolean = false) {
const img = get(imgElement);
const cropArea = get(cropAreaEl);
if (!cropArea || !img) {
return;
}
const containerWidth = cropArea.clientWidth ?? 0;
const containerHeight = cropArea.clientHeight ?? 0;
const scale = calculateScale(img, containerWidth, containerHeight);
cropImageSize.set([img.width, img.height]);
if (resetSize) {
cropSettings.update((crop) => {
crop.x = 0;
crop.y = 0;
crop.width = img.width * scale;
crop.height = img.height * scale;
return crop;
});
} else {
const cropFrameEl = get(cropFrame);
cropFrameEl?.classList.add('transition');
cropSettings.update((crop) => normalizeCropArea(crop, img, scale));
cropFrameEl?.classList.add('transition');
cropFrameEl?.addEventListener('transitionend', () => {
cropFrameEl?.classList.remove('transition');
});
}
cropImageScale.set(scale);
img.style.width = `${img.width * scale}px`;
img.style.height = `${img.height * scale}px`;
draw(get(cropSettings));
}
export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number {
const imageAspectRatio = img.width / img.height;
let scale: number;
if (imageAspectRatio > 1) {
scale = containerWidth / img.width;
if (img.height * scale > containerHeight) {
scale = containerHeight / img.height;
}
} else {
scale = containerHeight / img.height;
if (img.width * scale > containerWidth) {
scale = containerWidth / img.width;
}
}
return scale;
}
export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) {
const prevScale = get(cropImageScale);
const scaleRatio = scale / prevScale;
crop.x *= scaleRatio;
crop.y *= scaleRatio;
crop.width *= scaleRatio;
crop.height *= scaleRatio;
crop.width = Math.min(crop.width, img.width * scale);
crop.height = Math.min(crop.height, img.height * scale);
crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width));
crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height));
return crop;
}
export function resizeCanvas() {
const img = get(imgElement);
const cropArea = get(cropAreaEl);
if (!cropArea || !img) {
return;
}
const containerWidth = cropArea?.clientWidth ?? 0;
const containerHeight = cropArea?.clientHeight ?? 0;
const imageAspectRatio = img.width / img.height;
let scale;
if (imageAspectRatio > 1) {
scale = containerWidth / img.width;
if (img.height * scale > containerHeight) {
scale = containerHeight / img.height;
}
} else {
scale = containerHeight / img.height;
if (img.width * scale > containerWidth) {
scale = containerWidth / img.width;
}
}
img.style.width = `${img.width * scale}px`;
img.style.height = `${img.height * scale}px`;
const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
if (cropFrame) {
cropFrame.style.width = `${img.width * scale}px`;
cropFrame.style.height = `${img.height * scale}px`;
}
draw(get(cropSettings));
}

View File

@ -0,0 +1,536 @@
import {
cropAspectRatio,
cropImageScale,
cropImageSize,
cropSettings,
cropSettingsChanged,
normaizedRorateDegrees,
rotateDegrees,
showCancelConfirmDialog,
type CropSettings,
} from '$lib/stores/asset-editor.store';
import { get } from 'svelte/store';
import { adjustDimensions, keepAspectRatio } from './crop-settings';
import {
canvasCursor,
cropAreaEl,
dragOffset,
isDragging,
isResizingOrDragging,
overlayEl,
resizeSide,
} from './crop-store';
import { draw } from './drawing';
export function handleMouseDown(e: MouseEvent) {
const canvas = get(cropAreaEl);
if (!canvas) {
return;
}
const crop = get(cropSettings);
const { mouseX, mouseY } = getMousePosition(e);
const {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = isOnCropBoundary(mouseX, mouseY, crop);
if (
onTopLeftCorner ||
onTopRightCorner ||
onBottomLeftCorner ||
onBottomRightCorner ||
onLeftBoundary ||
onRightBoundary ||
onTopBoundary ||
onBottomBoundary
) {
setResizeSide(mouseX, mouseY);
} else if (isInCropArea(mouseX, mouseY, crop)) {
startDragging(mouseX, mouseY);
}
document.body.style.userSelect = 'none';
window.addEventListener('mouseup', handleMouseUp);
}
export function handleMouseMove(e: MouseEvent) {
const canvas = get(cropAreaEl);
if (!canvas) {
return;
}
const resizeSideValue = get(resizeSide);
const { mouseX, mouseY } = getMousePosition(e);
if (get(isDragging)) {
moveCrop(mouseX, mouseY);
} else if (resizeSideValue) {
resizeCrop(mouseX, mouseY);
} else {
updateCursor(mouseX, mouseY);
}
}
export function handleMouseUp() {
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
stopInteraction();
}
function getMousePosition(e: MouseEvent) {
let offsetX = e.clientX;
let offsetY = e.clientY;
const clienRect = getBoundingClientRectCached(get(cropAreaEl));
const rotateDeg = get(normaizedRorateDegrees);
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 };
}
type BoundingClientRect = ReturnType<HTMLElement['getBoundingClientRect']>;
let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = {
data: null,
time: 0,
};
rotateDegrees.subscribe(() => {
getBoundingClientRectCache.time = 0;
});
function getBoundingClientRectCached(el: HTMLElement | null) {
if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) {
getBoundingClientRectCache = {
time: Date.now(),
data: el?.getBoundingClientRect() ?? null,
};
}
return getBoundingClientRectCache.data;
}
function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) {
const { x, y, width, height } = crop;
const sensitivity = 10;
const cornerSensitivity = 15;
const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || 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 = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height;
const onRightBoundary =
mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height;
const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width;
const onBottomBoundary =
mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width;
const onTopLeftCorner =
mouseX >= x - cornerSensitivity &&
mouseX <= x + cornerSensitivity &&
mouseY >= y - cornerSensitivity &&
mouseY <= y + cornerSensitivity;
const onTopRightCorner =
mouseX >= x + width - cornerSensitivity &&
mouseX <= x + width + cornerSensitivity &&
mouseY >= y - cornerSensitivity &&
mouseY <= y + cornerSensitivity;
const onBottomLeftCorner =
mouseX >= x - cornerSensitivity &&
mouseX <= x + cornerSensitivity &&
mouseY >= y + height - cornerSensitivity &&
mouseY <= y + height + cornerSensitivity;
const onBottomRightCorner =
mouseX >= x + width - cornerSensitivity &&
mouseX <= x + width + cornerSensitivity &&
mouseY >= y + height - cornerSensitivity &&
mouseY <= y + height + cornerSensitivity;
return {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
};
}
function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) {
const { x, y, width, height } = crop;
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
}
function setResizeSide(mouseX: number, mouseY: number) {
const crop = get(cropSettings);
const {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = isOnCropBoundary(mouseX, mouseY, crop);
if (onTopLeftCorner) {
resizeSide.set('top-left');
} else if (onTopRightCorner) {
resizeSide.set('top-right');
} else if (onBottomLeftCorner) {
resizeSide.set('bottom-left');
} else if (onBottomRightCorner) {
resizeSide.set('bottom-right');
} else if (onLeftBoundary) {
resizeSide.set('left');
} else if (onRightBoundary) {
resizeSide.set('right');
} else if (onTopBoundary) {
resizeSide.set('top');
} else if (onBottomBoundary) {
resizeSide.set('bottom');
}
}
function startDragging(mouseX: number, mouseY: number) {
isDragging.set(true);
const crop = get(cropSettings);
isResizingOrDragging.set(true);
dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y });
fadeOverlay(false);
}
function moveCrop(mouseX: number, mouseY: number) {
const cropArea = get(cropAreaEl);
if (!cropArea) {
return;
}
const crop = get(cropSettings);
const { x, y } = get(dragOffset);
let newX = mouseX - x;
let newY = mouseY - y;
newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX));
newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY));
cropSettings.update((crop) => {
crop.x = newX;
crop.y = newY;
return crop;
});
draw(crop);
}
function resizeCrop(mouseX: number, mouseY: number) {
const canvas = get(cropAreaEl);
const crop = get(cropSettings);
const resizeSideValue = get(resizeSide);
if (!canvas || !resizeSideValue) {
return;
}
fadeOverlay(false);
const { x, y, width, height } = crop;
const minSize = 50;
let newWidth = width;
let newHeight = height;
switch (resizeSideValue) {
case 'left': {
newWidth = width + x - mouseX;
newHeight = height;
if (newWidth >= minSize && mouseX >= 0) {
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
cropSettings.update((crop) => {
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth));
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
crop.x = Math.max(0, x + width - crop.width);
return crop;
});
}
break;
}
case 'right': {
newWidth = mouseX - x;
newHeight = height;
if (newWidth >= minSize && mouseX <= canvas.clientWidth) {
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
cropSettings.update((crop) => {
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x));
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
return crop;
});
}
break;
}
case 'top': {
newHeight = height + y - mouseY;
newWidth = width;
if (newHeight >= minSize && mouseY >= 0) {
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth,
canvas.clientHeight,
minSize,
);
cropSettings.update((crop) => {
crop.y = Math.max(0, y + height - h);
crop.width = w;
crop.height = h;
return crop;
});
}
break;
}
case 'bottom': {
newHeight = mouseY - y;
newWidth = width;
if (newHeight >= minSize && mouseY <= canvas.clientHeight) {
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
cropSettings.update((crop) => {
crop.width = w;
crop.height = h;
return crop;
});
}
break;
}
case 'top-left': {
newWidth = width + x - Math.max(mouseX, 0);
newHeight = height + y - Math.max(mouseY, 0);
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth,
canvas.clientHeight,
minSize,
);
cropSettings.update((crop) => {
crop.width = w;
crop.height = h;
crop.x = Math.max(0, x + width - crop.width);
crop.y = Math.max(0, y + height - crop.height);
return crop;
});
break;
}
case 'top-right': {
newWidth = Math.max(mouseX, 0) - x;
newHeight = height + y - Math.max(mouseY, 0);
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth - x,
y + height,
minSize,
);
cropSettings.update((crop) => {
crop.width = w;
crop.height = h;
crop.y = Math.max(0, y + height - crop.height);
return crop;
});
break;
}
case 'bottom-left': {
newWidth = width + x - Math.max(mouseX, 0);
newHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
cropSettings.update((crop) => {
crop.width = w;
crop.height = h;
crop.x = Math.max(0, x + width - crop.width);
return crop;
});
break;
}
case 'bottom-right': {
newWidth = Math.max(mouseX, 0) - x;
newHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = adjustDimensions(
newWidth,
newHeight,
get(cropAspectRatio),
canvas.clientWidth - x,
canvas.clientHeight - y,
minSize,
);
cropSettings.update((crop) => {
crop.width = w;
crop.height = h;
return crop;
});
break;
}
}
cropSettings.update((crop) => {
crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width));
crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height));
return crop;
});
draw(crop);
}
function updateCursor(mouseX: number, mouseY: number) {
const canvas = get(cropAreaEl);
if (!canvas) {
return;
}
const crop = get(cropSettings);
const rotateDeg = get(normaizedRorateDegrees);
let {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = isOnCropBoundary(mouseX, mouseY, crop);
if (rotateDeg == 90) {
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
onLeftBoundary,
onTopBoundary,
onRightBoundary,
onBottomBoundary,
];
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
onBottomLeftCorner,
onTopLeftCorner,
onTopRightCorner,
onBottomRightCorner,
];
} else if (rotateDeg == 180) {
[onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
[onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
[onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
[onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
} else if (rotateDeg == 270) {
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
onRightBoundary,
onBottomBoundary,
onLeftBoundary,
onTopBoundary,
];
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
onTopRightCorner,
onBottomRightCorner,
onBottomLeftCorner,
onTopLeftCorner,
];
}
if (onTopLeftCorner || onBottomRightCorner) {
setCursor('nwse-resize');
} else if (onTopRightCorner || onBottomLeftCorner) {
setCursor('nesw-resize');
} else if (onLeftBoundary || onRightBoundary) {
setCursor('ew-resize');
} else if (onTopBoundary || onBottomBoundary) {
setCursor('ns-resize');
} else if (isInCropArea(mouseX, mouseY, crop)) {
setCursor('move');
} else {
setCursor('default');
}
function setCursor(cursorName: string) {
if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) {
canvasCursor.set(cursorName);
document.body.style.cursor = cursorName;
canvas.style.cursor = cursorName;
}
}
}
function stopInteraction() {
isResizingOrDragging.set(false);
isDragging.set(false);
resizeSide.set('');
fadeOverlay(true); // Darken the background
setTimeout(() => {
checkEdits();
}, 1);
}
export function checkEdits() {
const cropImageSizeParams = get(cropSettings);
const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale));
const changed =
Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 ||
Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2;
cropSettingsChanged.set(changed);
}
function fadeOverlay(toDark: boolean) {
const overlay = get(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');
}
isResizingOrDragging.set(!toDark);
}

View File

@ -0,0 +1,76 @@
<script lang="ts">
import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { shortcut } from '$lib/actions/shortcut';
export let asset: AssetResponseDto;
onMount(() => {
return websocketEvents.on('on_asset_update', (assetUpdate) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
});
});
export let onUpdateSelectedType: (type: string) => void;
export let onClose: () => void;
let selectedType: string = editTypes[0].name;
$: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
setTimeout(() => {
onUpdateSelectedType(selectedType);
}, 1);
function selectType(name: string) {
selectedType = name;
onUpdateSelectedType(selectedType);
}
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
</div>
<section class="px-4 py-4">
<ul class="flex w-full justify-around">
{#each editTypes as etype (etype.name)}
<li>
<CircleIconButton
color={etype.name === selectedType ? 'primary' : 'opaque'}
icon={etype.icon}
title={etype.name}
on:click={() => selectType(etype.name)}
/>
</li>
{/each}
</ul>
</section>
<section>
<svelte:component this={selectedTypeObj.component} />
</section>
</section>
{#if $showCancelConfirmDialog}
<ConfirmDialog
title={$t('editor_close_without_save_title')}
prompt={$t('editor_close_without_save_prompt')}
cancelText={$t('no')}
cancelColor="secondary"
confirmColor="red"
confirmText={$t('close')}
onCancel={() => {
$showCancelConfirmDialog = false;
}}
onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())}
/>
{/if}

View File

@ -359,6 +359,7 @@
"allow_edits": "Allow edits",
"allow_public_user_to_download": "Allow public user to download",
"allow_public_user_to_upload": "Allow public user to upload",
"anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
"api_key_empty": "Your API Key name shouldn't be empty",
@ -434,6 +435,7 @@
"clear_all_recent_searches": "Clear all recent searches",
"clear_message": "Clear message",
"clear_value": "Clear value",
"clockwise": "Сlockwise",
"close": "Close",
"collapse": "Collapse",
"collapse_all": "Collapse all",
@ -535,6 +537,11 @@
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edited": "Edited",
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"email": "Email",
"empty_trash": "Empty trash",
"empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!",

View File

@ -360,6 +360,7 @@
"allow_edits": "Разрешить редактирование",
"allow_public_user_to_download": "Разрешить скачивание публичным пользователям",
"allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы",
"anti_clockwise": "Против часовой",
"api_key": "API Ключ",
"api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.",
"api_key_empty": "Ваш API ключ не должен быть пустым",
@ -441,6 +442,7 @@
"clear_all_recent_searches": "Очистить все недавние результаты поиска",
"clear_message": "Очистить сообщение",
"clear_value": "Очистить значение",
"clockwise": "По часовой",
"close": "Закрыть",
"collapse": "Свернуть",
"collapse_all": "Свернуть всё",
@ -550,6 +552,10 @@
"edit_user": "Редактировать пользователя",
"edited": "Отредактировано",
"editor": "Редактор",
"editor_close_without_save_prompt": "Изменения не будут сохранены",
"editor_close_without_save_title": "Закрыть редактор?",
"editor_crop_tool_h2_aspect_ratios": "Соотношения сторон",
"editor_crop_tool_h2_rotation": "Вращение",
"email": "Электронная почта",
"empty": "",
"empty_album": "Пустой альбом",

View File

@ -0,0 +1,73 @@
import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte';
import { mdiCropRotate } from '@mdi/js';
import { derived, get, writable } from 'svelte/store';
//---------crop
export const cropSettings = writable<CropSettings>({ x: 0, y: 0, width: 100, height: 100 });
export const cropImageSize = writable([1000, 1000]);
export const cropImageScale = writable(1);
export const cropAspectRatio = writable<CropAspectRatio>('free');
export const cropSettingsChanged = writable<boolean>(false);
//---------rotate
export const rotateDegrees = writable<number>(0);
export const normaizedRorateDegrees = derived(rotateDegrees, (v) => {
const newAngle = v % 360;
return newAngle < 0 ? newAngle + 360 : newAngle;
});
export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0);
//-----other
export const showCancelConfirmDialog = writable<boolean | CallableFunction>(false);
export const editTypes = [
{
name: 'crop',
icon: mdiCropRotate,
component: CropTool,
changesFlag: cropSettingsChanged,
},
];
export function closeEditorCofirm(closeCallback: CallableFunction) {
if (get(hasChanges)) {
showCancelConfirmDialog.set(closeCallback);
} else {
closeCallback();
}
}
export const hasChanges = derived(
editTypes.map((t) => t.changesFlag),
($flags) => {
return $flags.some(Boolean);
},
);
export function resetGlobalCropStore() {
cropSettings.set({ x: 0, y: 0, width: 100, height: 100 });
cropImageSize.set([1000, 1000]);
cropImageScale.set(1);
cropAspectRatio.set('free');
cropSettingsChanged.set(false);
showCancelConfirmDialog.set(false);
rotateDegrees.set(0);
}
export type CropAspectRatio =
| '1:1'
| '16:9'
| '4:3'
| '3:2'
| '7:5'
| '9:16'
| '3:4'
| '2:3'
| '5:7'
| 'free'
| 'reset';
export type CropSettings = {
x: number;
y: number;
width: number;
height: number;
};