Skip to content

Commit

Permalink
feat(web): image editor - panel and cropping (immich-app#11074)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Alex Tran <[email protected]>
  • Loading branch information
3 people authored and Yuvi-raj-P committed Aug 19, 2024
1 parent 8a12a0c commit dfc2fed
Show file tree
Hide file tree
Showing 14 changed files with 1,491 additions and 5 deletions.
19 changes: 19 additions & 0 deletions web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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} />
Expand Down
45 changes: 40 additions & 5 deletions web/src/lib/components/asset-viewer/asset-viewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -272,6 +275,12 @@
await navigate({ targetRoute: 'current', assetId: null });
};
const closeEditor = () => {
closeEditorCofirm(() => {
isShowEditor = false;
});
};
const navigateAssetRandom = async () => {
if (!assetStore) {
return;
Expand Down Expand Up @@ -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 } });
Expand Down Expand Up @@ -383,6 +399,12 @@
onAction?.(action);
};
let selectedEditType: string = '';
function handleUpdateSelectedEditType(type: string) {
selectedEditType = type;
}
</script>

<svelte:document bind:fullscreenElement />
Expand All @@ -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}
Expand All @@ -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>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
200 changes: 200 additions & 0 deletions web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit dfc2fed

Please sign in to comment.