mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 12:15:47 -04: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 onRunJob: (name: AssetJobName) => void;
|
||||||
export let onPlaySlideshow: () => void;
|
export let onPlaySlideshow: () => void;
|
||||||
export let onShowDetail: () => void;
|
export let onShowDetail: () => void;
|
||||||
|
// export let showEditorHandler: () => void;
|
||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
|
|
||||||
const sharedLink = getSharedLink();
|
const sharedLink = getSharedLink();
|
||||||
|
|
||||||
$: isOwner = $user && asset.ownerId === $user?.id;
|
$: isOwner = $user && asset.ownerId === $user?.id;
|
||||||
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
$: 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -98,6 +108,15 @@
|
|||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<FavoriteAction {asset} {onAction} />
|
<FavoriteAction {asset} {onAction} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- {#if showEditorButton}
|
||||||
|
<CircleIconButton
|
||||||
|
color="opaque"
|
||||||
|
hideMobile={true}
|
||||||
|
icon={mdiImageEditOutline}
|
||||||
|
on:click={showEditorHandler}
|
||||||
|
title={$t('editor')}
|
||||||
|
/>
|
||||||
|
{/if} -->
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<DeleteAction {asset} {onAction} />
|
<DeleteAction {asset} {onAction} />
|
||||||
|
@ -45,7 +45,9 @@
|
|||||||
import PhotoViewer from './photo-viewer.svelte';
|
import PhotoViewer from './photo-viewer.svelte';
|
||||||
import SlideshowBar from './slideshow-bar.svelte';
|
import SlideshowBar from './slideshow-bar.svelte';
|
||||||
import VideoViewer from './video-wrapper-viewer.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 assetStore: AssetStore | null = null;
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
export let preloadAssets: AssetResponseDto[] = [];
|
export let preloadAssets: AssetResponseDto[] = [];
|
||||||
@ -80,6 +82,7 @@
|
|||||||
let shuffleSlideshowUnsubscribe: () => void;
|
let shuffleSlideshowUnsubscribe: () => void;
|
||||||
let previewStackedAsset: AssetResponseDto | undefined;
|
let previewStackedAsset: AssetResponseDto | undefined;
|
||||||
let isShowActivity = false;
|
let isShowActivity = false;
|
||||||
|
let isShowEditor = false;
|
||||||
let isLiked: ActivityResponseDto | null = null;
|
let isLiked: ActivityResponseDto | null = null;
|
||||||
let numberOfComments: number;
|
let numberOfComments: number;
|
||||||
let fullscreenElement: Element;
|
let fullscreenElement: Element;
|
||||||
@ -272,6 +275,12 @@
|
|||||||
await navigate({ targetRoute: 'current', assetId: null });
|
await navigate({ targetRoute: 'current', assetId: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
closeEditorCofirm(() => {
|
||||||
|
isShowEditor = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const navigateAssetRandom = async () => {
|
const navigateAssetRandom = async () => {
|
||||||
if (!assetStore) {
|
if (!assetStore) {
|
||||||
return;
|
return;
|
||||||
@ -315,6 +324,13 @@
|
|||||||
dispatch(order);
|
dispatch(order);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// const showEditorHandler = () => {
|
||||||
|
// if (isShowActivity) {
|
||||||
|
// isShowActivity = false;
|
||||||
|
// }
|
||||||
|
// isShowEditor = !isShowEditor;
|
||||||
|
// };
|
||||||
|
|
||||||
const handleRunJob = async (name: AssetJobName) => {
|
const handleRunJob = async (name: AssetJobName) => {
|
||||||
try {
|
try {
|
||||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||||
@ -383,6 +399,12 @@
|
|||||||
|
|
||||||
onAction?.(action);
|
onAction?.(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let selectedEditType: string = '';
|
||||||
|
|
||||||
|
function handleUpdateSelectedEditType(type: string) {
|
||||||
|
selectedEditType = type;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document bind:fullscreenElement />
|
<svelte:document bind:fullscreenElement />
|
||||||
@ -393,7 +415,7 @@
|
|||||||
use:focusTrap
|
use:focusTrap
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
<!-- 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">
|
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||||
<AssetViewerNavBar
|
<AssetViewerNavBar
|
||||||
{asset}
|
{asset}
|
||||||
@ -419,7 +441,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<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')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
@ -487,6 +509,8 @@
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.endsWith('.insp'))}
|
.endsWith('.insp'))}
|
||||||
<PanoramaViewer {asset} />
|
<PanoramaViewer {asset} />
|
||||||
|
{:else if isShowEditor && selectedEditType === 'crop'}
|
||||||
|
<CropArea {asset} />
|
||||||
{:else}
|
{:else}
|
||||||
<PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} />
|
<PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} />
|
||||||
{/if}
|
{/if}
|
||||||
@ -516,13 +540,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<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')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail}
|
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
@ -533,6 +557,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if stackedAssets.length > 0 && withStacked}
|
||||||
<div
|
<div
|
||||||
id="stack-slideshow"
|
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_edits": "Allow edits",
|
||||||
"allow_public_user_to_download": "Allow public user to download",
|
"allow_public_user_to_download": "Allow public user to download",
|
||||||
"allow_public_user_to_upload": "Allow public user to upload",
|
"allow_public_user_to_upload": "Allow public user to upload",
|
||||||
|
"anti_clockwise": "Anti-clockwise",
|
||||||
"api_key": "API Key",
|
"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_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",
|
"api_key_empty": "Your API Key name shouldn't be empty",
|
||||||
@ -434,6 +435,7 @@
|
|||||||
"clear_all_recent_searches": "Clear all recent searches",
|
"clear_all_recent_searches": "Clear all recent searches",
|
||||||
"clear_message": "Clear message",
|
"clear_message": "Clear message",
|
||||||
"clear_value": "Clear value",
|
"clear_value": "Clear value",
|
||||||
|
"clockwise": "Сlockwise",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"collapse_all": "Collapse all",
|
"collapse_all": "Collapse all",
|
||||||
@ -535,6 +537,11 @@
|
|||||||
"edit_title": "Edit Title",
|
"edit_title": "Edit Title",
|
||||||
"edit_user": "Edit user",
|
"edit_user": "Edit user",
|
||||||
"edited": "Edited",
|
"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",
|
"email": "Email",
|
||||||
"empty_trash": "Empty trash",
|
"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!",
|
"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_edits": "Разрешить редактирование",
|
||||||
"allow_public_user_to_download": "Разрешить скачивание публичным пользователям",
|
"allow_public_user_to_download": "Разрешить скачивание публичным пользователям",
|
||||||
"allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы",
|
"allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы",
|
||||||
|
"anti_clockwise": "Против часовой",
|
||||||
"api_key": "API Ключ",
|
"api_key": "API Ключ",
|
||||||
"api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.",
|
"api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.",
|
||||||
"api_key_empty": "Ваш API ключ не должен быть пустым",
|
"api_key_empty": "Ваш API ключ не должен быть пустым",
|
||||||
@ -441,6 +442,7 @@
|
|||||||
"clear_all_recent_searches": "Очистить все недавние результаты поиска",
|
"clear_all_recent_searches": "Очистить все недавние результаты поиска",
|
||||||
"clear_message": "Очистить сообщение",
|
"clear_message": "Очистить сообщение",
|
||||||
"clear_value": "Очистить значение",
|
"clear_value": "Очистить значение",
|
||||||
|
"clockwise": "По часовой",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
"collapse_all": "Свернуть всё",
|
"collapse_all": "Свернуть всё",
|
||||||
@ -550,6 +552,10 @@
|
|||||||
"edit_user": "Редактировать пользователя",
|
"edit_user": "Редактировать пользователя",
|
||||||
"edited": "Отредактировано",
|
"edited": "Отредактировано",
|
||||||
"editor": "Редактор",
|
"editor": "Редактор",
|
||||||
|
"editor_close_without_save_prompt": "Изменения не будут сохранены",
|
||||||
|
"editor_close_without_save_title": "Закрыть редактор?",
|
||||||
|
"editor_crop_tool_h2_aspect_ratios": "Соотношения сторон",
|
||||||
|
"editor_crop_tool_h2_rotation": "Вращение",
|
||||||
"email": "Электронная почта",
|
"email": "Электронная почта",
|
||||||
"empty": "",
|
"empty": "",
|
||||||
"empty_album": "Пустой альбом",
|
"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…
x
Reference in New Issue
Block a user