diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 85eff91ff46cd..a5534f79d8e6d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -47,12 +47,22 @@ export let onRunJob: (name: AssetJobName) => void; export let onPlaySlideshow: () => void; export let onShowDetail: () => void; + // export let showEditorHandler: () => void; export let onClose: () => void; const sharedLink = getSharedLink(); $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; + // $: showEditorButton = + // isOwner && + // asset.type === AssetTypeEnum.Image && + // !( + // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + // ) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + // !asset.livePhotoVideoId;
{/if} + {#if isOwner} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 2148ff7dda1cc..0c8481805ae75 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -45,7 +45,9 @@ import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; import VideoViewer from './video-wrapper-viewer.svelte'; - + import EditorPanel from './editor/editor-panel.svelte'; + import CropArea from './editor/crop-tool/crop-area.svelte'; + import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -80,6 +82,7 @@ let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; let isShowActivity = false; + let isShowEditor = false; let isLiked: ActivityResponseDto | null = null; let numberOfComments: number; let fullscreenElement: Element; @@ -272,6 +275,12 @@ await navigate({ targetRoute: 'current', assetId: null }); }; + const closeEditor = () => { + closeEditorCofirm(() => { + isShowEditor = false; + }); + }; + const navigateAssetRandom = async () => { if (!assetStore) { return; @@ -315,6 +324,13 @@ dispatch(order); }; + // const showEditorHandler = () => { + // if (isShowActivity) { + // isShowActivity = false; + // } + // isShowEditor = !isShowEditor; + // }; + const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -383,6 +399,12 @@ onAction?.(action); }; + + let selectedEditType: string = ''; + + function handleUpdateSelectedEditType(type: string) { + selectedEditType = type; + } @@ -393,7 +415,7 @@ use:focusTrap > - {#if $slideshowState === SlideshowState.None} + {#if $slideshowState === SlideshowState.None && !isShowEditor}
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
navigateAsset('previous')} />
@@ -487,6 +509,8 @@ .toLowerCase() .endsWith('.insp'))} + {:else if isShowEditor && selectedEditType === 'crop'} + {:else} {/if} @@ -516,13 +540,13 @@ {/if}
- {#if $slideshowState === SlideshowState.None && showNavigation} + {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
navigateAsset('next')} />
{/if} - {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail} + {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
{/if} + {#if isShowEditor} +
+ +
+ {/if} + {#if stackedAssets.length > 0 && withStacked}
+ import { onMount, afterUpdate, onDestroy, tick } from 'svelte'; + import { t } from 'svelte-i18n'; + import { getAssetOriginalUrl } from '$lib/utils'; + import { handleError } from '$lib/utils/handle-error'; + import { getAltText } from '$lib/utils/thumbnail-util'; + + import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store'; + import { draw } from './drawing'; + import { onImageLoad, resizeCanvas } from './image-loading'; + import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers'; + import { recalculateCrop, animateCropChange } from './crop-settings'; + import { + changedOriention, + cropAspectRatio, + cropSettings, + resetGlobalCropStore, + rotateDegrees, + } from '$lib/stores/asset-editor.store'; + + export let asset; + let img: HTMLImageElement; + + $: imgElement.set(img); + + cropAspectRatio.subscribe((value) => { + if (!img || !$cropAreaEl) { + return; + } + const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true); + if (newCrop) { + animateCropChange($cropSettings, newCrop, () => draw($cropSettings)); + } + }); + + onMount(async () => { + resetGlobalCropStore(); + img = new Image(); + await tick(); + + img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); + + img.addEventListener('load', () => onImageLoad(true)); + img.addEventListener('error', (error) => { + handleError(error, $t('error_loading_image')); + }); + + window.addEventListener('mousemove', handleMouseMove); + }); + + onDestroy(() => { + window.removeEventListener('mousemove', handleMouseMove); + resetCropStore(); + resetGlobalCropStore(); + }); + + afterUpdate(() => { + resizeCanvas(); + }); + + +
+ +
+ + diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte new file mode 100644 index 0000000000000..667191274faaa --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte @@ -0,0 +1,40 @@ + + +
  • + +
  • diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts new file mode 100644 index 0000000000000..a0390d2d4d47e --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts @@ -0,0 +1,159 @@ +import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl } from './crop-store'; +import { checkEdits } from './mouse-handlers'; + +export function recalculateCrop( + crop: CropSettings, + canvas: HTMLElement, + aspectRatio: CropAspectRatio, + returnNewCrop = false, +): CropSettings | null { + const canvasW = canvas.clientWidth; + const canvasH = canvas.clientHeight; + + let newWidth = crop.width; + let newHeight = crop.height; + + const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio); + + if (w > canvasW) { + newWidth = canvasW; + newHeight = canvasW / (w / h); + } else if (h > canvasH) { + newHeight = canvasH; + newWidth = canvasH * (w / h); + } else { + newWidth = w; + newHeight = h; + } + + const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth)); + const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight)); + + const newCrop = { + width: newWidth, + height: newHeight, + x: newX, + y: newY, + }; + + if (returnNewCrop) { + setTimeout(() => { + checkEdits(); + }, 1); + return newCrop; + } else { + crop.width = newWidth; + crop.height = newHeight; + crop.x = newX; + crop.y = newY; + return null; + } +} + +export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { + const cropArea = get(cropAreaEl); + if (!cropArea) { + return; + } + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (!cropFrame) { + return; + } + + const startTime = performance.now(); + const initialCrop = { ...crop }; + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress; + crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress; + crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress; + crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress; + + draw(); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); +} + +export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + + if (widthRatio && heightRatio) { + const calculatedWidth = (newHeight * widthRatio) / heightRatio; + return { newWidth: calculatedWidth, newHeight }; + } + + return { newWidth, newHeight }; +} + +export function adjustDimensions( + newWidth: number, + newHeight: number, + aspectRatio: CropAspectRatio, + xLimit: number, + yLimit: number, + minSize: number, +) { + let w = newWidth; + let h = newHeight; + + let aspectMultiplier: number; + + if (aspectRatio === 'free') { + aspectMultiplier = newWidth / newHeight; + } else { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; + } + + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + + if (w > xLimit) { + w = xLimit; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h > yLimit) { + h = yLimit; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (w < minSize) { + w = minSize; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h < minSize) { + h = minSize; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w < minSize) { + h = w / aspectMultiplier; + } + if (h < minSize) { + w = h * aspectMultiplier; + } + } + + return { newWidth: w, newHeight: h }; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts new file mode 100644 index 0000000000000..8e27d41f21926 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; + +export const darkenLevel = writable(0.65); +export const isResizingOrDragging = writable(false); +export const animationFrame = writable | null>(null); +export const canvasCursor = writable('default'); +export const dragOffset = writable({ x: 0, y: 0 }); +export const resizeSide = writable(''); +export const imgElement = writable(null); +export const cropAreaEl = writable(null); +export const isDragging = writable(false); + +export const overlayEl = writable(null); +export const cropFrame = writable(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); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte new file mode 100644 index 0000000000000..dba3be5d671ff --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -0,0 +1,151 @@ + + +
    +
    +

    {$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}

    +
    + {#each sizesRows as sizesRow} +
      + {#each sizesRow as size (size.name)} + + {/each} +
    + {/each} +
    +

    {$t('editor_crop_tool_h2_rotation').toUpperCase()}

    +
    +
      +
    • rotate(false)} icon={mdiRotateLeft} />
    • +
    • rotate(true)} icon={mdiRotateRight} />
    • +
    +
    diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts new file mode 100644 index 0000000000000..85e7f4b1c408d --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts @@ -0,0 +1,40 @@ +import type { CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropFrame, overlayEl } from './crop-store'; + +export function draw(crop: CropSettings) { + const mCropFrame = get(cropFrame); + + if (!mCropFrame) { + return; + } + + mCropFrame.style.left = `${crop.x}px`; + mCropFrame.style.top = `${crop.y}px`; + mCropFrame.style.width = `${crop.width}px`; + mCropFrame.style.height = `${crop.height}px`; + + drawOverlay(crop); +} + +export function drawOverlay(crop: CropSettings) { + const overlay = get(overlayEl); + if (!overlay) { + return; + } + + overlay.style.clipPath = ` + polygon( + 0% 0%, + 0% 100%, + 100% 100%, + 100% 0%, + 0% 0%, + ${crop.x}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y}px + ) + `; +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts new file mode 100644 index 0000000000000..bce90efd9e1f7 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -0,0 +1,117 @@ +import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { cropAreaEl, cropFrame, imgElement } from './crop-store'; +import { draw } from './drawing'; + +export function onImageLoad(resetSize: boolean = false) { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea.clientWidth ?? 0; + const containerHeight = cropArea.clientHeight ?? 0; + + const scale = calculateScale(img, containerWidth, containerHeight); + + cropImageSize.set([img.width, img.height]); + + if (resetSize) { + cropSettings.update((crop) => { + crop.x = 0; + crop.y = 0; + crop.width = img.width * scale; + crop.height = img.height * scale; + return crop; + }); + } else { + const cropFrameEl = get(cropFrame); + cropFrameEl?.classList.add('transition'); + cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); + cropFrameEl?.classList.add('transition'); + cropFrameEl?.addEventListener('transitionend', () => { + cropFrameEl?.classList.remove('transition'); + }); + } + cropImageScale.set(scale); + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + draw(get(cropSettings)); +} + +export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number { + const imageAspectRatio = img.width / img.height; + let scale: number; + + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + return scale; +} + +export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { + const prevScale = get(cropImageScale); + const scaleRatio = scale / prevScale; + + crop.x *= scaleRatio; + crop.y *= scaleRatio; + crop.width *= scaleRatio; + crop.height *= scaleRatio; + + crop.width = Math.min(crop.width, img.width * scale); + crop.height = Math.min(crop.height, img.height * scale); + crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width)); + crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height)); + + return crop; +} + +export function resizeCanvas() { + const img = get(imgElement); + const cropArea = get(cropAreaEl); + + if (!cropArea || !img) { + return; + } + + const containerWidth = cropArea?.clientWidth ?? 0; + const containerHeight = cropArea?.clientHeight ?? 0; + const imageAspectRatio = img.width / img.height; + + let scale; + if (imageAspectRatio > 1) { + scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + } else { + scale = containerHeight / img.height; + if (img.width * scale > containerWidth) { + scale = containerWidth / img.width; + } + } + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; + if (cropFrame) { + cropFrame.style.width = `${img.width * scale}px`; + cropFrame.style.height = `${img.height * scale}px`; + } + + draw(get(cropSettings)); +} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts new file mode 100644 index 0000000000000..656fd09294abb --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -0,0 +1,536 @@ +import { + cropAspectRatio, + cropImageScale, + cropImageSize, + cropSettings, + cropSettingsChanged, + normaizedRorateDegrees, + rotateDegrees, + showCancelConfirmDialog, + type CropSettings, +} from '$lib/stores/asset-editor.store'; +import { get } from 'svelte/store'; +import { adjustDimensions, keepAspectRatio } from './crop-settings'; +import { + canvasCursor, + cropAreaEl, + dragOffset, + isDragging, + isResizingOrDragging, + overlayEl, + resizeSide, +} from './crop-store'; +import { draw } from './drawing'; + +export function handleMouseDown(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const crop = get(cropSettings); + const { mouseX, mouseY } = getMousePosition(e); + + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = isOnCropBoundary(mouseX, mouseY, crop); + + if ( + onTopLeftCorner || + onTopRightCorner || + onBottomLeftCorner || + onBottomRightCorner || + onLeftBoundary || + onRightBoundary || + onTopBoundary || + onBottomBoundary + ) { + setResizeSide(mouseX, mouseY); + } else if (isInCropArea(mouseX, mouseY, crop)) { + startDragging(mouseX, mouseY); + } + + document.body.style.userSelect = 'none'; + window.addEventListener('mouseup', handleMouseUp); +} + +export function handleMouseMove(e: MouseEvent) { + const canvas = get(cropAreaEl); + if (!canvas) { + return; + } + + const resizeSideValue = get(resizeSide); + const { mouseX, mouseY } = getMousePosition(e); + + if (get(isDragging)) { + moveCrop(mouseX, mouseY); + } else if (resizeSideValue) { + resizeCrop(mouseX, mouseY); + } else { + updateCursor(mouseX, mouseY); + } +} + +export function handleMouseUp() { + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.userSelect = ''; + stopInteraction(); +} + +function getMousePosition(e: MouseEvent) { + let offsetX = e.clientX; + let offsetY = e.clientY; + const clienRect = getBoundingClientRectCached(get(cropAreaEl)); + const rotateDeg = get(normaizedRorateDegrees); + + if (rotateDeg == 90) { + offsetX = e.clientY - (clienRect?.top ?? 0); + offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + } else if (rotateDeg == 180) { + offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + } else if (rotateDeg == 270) { + offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + offsetY = e.clientX - (clienRect?.left ?? 0); + } else if (rotateDeg == 0) { + offsetX -= clienRect?.left ?? 0; + offsetY -= clienRect?.top ?? 0; + } + return { mouseX: offsetX, mouseY: offsetY }; +} + +type BoundingClientRect = ReturnType; +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); +} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte new file mode 100644 index 0000000000000..1adef3273502d --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -0,0 +1,76 @@ + + + + +
    +
    + +

    {$t('editor')}

    +
    +
    +
      + {#each editTypes as etype (etype.name)} +
    • + selectType(etype.name)} + /> +
    • + {/each} +
    +
    +
    + +
    +
    + +{#if $showCancelConfirmDialog} + { + $showCancelConfirmDialog = false; + }} + onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())} + /> +{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f424e60a66be5..5b2d9d393a2e7 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -359,6 +359,7 @@ "allow_edits": "Allow edits", "allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_upload": "Allow public user to upload", + "anti_clockwise": "Anti-clockwise", "api_key": "API Key", "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", "api_key_empty": "Your API Key name shouldn't be empty", @@ -434,6 +435,7 @@ "clear_all_recent_searches": "Clear all recent searches", "clear_message": "Clear message", "clear_value": "Clear value", + "clockwise": "Сlockwise", "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", @@ -535,6 +537,11 @@ "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", + "editor": "Editor", + "editor_close_without_save_prompt": "The changes will not be saved", + "editor_close_without_save_title": "Close editor?", + "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", + "editor_crop_tool_h2_rotation": "Rotation", "email": "Email", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 6a31d297af4bd..1a55ab009dd43 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -360,6 +360,7 @@ "allow_edits": "Разрешить редактирование", "allow_public_user_to_download": "Разрешить скачивание публичным пользователям", "allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы", + "anti_clockwise": "Против часовой", "api_key": "API Ключ", "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", "api_key_empty": "Ваш API ключ не должен быть пустым", @@ -441,6 +442,7 @@ "clear_all_recent_searches": "Очистить все недавние результаты поиска", "clear_message": "Очистить сообщение", "clear_value": "Очистить значение", + "clockwise": "По часовой", "close": "Закрыть", "collapse": "Свернуть", "collapse_all": "Свернуть всё", @@ -550,6 +552,10 @@ "edit_user": "Редактировать пользователя", "edited": "Отредактировано", "editor": "Редактор", + "editor_close_without_save_prompt": "Изменения не будут сохранены", + "editor_close_without_save_title": "Закрыть редактор?", + "editor_crop_tool_h2_aspect_ratios": "Соотношения сторон", + "editor_crop_tool_h2_rotation": "Вращение", "email": "Электронная почта", "empty": "", "empty_album": "Пустой альбом", diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts new file mode 100644 index 0000000000000..4d2f8977ee592 --- /dev/null +++ b/web/src/lib/stores/asset-editor.store.ts @@ -0,0 +1,73 @@ +import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte'; +import { mdiCropRotate } from '@mdi/js'; +import { derived, get, writable } from 'svelte/store'; + +//---------crop +export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 }); +export const cropImageSize = writable([1000, 1000]); +export const cropImageScale = writable(1); +export const cropAspectRatio = writable('free'); +export const cropSettingsChanged = writable(false); +//---------rotate +export const rotateDegrees = writable(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(false); + +export const editTypes = [ + { + name: 'crop', + icon: mdiCropRotate, + component: CropTool, + changesFlag: cropSettingsChanged, + }, +]; + +export function closeEditorCofirm(closeCallback: CallableFunction) { + if (get(hasChanges)) { + showCancelConfirmDialog.set(closeCallback); + } else { + closeCallback(); + } +} + +export const hasChanges = derived( + editTypes.map((t) => t.changesFlag), + ($flags) => { + return $flags.some(Boolean); + }, +); + +export function resetGlobalCropStore() { + cropSettings.set({ x: 0, y: 0, width: 100, height: 100 }); + cropImageSize.set([1000, 1000]); + cropImageScale.set(1); + cropAspectRatio.set('free'); + cropSettingsChanged.set(false); + showCancelConfirmDialog.set(false); + rotateDegrees.set(0); +} + +export type CropAspectRatio = + | '1:1' + | '16:9' + | '4:3' + | '3:2' + | '7:5' + | '9:16' + | '3:4' + | '2:3' + | '5:7' + | 'free' + | 'reset'; + +export type CropSettings = { + x: number; + y: number; + width: number; + height: number; +};