Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 61 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6154c9c
cropping, panel
ilyaChuk Jul 12, 2024
e23fdaf
fix presets
ilyaChuk Jul 12, 2024
100f83d
types
ilyaChuk Jul 13, 2024
31790b2
Merge branch 'immich-app:main' into main
ilyaChuk Jul 13, 2024
539d271
prettier
ilyaChuk Jul 13, 2024
1cb58b9
fix lint
ilyaChuk Jul 13, 2024
aa21e08
Merge branch 'immich-app:main' into main
ilyaChuk Jul 28, 2024
0a8bab6
fix aspect ratio, performance optimization
ilyaChuk Jul 28, 2024
c81bda4
improved tool selection, removed placeholder
ilyaChuk Jul 29, 2024
71ea55f
fix the mouse's exit from canvas
ilyaChuk Jul 29, 2024
74bac38
fix error
ilyaChuk Jul 29, 2024
21731e8
the "save" button and change tracking
ilyaChuk Jul 29, 2024
dcfd221
lint, format
ilyaChuk Jul 29, 2024
e498055
the mini functionality of the save button
ilyaChuk Jul 29, 2024
c5cff01
fix aspect ratio
ilyaChuk Jul 29, 2024
0f01afe
hide editor button on mobiles
ilyaChuk Jul 29, 2024
019ed53
strict equality
ilyaChuk Jul 29, 2024
1eb7f1c
Use the dollar sign syntax for stores inside components
ilyaChuk Jul 29, 2024
9de3298
unobtrusive grid lines, circles at the corners
ilyaChuk Jul 29, 2024
b9e2f6a
more correct image load, handleError
ilyaChuk Jul 29, 2024
f982a54
more strict equality
ilyaChuk Jul 29, 2024
7cba82e
fix styles. unused and tailwind
ilyaChuk Jul 29, 2024
d17c0da
dont store isShowEditor
ilyaChuk Jul 29, 2024
840d55f
if showEditor - hide navbar & shortcuts
ilyaChuk Jul 29, 2024
574da14
crop-canvas decomposition (danger)
ilyaChuk Jul 29, 2024
7d0e3c8
Merge branch 'main' into main
ilyaChuk Jul 29, 2024
25f8d2d
fix lint
ilyaChuk Jul 29, 2024
1f1b590
Merge branch 'main' into main
ilyaChuk Jul 30, 2024
0790090
fix ts
ilyaChuk Jul 30, 2024
82e6577
Merge branch 'main' into main
ilyaChuk Jul 30, 2024
ef1ff62
callback function as props
ilyaChuk Jul 30, 2024
bfe85a3
correctly disabling shortcuts
ilyaChuk Jul 30, 2024
2bd17ba
convenient canvas borders
ilyaChuk Jul 30, 2024
4bc6beb
-the editor button for video files, -save button
ilyaChuk Jul 30, 2024
66b29f3
Merge branch 'main' into main
ilyaChuk Jul 30, 2024
22e83ee
hide editor btn if panoramic || gif || live
ilyaChuk Jul 30, 2024
9815252
corners instead of circles (preview), fix lint&format
ilyaChuk Jul 31, 2024
a8dd073
confirm close editor without save
ilyaChuk Jul 31, 2024
cadd413
vertical aspect ratios
ilyaChuk Jul 31, 2024
ba97426
Merge remote-tracking branch 'upstream/main'
ilyaChuk Jul 31, 2024
f3d4d76
recovery after merge. editor's closing shortcut
ilyaChuk Jul 31, 2024
5eef4b3
fix format
ilyaChuk Jul 31, 2024
8f6cc65
Merge branch 'main' into main
ilyaChuk Aug 9, 2024
2f1da40
move from canvas to html elements
ilyaChuk Aug 9, 2024
5028a96
fix changes detections
ilyaChuk Aug 9, 2024
6678521
rotation
ilyaChuk Aug 10, 2024
1ed0647
hide detail panel if showing editor
ilyaChuk Aug 10, 2024
20cd0b8
fix aspect ratios near min size
ilyaChuk Aug 10, 2024
bc6e18c
Merge branch 'main' into main
ilyaChuk Aug 10, 2024
73ab707
fix crop area when changing image size when rotate
ilyaChuk Aug 10, 2024
219fff9
fix of fix
ilyaChuk Aug 10, 2024
dbb3fe4
better layout - grouping
ilyaChuk Aug 10, 2024
ae0c5a3
Merge branch 'main' into main
ilyaChuk Aug 12, 2024
53e57ea
Merge branch 'main' into main
ilyaChuk Aug 12, 2024
69043f8
Merge branch 'main' into main
ilyaChuk Aug 13, 2024
59c015f
Merge branch 'main' into main
ilyaChuk Aug 14, 2024
bfd6cbe
hide the button
alextran1502 Aug 14, 2024
cb1e541
Merge branch 'main' into main
ilyaChuk Aug 14, 2024
63bfc43
fix i18n, format
ilyaChuk Aug 14, 2024
c154d00
hide button
ilyaChuk Aug 14, 2024
857f02a
hide button v2
ilyaChuk Aug 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 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 @@ -15,6 +15,7 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { ProjectionType } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
Expand All @@ -26,6 +27,7 @@
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDotsVertical,
mdiImageEditOutline,
mdiImageRefreshOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
Expand All @@ -47,12 +49,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 +110,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
44 changes: 40 additions & 4 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 CropCanvas from './editor/crop-tool/crop-canvas.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 @@ -259,6 +262,12 @@
await navigate({ targetRoute: 'current', assetId: null });
};

