mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: image editing (#24155)
This commit is contained in:
@@ -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}
|
||||
|
||||
+51
-62
@@ -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
-1
@@ -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
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>({
|
||||
|
||||
Reference in New Issue
Block a user