feat: image editing (#24155)

This commit is contained in:
Brandon Wees
2026-01-09 17:59:52 -05:00
committed by GitHub
parent 76241a7b2b
commit e8c80d88a5
141 changed files with 7836 additions and 1634 deletions
@@ -0,0 +1,20 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onAction: () => void;
}
let { onAction }: Props = $props();
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiTune}
aria-label={$t('editor')}
onclick={() => onAction()}
/>
@@ -72,7 +72,7 @@
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
// export let showEditorHandler: () => void;
// onEdit: () => void;
onClose?: () => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
@@ -92,6 +92,7 @@
onRunJob,
onPlaySlideshow,
onClose,
// onEdit,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
@@ -114,15 +115,18 @@
const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } =
$derived(getAssetActions($t, asset));
// $: showEditorButton =
// TODO: Enable when edits are ready for release
// let showEditorButton = $derived(
// 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;
// asset.type === AssetTypeEnum.Image &&
// !(
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
// ) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
// !asset.livePhotoVideoId,
// );
</script>
<CommandPaletteDefaultProvider
@@ -175,6 +179,10 @@
<RatingAction {asset} {onAction} />
{/if}
<!-- {#if showEditorButton}
<EditAction onAction={onEdit} />
{/if} -->
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
@@ -10,8 +10,8 @@
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -44,8 +44,8 @@
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import CropArea from './editor/transform-tool/crop-area.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
@@ -67,6 +67,7 @@
isShared?: boolean;
album?: AlbumResponseDto;
person?: PersonResponseDto;
onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
@@ -84,6 +85,7 @@
isShared = false,
album,
person,
onAssetChange,
preAction,
onAction,
onUndoDelete,
@@ -112,7 +114,6 @@
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let selectedEditType: string = $state('');
let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
@@ -200,10 +201,15 @@
onClose?.(asset);
};
const closeEditor = () => {
closeEditorCofirm(() => {
isShowEditor = false;
});
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}
isShowEditor = false;
};
const tracker = new InvocationTracker();
@@ -249,6 +255,13 @@
});
};
// const showEditor = () => {
// if (assetViewerManager.isShowActivityPanel) {
// assetViewerManager.isShowActivityPanel = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -346,10 +359,6 @@
onAction?.(action);
};
const handleUpdateSelectedEditType = (type: string) => {
selectedEditType = type;
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
@@ -498,7 +507,7 @@
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
{:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform}
<CropArea {asset} />
{:else}
<PhotoViewer
@@ -571,7 +580,7 @@
class="row-start-1 row-span-4 w-[400px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
<EditorPanel {asset} onClose={closeEditor} />
</div>
{/if}
@@ -1,159 +0,0 @@
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 };
}
@@ -1,27 +0,0 @@
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);
}
@@ -1,172 +0,0 @@
<script lang="ts">
import {
cropAspectRatio,
cropImageScale,
cropImageSize,
cropSettings,
cropSettingsChanged,
normaizedRorateDegrees,
rotateDegrees,
type CropAspectRatio,
} from '$lib/stores/asset-editor.store';
import { IconButton } from '@immich/ui';
import { mdiBackupRestore, mdiCropFree, mdiRotateLeft, mdiRotateRight, mdiSquareOutline } from '@mdi/js';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import CropPreset from './crop-preset.svelte';
import { onImageLoad } from './image-loading';
let rotateHorizontal = $derived([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 = $state('free');
$effect(() => {
$cropAspectRatio = selectedSize;
});
let sizesRows = $derived([
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 class="uppercase">{$t('editor_crop_tool_h2_aspect_ratios')}</h2>
</div>
{#each sizesRows as sizesRow, index (index)}
<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 class="uppercase">{$t('editor_crop_tool_h2_rotation')}</h2>
</div>
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
<li>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('anti_clockwise')}
onclick={() => rotate(false)}
icon={mdiRotateLeft}
/>
</li>
<li>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('clockwise')}
onclick={() => rotate(true)}
icon={mdiRotateRight}
/>
</li>
</ul>
</div>
@@ -1,40 +0,0 @@
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
)
`;
}
@@ -1,117 +0,0 @@
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'), {
passive: true,
});
}
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));
}
@@ -1,536 +0,0 @@
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';
globalThis.addEventListener('mouseup', handleMouseUp, { passive: true });
}
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() {
globalThis.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);
}
@@ -1,11 +1,11 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, IconButton } from '@immich/ui';
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
onMount(() => {
@@ -18,67 +18,69 @@
interface Props {
asset: AssetResponseDto;
onUpdateSelectedType: (type: string) => void;
onClose: () => void;
}
let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
onMount(async () => {
const edits = await getAssetEdits({ id: asset.id });
await editManager.activateTool(EditToolType.Transform, asset, edits);
});
let selectedType: string = $state(editTypes[0].name);
let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]);
onDestroy(() => {
editManager.cleanup();
});
setTimeout(() => {
onUpdateSelectedType(selectedType);
}, 1);
async function applyEdits() {
const success = await editManager.applyEdits();
function selectType(name: string) {
selectedType = name;
onUpdateSelectedType(selectedType);
if (success) {
onClose();
}
}
const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
async function closeEditor() {
if (await editManager.closeConfirm()) {
onClose();
}
}
let { asset = $bindable(), onClose }: Props = $props();
</script>
<svelte:document 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">
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiClose}
aria-label={$t('close')}
onclick={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>
<IconButton
shape="round"
color={etype.name === selectedType ? 'primary' : 'secondary'}
icon={etype.icon}
aria-label={etype.name}
onclick={() => selectType(etype.name)}
/>
</li>
{/each}
</ul>
</section>
<section class="relative flex flex-col h-full p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg dark pt-3">
<HStack class="justify-between me-4">
<HStack>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiClose}
aria-label={$t('close')}
onclick={closeEditor}
/>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
</HStack>
<Button shape="round" size="small" onclick={applyEdits}>{$t('save')}</Button>
</HStack>
<section>
<selectedTypeObj.component />
{#if editManager.selectedTool}
<editManager.selectedTool.component />
{/if}
</section>
<div class="flex-1"></div>
<section class="p-4">
<Button
variant="outline"
onclick={() => editManager.resetAllChanges()}
disabled={!editManager.hasChanges}
class="self-start"
shape="round"
size="small"
>
{$t('editor_reset_all_changes')}
</Button>
</section>
</section>
{#if $showCancelConfirmDialog}
<ConfirmModal
title={$t('editor_close_without_save_title')}
prompt={$t('editor_close_without_save_prompt')}
confirmColor="danger"
confirmText={$t('close')}
onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))}
/>
{/if}
@@ -1,24 +1,9 @@
<script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import {
changedOriention,
cropAspectRatio,
cropSettings,
resetGlobalCropStore,
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
import { draw } from './drawing';
import { onImageLoad, resizeCanvas } from './image-loading';
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
interface Props {
asset: AssetResponseDto;
@@ -26,69 +11,72 @@
let { asset }: Props = $props();
let img = $state<HTMLImageElement>();
let canvasContainer = $state<HTMLElement | null>(null);
$effect(() => {
if (!img) {
return;
let imageSrc = $derived(
getAssetThumbnailUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
);
let imageTransform = $derived.by(() => {
const transforms: string[] = [];
if (transformManager.mirrorHorizontal) {
transforms.push('scaleX(-1)');
}
if (transformManager.mirrorVertical) {
transforms.push('scaleY(-1)');
}
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, cacheKey: asset.thumbhash });
img.addEventListener('load', () => onImageLoad(true), { passive: true });
img.addEventListener('error', (error) => handleError(error, $t('error_loading_image')), { passive: true });
globalThis.addEventListener('mousemove', handleMouseMove, { passive: true });
});
onDestroy(() => {
globalThis.removeEventListener('mousemove', handleMouseMove);
resetCropStore();
resetGlobalCropStore();
return transforms.join(' ');
});
$effect(() => {
resizeCanvas();
if (!canvasContainer) {
return;
}
const resizeObserver = new ResizeObserver(() => {
transformManager.resizeCanvas();
});
resizeObserver.observe(canvasContainer);
return () => {
resizeObserver.disconnect();
};
});
</script>
<div class="canvas-container">
<div class="canvas-container" bind:this={canvasContainer}>
<button
class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
style={`rotate:${$rotateDegrees}deg`}
bind:this={$cropAreaEl}
onmousedown={handleMouseDown}
onmouseup={handleMouseUp}
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`}
style={`rotate:${transformManager.imageRotation}deg`}
bind:this={transformManager.cropAreaEl}
onmousedown={(e) => transformManager.handleMouseDown(e)}
onmouseup={() => transformManager.handleMouseUp()}
aria-label="Crop area"
type="button"
>
<img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
<img
draggable="false"
src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))}
style={imageTransform ? `transform: ${imageTransform}` : ''}
/>
<div
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
bind:this={transformManager.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>
<div
class={`${transformManager.isInteracting ? 'light' : ''} overlay`}
bind:this={transformManager.overlayEl}
></div>
</button>
</div>
@@ -161,6 +149,7 @@
max-width: 100%;
height: 100%;
user-select: none;
transition: transform 0.15s ease;
}
.crop-frame {
@@ -1,5 +1,5 @@
<script lang="ts">
import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
import type { CropAspectRatio } from '$lib/managers/edit/transform-manager.svelte';
import { Button, Icon, type Color } from '@immich/ui';
interface Props {
@@ -0,0 +1,150 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n';
interface AspectRatioOption {
label: string;
value: string;
width?: number;
height?: number;
isFree?: boolean;
}
const aspectRatios: AspectRatioOption[] = [
{ label: $t('crop_aspect_ratio_free'), value: 'free', isFree: true },
{ label: $t('crop_aspect_ratio_original'), value: 'original', width: 24, height: 18 },
{ label: '5:4', value: '5:4', width: 22, height: 18 },
{ label: '4:5', value: '4:5', width: 18, height: 22 },
{ label: '4:3', value: '4:3', width: 24, height: 18 },
{ label: '3:4', value: '3:4', width: 18, height: 24 },
{ label: '3:2', value: '3:2', width: 24, height: 16 },
{ label: '2:3', value: '2:3', width: 16, height: 24 },
{ label: '16:9', value: '16:9', width: 24, height: 14 },
{ label: '9:16', value: '9:16', width: 14, height: 24 },
{ label: 'Square', value: '1:1', width: 20, height: 20 },
];
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
function rotatedRatio(ratio: AspectRatioOption): string {
if (ratio.value === 'free') {
return ratio.value;
}
if (isRotated) {
let [width, height] = ratio.value.split(':');
return `${height}:${width}`;
} else {
return ratio.value;
}
}
function ratioSelected(ratio: AspectRatioOption): boolean {
let currentRatioRotated;
if (ratio.value === 'original') {
const { width, height } = transformManager.cropImageSize;
// Account for rotation when comparing to original
if (isRotated) {
currentRatioRotated = `${height}:${width}`;
}
currentRatioRotated = `${width}:${height}`;
}
currentRatioRotated = rotatedRatio(ratio);
return transformManager.cropAspectRatio === currentRatioRotated;
}
function selectAspectRatio(ratio: AspectRatioOption) {
let appliedRatio;
if (ratio.value === 'original') {
const { width, height } = transformManager.cropImageSize;
appliedRatio = `${width}:${height}`;
} else {
appliedRatio = rotatedRatio(ratio);
}
transformManager.setAspectRatio(appliedRatio);
}
async function rotateImage(degrees: number) {
await transformManager.rotate(degrees);
}
function mirrorImage(axis: 'horizontal' | 'vertical') {
transformManager.mirror(axis);
}
</script>
<div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<h2>{$t('editor_orientation')}</h2>
</div>
<HStack>
<IconButton
class="w-full"
size="small"
aria-label={$t('editor_rotate_left')}
icon={mdiRotateLeft}
onclick={() => rotateImage(-90)}
/>
<IconButton
class="w-full"
size="small"
aria-label={$t('editor_rotate_right')}
icon={mdiRotateRight}
onclick={() => rotateImage(90)}
/>
<IconButton
class="w-full"
size="small"
aria-label={$t('editor_flip_horizontal')}
icon={mdiFlipHorizontal}
onclick={() => mirrorImage('horizontal')}
/>
<IconButton
class="w-full"
size="small"
aria-label={$t('editor_flip_vertical')}
icon={mdiFlipVertical}
onclick={() => mirrorImage('vertical')}
/>
</HStack>
<div class="flex h-10 w-full items-center justify-between text-sm mt-6">
<h2>{$t('crop')}</h2>
</div>
<!-- Aspect Ratio Grid -->
<div class="grid grid-cols-2 mb-4">
{#each aspectRatios as ratio (ratio.value)}
<HStack>
<Button
class="w-14 h-14 m-2"
shape="round"
onclick={() => selectAspectRatio(ratio)}
aria-label={ratio.label}
color={ratioSelected(ratio) ? 'primary' : 'secondary'}
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
>
{#if ratio.isFree}
<!-- Free crop icon with dashed border -->
<div
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
? 'border-black'
: 'border-white'}"
></div>
{:else}
<!-- Aspect ratio box -->
<div
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
style="width: {ratio.width}px; height: {ratio.height}px;"
></div>
{/if}
</Button>
<span class="text-sm text-white text-left">{ratio.label}</span>
</HStack>
{/each}
</div>
</div>
@@ -104,6 +104,10 @@
};
});
const updateCurrentAsset = (asset: AssetResponseDto) => {
assets[currentIndex] = asset;
};
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
@@ -484,6 +488,7 @@
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
@@ -225,6 +225,9 @@
{isShared}
{album}
{person}
onAssetChange={(asset) => {
timelineManager?.upsertAssets([toTimelineAsset(asset)]);
}}
preAction={handlePreAction}
onAction={(action) => {
handleAction(action);
@@ -0,0 +1,145 @@
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
import { mdiCropRotate } from '@mdi/js';
import type { Component } from 'svelte';
export type EditAction = AssetEditsDto['edits'][number];
export type EditActions = EditAction[];
export interface EditToolManager {
onActivate: (asset: AssetResponseDto, edits: EditActions) => Promise<void>;
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
hasChanges: boolean;
edits: EditAction[];
}
export enum EditToolType {
Transform = 'transform',
}
export interface EditTool {
type: EditToolType;
icon: string;
component: Component;
manager: EditToolManager;
}
export class EditManager {
tools: EditTool[] = [
{
type: EditToolType.Transform,
icon: mdiCropRotate,
component: TransformTool,
manager: transformManager,
},
];
currentAsset = $state<AssetResponseDto | null>(null);
selectedTool = $state<EditTool | null>(null);
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
// used to disable multiple confirm dialogs and mouse events while one is open
isShowingConfirmDialog = $state(false);
isApplyingEdits = $state(false);
hasAppliedEdits = $state(false);
async closeConfirm(): Promise<boolean> {
// Prevent multiple dialogs (usually happens with rapid escape key presses)
if (this.isShowingConfirmDialog) {
return false;
}
if (!this.hasChanges || this.hasAppliedEdits) {
return true;
}
this.isShowingConfirmDialog = true;
const confirmed = await modalManager.show(ConfirmModal, {
title: 'Discard Edits?',
prompt: 'You have unsaved edits. Are you sure you want to discard them?',
confirmText: 'Discard Edits',
});
this.isShowingConfirmDialog = false;
return confirmed;
}
reset() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
}
this.selectedTool = this.tools[0];
}
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) {
this.hasAppliedEdits = false;
if (this.selectedTool?.type === toolType) {
return;
}
this.currentAsset = asset;
this.selectedTool?.manager.onDeactivate?.();
const newTool = this.tools.find((t) => t.type === toolType);
if (newTool) {
this.selectedTool = newTool;
await newTool.manager.onActivate?.(asset, edits.edits);
}
}
cleanup() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
}
this.currentAsset = null;
this.selectedTool = null;
}
async resetAllChanges() {
for (const tool of this.tools) {
await tool.manager.resetAllChanges();
}
}
async applyEdits(): Promise<boolean> {
this.isApplyingEdits = true;
const edits = this.tools.flatMap((tool) => tool.manager.edits);
try {
// Setup the websocket listener before sending the edit request
const editCompleted = waitForWebsocketEvent(
'AssetEditReadyV1',
(event) => event.assetId === this.currentAsset!.id,
10_000,
);
await (edits.length === 0
? removeAssetEdits({ id: this.currentAsset!.id })
: editAsset({
id: this.currentAsset!.id,
assetEditActionListDto: {
edits,
},
}));
await editCompleted;
toastManager.success('Edits applied successfully');
this.hasAppliedEdits = true;
return true;
} catch {
toastManager.danger('Failed to apply edits');
return false;
} finally {
this.isApplyingEdits = false;
}
}
}
export const editManager = new EditManager();
File diff suppressed because it is too large Load Diff
+5
View File
@@ -29,6 +29,7 @@ import {
mdiLibraryShelves,
mdiOcr,
mdiPause,
mdiPencil,
mdiPlay,
mdiPlus,
mdiStateMachine,
@@ -241,6 +242,10 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
icon: mdiStateMachine,
title: $t('workflows'),
},
[QueueName.Editor]: {
icon: mdiPencil,
title: $t('editor'),
},
};
return items[queue.name];
+1 -71
View File
@@ -1,74 +1,4 @@
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';
import { 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 lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);
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;
};
+23
View File
@@ -31,6 +31,7 @@ export interface Events {
on_notification: (notification: NotificationDto) => void;
AppRestartV1: (event: AppRestartEvent) => void;
AssetEditReadyV1: (data: { assetId: string }) => void;
}
const websocket: Socket<Events> = io({
@@ -73,3 +74,25 @@ export const openWebsocketConnection = () => {
export const closeWebsocketConnection = () => {
websocket.disconnect();
};
export const waitForWebsocketEvent = <T extends keyof Events>(
event: T,
predicate?: (...args: Parameters<Events[T]>) => boolean,
timeout: number = 10_000,
): Promise<Parameters<Events[T]>> => {
return new Promise((resolve, reject) => {
// @ts-expect-error: The typings are weird on this?
const cleanup = websocketEvents.on(event, (...args: Parameters<Events[T]>) => {
if (!predicate || predicate(...args)) {
cleanup();
clearTimeout(timer);
resolve(args);
}
});
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for event: ${String(event)}`));
}, timeout);
});
};
+6 -5
View File
@@ -166,6 +166,7 @@ export const getQueueName = derived(t, ($t) => {
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflows'),
[QueueName.Editor]: $t('editor'),
};
return names[name];
@@ -192,7 +193,7 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash;
};
type AssetUrlOptions = { id: string; cacheKey?: string | null };
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
export const getAssetUrl = ({
asset,
@@ -232,16 +233,16 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, cacheKey } = options;
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey });
const { id, cacheKey, edited = true } = options;
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey, edited });
};
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, size, cacheKey } = options;
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey });
const { id, size, cacheKey, edited = true } = options;
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey, edited });
};
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
+3 -10
View File
@@ -277,25 +277,18 @@ export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
}
export function getAssetResolution(asset: AssetResponseDto): string {
const { width, height } = getAssetRatio(asset);
if (width === 235 && height === 235) {
if (!asset.width || !asset.height) {
return 'Invalid Data';
}
return `${width} x ${height}`;
return `${asset.width} x ${asset.height}`;
}
/**
* Returns aspect ratio for the asset
*/
export function getAssetRatio(asset: AssetResponseDto) {
let height = asset.exifInfo?.exifImageHeight || 235;
let width = asset.exifInfo?.exifImageWidth || 235;
if (isFlipped(asset.exifInfo?.orientation)) {
[width, height] = [height, width];
}
return { width, height };
return asset.width && asset.height ? asset.width / asset.height : null;
}
// list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
+2 -3
View File
@@ -49,8 +49,7 @@ function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions)
function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) {
const aspectRatios = new Float32Array(assets.length);
for (let i = 0; i < assets.length; i++) {
const { width, height } = getAssetRatio(assets[i]);
aspectRatios[i] = width / height;
aspectRatios[i] = getAssetRatio(assets[i]) ?? 1;
}
return new JustifiedLayout(aspectRatios, options);
}
@@ -111,7 +110,7 @@ export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], op
};
const result = createJustifiedLayout(
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : getAssetRatio(asset))),
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : (getAssetRatio(asset) ?? 1))),
adapter,
);
return new Adapter(result);
+1 -2
View File
@@ -159,8 +159,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
return unknownAsset;
}
const assetResponse = unknownAsset;
const { width, height } = getAssetRatio(assetResponse);
const ratio = width / height;
const ratio = getAssetRatio(assetResponse) ?? 1;
const city = assetResponse.exifInfo?.city;
const country = assetResponse.exifInfo?.country;
const people = assetResponse.people?.map((person) => person.name) || [];
@@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
isOffline: Sync.each(() => faker.datatype.boolean()),
hasMetadata: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline,
width: faker.number.int({ min: 100, max: 1000 }),
height: faker.number.int({ min: 100, max: 1000 }),
});
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({