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:
parent
593f036c0d
commit
7f7fec2cea
@ -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} />
|
||||
|
@ -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"
|
||||
|
@ -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>
|
@ -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>
|
@ -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 };
|
||||
}
|
@ -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);
|
||||
}
|
@ -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>
|
@ -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
|
||||
)
|
||||
`;
|
||||
}
|
@ -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));
|
||||
}
|
@ -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);
|
||||
}
|
@ -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}
|
@ -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!",
|
||||
|
@ -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": "Пустой альбом",
|
||||
|
73
web/src/lib/stores/asset-editor.store.ts
Normal file
73
web/src/lib/stores/asset-editor.store.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user