const closeEditor = () => {
closeEditorCofirm(() => {
isShowEditor = false;
});
};

const navigateAssetRandom = async () => {
if (!assetStore) {
return;
Expand Down Expand Up @@ -302,6 +311,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 @@ -370,6 +386,12 @@

onAction?.(action);
};

let selectedEditType: string = '';

function handleUpdateSelectedEditType(type: string) {
selectedEditType = type;
}
</script>

<svelte:document bind:fullscreenElement />
Expand All @@ -380,7 +402,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 @@ -393,6 +415,7 @@
onCopyImage={copyImage}
onAction={handleAction}
onRunJob={handleRunJob}
{showEditorHandler}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onShowDetail={toggleDetailPanel}
onClose={closeViewer}
Expand All @@ -406,7 +429,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 @@ -474,6 +497,8 @@
.toLowerCase()
.endsWith('.insp'))}
<PanoramaViewer {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropCanvas {asset} />
{:else}
<PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} />
{/if}
Expand Down Expand Up @@ -503,7 +528,7 @@
{/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>
Expand All @@ -520,6 +545,17 @@
</div>
{/if}

{#if isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="z-[1002] row-start-1 row-span-4 w-[460px] 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
118 changes: 118 additions & 0 deletions web/src/lib/components/asset-viewer/editor/crop-tool/canvas-drawing.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm starting to think that using regular HTML elements for the controls might be a better choice. I can think of a couple advantages:

  1. Can be styled with CSS
  2. Event handling is easier with essentially built-in hit detection and likely better support for touch devices
  3. Accessibility features can be added

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make a rotation tool yesterday. I had to repeat the course of linear algebra to remember how to work with matrices and vectors 💀.
Yes, the canvas creates difficulties. After yesterday, I also think about switching to html elements

Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { CropSettings } from '$lib/stores/asset-editor.store';
import { get } from 'svelte/store';
import { context2D, darkenLevel, imgElement, isResizingOrDragging, padding } from './crop-store';
const mPadding = get(padding);
export function draw(canvas: HTMLCanvasElement | null, crop: CropSettings) {
const ctx = get(context2D);
const img = get(imgElement);
if (!ctx || !canvas || !img) {
return;
}

ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, mPadding, mPadding, canvas.width - 2 * mPadding, canvas.height - 2 * mPadding);
drawOverlay(canvas, crop);
drawCropRect(crop);
}

export function drawCropRect(crop: CropSettings) {
const ctx = get(context2D);
if (!ctx) {
return;
}

ctx.globalCompositeOperation = 'exclusion';
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.strokeRect(crop.x + mPadding, crop.y + mPadding, crop.width, crop.height);

if (get(isResizingOrDragging)) {
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;

const thirdWidth = crop.width / 3;
ctx.beginPath();
ctx.moveTo(crop.x + thirdWidth + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + thirdWidth + mPadding, crop.y + crop.height + mPadding);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(crop.x + 2 * thirdWidth + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + 2 * thirdWidth + mPadding, crop.y + crop.height + mPadding);
ctx.stroke();

const thirdHeight = crop.height / 3;
ctx.beginPath();
ctx.moveTo(crop.x + mPadding, crop.y + thirdHeight + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding, crop.y + thirdHeight + mPadding);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(crop.x + mPadding, crop.y + 2 * thirdHeight + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding, crop.y + 2 * thirdHeight + mPadding);
ctx.stroke();
}

ctx.globalCompositeOperation = 'source-over';
//dynamic corners size
const minSide = Math.min(crop.height, crop.width);
const length = minSide > 18 * 5 ? 18 : Math.min(18, minSide / 5);

ctx.strokeStyle = 'white';
ctx.lineWidth = mPadding * 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';

ctx.beginPath();
ctx.moveTo(crop.x + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + mPadding + length, crop.y + mPadding);
ctx.moveTo(crop.x + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + mPadding, crop.y + mPadding + length);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(crop.x + crop.width + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding - length, crop.y + mPadding);
ctx.moveTo(crop.x + crop.width + mPadding, crop.y + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding, crop.y + mPadding + length);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(crop.x + mPadding, crop.y + crop.height + mPadding);
ctx.lineTo(crop.x + mPadding + length, crop.y + crop.height + mPadding);
ctx.moveTo(crop.x + mPadding, crop.y + crop.height + mPadding);
ctx.lineTo(crop.x + mPadding, crop.y + crop.height + mPadding - length);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(crop.x + crop.width + mPadding, crop.y + crop.height + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding - length, crop.y + crop.height + mPadding);
ctx.moveTo(crop.x + crop.width + mPadding, crop.y + crop.height + mPadding);
ctx.lineTo(crop.x + crop.width + mPadding, crop.y + crop.height + mPadding - length);
ctx.stroke();
}

export function drawOverlay(canvas: HTMLCanvasElement, crop: CropSettings) {
const ctx = get(context2D);
const darken = get(darkenLevel);
if (!ctx) {
return;
}

ctx.fillStyle = `rgba(0, 0, 0, ${darken})`;

ctx.fillRect(mPadding, mPadding, canvas.width - 2 * mPadding, crop.y);
ctx.fillRect(mPadding, crop.y + mPadding, crop.x, crop.height);
ctx.fillRect(
crop.x + crop.width + mPadding,
crop.y + mPadding,
canvas.width - crop.x - crop.width - 2 * mPadding,
crop.height,
);
ctx.fillRect(
mPadding,
crop.y + crop.height + mPadding,
canvas.width - 2 * mPadding,
canvas.height - crop.y - crop.height - 2 * mPadding,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script lang="ts">
import { onMount, afterUpdate, onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';

import { imgElement, canvasElement, resetCropStore } from './crop-store';

import { draw } from './canvas-drawing';
import { onImageLoad, resizeCanvas } from './image-loading';
import { handleMouseDown, handleMouseMove, handleMouseUp, handleMouseOut } from './mouse-handlers';
import { recalculateCrop, animateCropChange } from './crop-settings';
import { cropAspectRatio, cropSettings, resetGlobalCropStore } from '$lib/stores/asset-editor.store';

export let asset;

let canvas: HTMLCanvasElement;
let img: HTMLImageElement;

$: imgElement.set(img);
$: canvasElement.set(canvas);

cropAspectRatio.subscribe((value) => {
if (!$imgElement || !$canvasElement) {
return;
}
const newCrop = recalculateCrop($cropSettings, $canvasElement, value, true);
if (newCrop) {
animateCropChange($cropSettings, newCrop, () => draw($canvasElement, $cropSettings));
}
});

onMount(() => {
resetCropStore();
resetGlobalCropStore();
$imgElement = new Image();
$imgElement.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum });
$imgElement.addEventListener('load', onImageLoad);
$imgElement.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">
<canvas bind:this={canvas} on:mousedown={handleMouseDown} on:mouseup={handleMouseUp} on:blur={handleMouseOut}
></canvas>
</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;
}
canvas {
cursor: default;
}
</style>
Loading
Loading