You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(web): image editor - panel and cropping (#11074)
* cropping, panel * fix presets * types * prettier * fix lint * fix aspect ratio, performance optimization * improved tool selection, removed placeholder * fix the mouse's exit from canvas * fix error * the "save" button and change tracking * lint, format * the mini functionality of the save button * fix aspect ratio * hide editor button on mobiles * strict equality Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * Use the dollar sign syntax for stores inside components * unobtrusive grid lines, circles at the corners * more correct image load, handleError * more strict equality * fix styles. unused and tailwind Co-Authored-By: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * dont store isShowEditor * if showEditor - hide navbar & shortcuts * crop-canvas decomposition (danger) I could have accidentally broken something.. but I checked the work and it seems ok. * fix lint * fix ts * callback function as props * correctly disabling shortcuts * convenient canvas borders • you can use the mouse to go beyond the boundaries and freely change the crop. • the circles on the corners of the canvas are not cut off. * -the editor button for video files, -save button * hide editor btn if panoramic || gif || live * corners instead of circles (preview), fix lint&format * confirm close editor without save * vertical aspect ratios * recovery after merge. editor's closing shortcut * fix format * move from canvas to html elements * fix changes detections * rotation * hide detail panel if showing editor * fix aspect ratios near min size * fix crop area when changing image size when rotate * fix of fix * better layout - grouping https://github.com/user-attachments/assets/48f15172-9666-4588-acb6-3cb5eda873a8 * hide the button * fix i18n, format * hide button * hide button v2 --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -47,12 +47,22 @@ | |||||||
|   export let onRunJob: (name: AssetJobName) => void; |   export let onRunJob: (name: AssetJobName) => void; | ||||||
|   export let onPlaySlideshow: () => void; |   export let onPlaySlideshow: () => void; | ||||||
|   export let onShowDetail: () => void; |   export let onShowDetail: () => void; | ||||||
|  |   // export let showEditorHandler: () => void; | ||||||
|   export let onClose: () => void; |   export let onClose: () => void; | ||||||
|  |  | ||||||
|   const sharedLink = getSharedLink(); |   const sharedLink = getSharedLink(); | ||||||
|  |  | ||||||
|   $: isOwner = $user && asset.ownerId === $user?.id; |   $: isOwner = $user && asset.ownerId === $user?.id; | ||||||
|   $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; |   $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; | ||||||
|  |   // $: showEditorButton = | ||||||
|  |   //   isOwner && | ||||||
|  |   //   asset.type === AssetTypeEnum.Image && | ||||||
|  |   //   !( | ||||||
|  |   //     asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || | ||||||
|  |   //     (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) | ||||||
|  |   //   ) && | ||||||
|  |   //   !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && | ||||||
|  |   //   !asset.livePhotoVideoId; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <div | <div | ||||||
| @@ -98,6 +108,15 @@ | |||||||
|     {#if isOwner} |     {#if isOwner} | ||||||
|       <FavoriteAction {asset} {onAction} /> |       <FavoriteAction {asset} {onAction} /> | ||||||
|     {/if} |     {/if} | ||||||
|  |     <!-- {#if showEditorButton} | ||||||
|  |       <CircleIconButton | ||||||
|  |         color="opaque" | ||||||
|  |         hideMobile={true} | ||||||
|  |         icon={mdiImageEditOutline} | ||||||
|  |         on:click={showEditorHandler} | ||||||
|  |         title={$t('editor')} | ||||||
|  |       /> | ||||||
|  |     {/if} --> | ||||||
|  |  | ||||||
|     {#if isOwner} |     {#if isOwner} | ||||||
|       <DeleteAction {asset} {onAction} /> |       <DeleteAction {asset} {onAction} /> | ||||||
|   | |||||||
| @@ -45,7 +45,9 @@ | |||||||
|   import PhotoViewer from './photo-viewer.svelte'; |   import PhotoViewer from './photo-viewer.svelte'; | ||||||
|   import SlideshowBar from './slideshow-bar.svelte'; |   import SlideshowBar from './slideshow-bar.svelte'; | ||||||
|   import VideoViewer from './video-wrapper-viewer.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 assetStore: AssetStore | null = null; | ||||||
|   export let asset: AssetResponseDto; |   export let asset: AssetResponseDto; | ||||||
|   export let preloadAssets: AssetResponseDto[] = []; |   export let preloadAssets: AssetResponseDto[] = []; | ||||||
| @@ -80,6 +82,7 @@ | |||||||
|   let shuffleSlideshowUnsubscribe: () => void; |   let shuffleSlideshowUnsubscribe: () => void; | ||||||
|   let previewStackedAsset: AssetResponseDto | undefined; |   let previewStackedAsset: AssetResponseDto | undefined; | ||||||
|   let isShowActivity = false; |   let isShowActivity = false; | ||||||
|  |   let isShowEditor = false; | ||||||
|   let isLiked: ActivityResponseDto | null = null; |   let isLiked: ActivityResponseDto | null = null; | ||||||
|   let numberOfComments: number; |   let numberOfComments: number; | ||||||
|   let fullscreenElement: Element; |   let fullscreenElement: Element; | ||||||
| @@ -272,6 +275,12 @@ | |||||||
|     await navigate({ targetRoute: 'current', assetId: null }); |     await navigate({ targetRoute: 'current', assetId: null }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const closeEditor = () => { | ||||||
|  |     closeEditorCofirm(() => { | ||||||
|  |       isShowEditor = false; | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const navigateAssetRandom = async () => { |   const navigateAssetRandom = async () => { | ||||||
|     if (!assetStore) { |     if (!assetStore) { | ||||||
|       return; |       return; | ||||||
| @@ -315,6 +324,13 @@ | |||||||
|     dispatch(order); |     dispatch(order); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   // const showEditorHandler = () => { | ||||||
|  |   //   if (isShowActivity) { | ||||||
|  |   //     isShowActivity = false; | ||||||
|  |   //   } | ||||||
|  |   //   isShowEditor = !isShowEditor; | ||||||
|  |   // }; | ||||||
|  |  | ||||||
|   const handleRunJob = async (name: AssetJobName) => { |   const handleRunJob = async (name: AssetJobName) => { | ||||||
|     try { |     try { | ||||||
|       await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); |       await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); | ||||||
| @@ -383,6 +399,12 @@ | |||||||
|  |  | ||||||
|     onAction?.(action); |     onAction?.(action); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   let selectedEditType: string = ''; | ||||||
|  |  | ||||||
|  |   function handleUpdateSelectedEditType(type: string) { | ||||||
|  |     selectedEditType = type; | ||||||
|  |   } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <svelte:document bind:fullscreenElement /> | <svelte:document bind:fullscreenElement /> | ||||||
| @@ -393,7 +415,7 @@ | |||||||
|   use:focusTrap |   use:focusTrap | ||||||
| > | > | ||||||
|   <!-- Top navigation bar --> |   <!-- Top navigation bar --> | ||||||
|   {#if $slideshowState === SlideshowState.None} |   {#if $slideshowState === SlideshowState.None && !isShowEditor} | ||||||
|     <div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> |     <div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> | ||||||
|       <AssetViewerNavBar |       <AssetViewerNavBar | ||||||
|         {asset} |         {asset} | ||||||
| @@ -419,7 +441,7 @@ | |||||||
|     </div> |     </div> | ||||||
|   {/if} |   {/if} | ||||||
|  |  | ||||||
|   {#if $slideshowState === SlideshowState.None && showNavigation} |   {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} | ||||||
|     <div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> |     <div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> | ||||||
|       <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> |       <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> | ||||||
|     </div> |     </div> | ||||||
| @@ -487,6 +509,8 @@ | |||||||
|                 .toLowerCase() |                 .toLowerCase() | ||||||
|                 .endsWith('.insp'))} |                 .endsWith('.insp'))} | ||||||
|             <PanoramaViewer {asset} /> |             <PanoramaViewer {asset} /> | ||||||
|  |           {:else if isShowEditor && selectedEditType === 'crop'} | ||||||
|  |             <CropArea {asset} /> | ||||||
|           {:else} |           {:else} | ||||||
|             <PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} /> |             <PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} /> | ||||||
|           {/if} |           {/if} | ||||||
| @@ -516,13 +540,13 @@ | |||||||
|     {/if} |     {/if} | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   {#if $slideshowState === SlideshowState.None && showNavigation} |   {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} | ||||||
|     <div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> |     <div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> | ||||||
|       <NextAssetAction onNextAsset={() => navigateAsset('next')} /> |       <NextAssetAction onNextAsset={() => navigateAsset('next')} /> | ||||||
|     </div> |     </div> | ||||||
|   {/if} |   {/if} | ||||||
|  |  | ||||||
|   {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail} |   {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor} | ||||||
|     <div |     <div | ||||||
|       transition:fly={{ duration: 150 }} |       transition:fly={{ duration: 150 }} | ||||||
|       id="detail-panel" |       id="detail-panel" | ||||||
| @@ -533,6 +557,17 @@ | |||||||
|     </div> |     </div> | ||||||
|   {/if} |   {/if} | ||||||
|  |  | ||||||
|  |   {#if isShowEditor} | ||||||
|  |     <div | ||||||
|  |       transition:fly={{ duration: 150 }} | ||||||
|  |       id="editor-panel" | ||||||
|  |       class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg" | ||||||
|  |       translate="yes" | ||||||
|  |     > | ||||||
|  |       <EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} /> | ||||||
|  |     </div> | ||||||
|  |   {/if} | ||||||
|  |  | ||||||
|   {#if stackedAssets.length > 0 && withStacked} |   {#if stackedAssets.length > 0 && withStacked} | ||||||
|     <div |     <div | ||||||
|       id="stack-slideshow" |       id="stack-slideshow" | ||||||
|   | |||||||
| @@ -0,0 +1,200 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import { onMount, afterUpdate, onDestroy, tick } from 'svelte'; | ||||||
|  |   import { t } from 'svelte-i18n'; | ||||||
|  |   import { getAssetOriginalUrl } from '$lib/utils'; | ||||||
|  |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|  |   import { getAltText } from '$lib/utils/thumbnail-util'; | ||||||
|  |  | ||||||
|  |   import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store'; | ||||||
|  |   import { draw } from './drawing'; | ||||||
|  |   import { onImageLoad, resizeCanvas } from './image-loading'; | ||||||
|  |   import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers'; | ||||||
|  |   import { recalculateCrop, animateCropChange } from './crop-settings'; | ||||||
|  |   import { | ||||||
|  |     changedOriention, | ||||||
|  |     cropAspectRatio, | ||||||
|  |     cropSettings, | ||||||
|  |     resetGlobalCropStore, | ||||||
|  |     rotateDegrees, | ||||||
|  |   } from '$lib/stores/asset-editor.store'; | ||||||
|  |  | ||||||
|  |   export let asset; | ||||||
|  |   let img: HTMLImageElement; | ||||||
|  |  | ||||||
|  |   $: imgElement.set(img); | ||||||
|  |  | ||||||
|  |   cropAspectRatio.subscribe((value) => { | ||||||
|  |     if (!img || !$cropAreaEl) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true); | ||||||
|  |     if (newCrop) { | ||||||
|  |       animateCropChange($cropSettings, newCrop, () => draw($cropSettings)); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   onMount(async () => { | ||||||
|  |     resetGlobalCropStore(); | ||||||
|  |     img = new Image(); | ||||||
|  |     await tick(); | ||||||
|  |  | ||||||
|  |     img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); | ||||||
|  |  | ||||||
|  |     img.addEventListener('load', () => onImageLoad(true)); | ||||||
|  |     img.addEventListener('error', (error) => { | ||||||
|  |       handleError(error, $t('error_loading_image')); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     window.addEventListener('mousemove', handleMouseMove); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   onDestroy(() => { | ||||||
|  |     window.removeEventListener('mousemove', handleMouseMove); | ||||||
|  |     resetCropStore(); | ||||||
|  |     resetGlobalCropStore(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   afterUpdate(() => { | ||||||
|  |     resizeCanvas(); | ||||||
|  |   }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <div class="canvas-container"> | ||||||
|  |   <button | ||||||
|  |     class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`} | ||||||
|  |     style={`rotate:${$rotateDegrees}deg`} | ||||||
|  |     bind:this={$cropAreaEl} | ||||||
|  |     on:mousedown={handleMouseDown} | ||||||
|  |     on:mouseup={handleMouseUp} | ||||||
|  |     aria-label="Crop area" | ||||||
|  |     type="button" | ||||||
|  |   > | ||||||
|  |     <img draggable="false" src={img?.src} alt={$getAltText(asset)} /> | ||||||
|  |     <div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}> | ||||||
|  |       <div class="grid"></div> | ||||||
|  |       <div class="corner top-left"></div> | ||||||
|  |       <div class="corner top-right"></div> | ||||||
|  |       <div class="corner bottom-left"></div> | ||||||
|  |       <div class="corner bottom-right"></div> | ||||||
|  |     </div> | ||||||
|  |     <div class={`${$isResizingOrDragging ? 'light' : ''} overlay`} bind:this={$overlayEl}></div> | ||||||
|  |   </button> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  |   .canvas-container { | ||||||
|  |     width: calc(100% - 4rem); | ||||||
|  |     margin: auto; | ||||||
|  |     margin-top: 2rem; | ||||||
|  |     height: calc(100% - 4rem); | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .crop-area { | ||||||
|  |     position: relative; | ||||||
|  |     display: inline-block; | ||||||
|  |     outline: none; | ||||||
|  |     transition: rotate 0.15s ease; | ||||||
|  |     max-height: 100%; | ||||||
|  |     max-width: 100%; | ||||||
|  |     width: max-content; | ||||||
|  |   } | ||||||
|  |   .crop-area.changedOriention { | ||||||
|  |     max-width: 92vh; | ||||||
|  |     max-height: calc(100vw - 400px - 1.5rem); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .crop-frame.transition { | ||||||
|  |     transition: all 0.15s ease; | ||||||
|  |   } | ||||||
|  |   .overlay { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     background: rgba(0, 0, 0, 0.56); | ||||||
|  |     pointer-events: none; | ||||||
|  |     transition: background 0.1s; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .overlay.light { | ||||||
|  |     background: rgba(0, 0, 0, 0.3); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .grid { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     --color: white; | ||||||
|  |     --shadow: #00000057; | ||||||
|  |     background-image: linear-gradient(var(--color) 1px, transparent 0), | ||||||
|  |       linear-gradient(90deg, var(--color) 1px, transparent 0), linear-gradient(var(--shadow) 3px, transparent 0), | ||||||
|  |       linear-gradient(90deg, var(--shadow) 3px, transparent 0); | ||||||
|  |     background-size: calc(100% / 3) calc(100% / 3); | ||||||
|  |     opacity: 0; | ||||||
|  |     transition: opacity 0.1s ease; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .crop-frame.resizing .grid { | ||||||
|  |     opacity: 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .crop-area img { | ||||||
|  |     display: block; | ||||||
|  |     max-width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     user-select: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .crop-frame { | ||||||
|  |     position: absolute; | ||||||
|  |     border: 2px solid white; | ||||||
|  |     box-sizing: border-box; | ||||||
|  |     pointer-events: none; | ||||||
|  |     z-index: 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .corner { | ||||||
|  |     position: absolute; | ||||||
|  |     width: 20px; | ||||||
|  |     height: 20px; | ||||||
|  |     --size: 5.2px; | ||||||
|  |     --mSize: calc(-0.5 * var(--size)); | ||||||
|  |     border: var(--size) solid white; | ||||||
|  |     box-sizing: border-box; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .top-left { | ||||||
|  |     top: var(--mSize); | ||||||
|  |     left: var(--mSize); | ||||||
|  |     border-right: none; | ||||||
|  |     border-bottom: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .top-right { | ||||||
|  |     top: var(--mSize); | ||||||
|  |     right: var(--mSize); | ||||||
|  |     border-left: none; | ||||||
|  |     border-bottom: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .bottom-left { | ||||||
|  |     bottom: var(--mSize); | ||||||
|  |     left: var(--mSize); | ||||||
|  |     border-right: none; | ||||||
|  |     border-top: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .bottom-right { | ||||||
|  |     bottom: var(--mSize); | ||||||
|  |     right: var(--mSize); | ||||||
|  |     border-left: none; | ||||||
|  |     border-top: none; | ||||||
|  |   } | ||||||
|  | </style> | ||||||
| @@ -0,0 +1,40 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import Button, { type Color } from '$lib/components/elements/buttons/button.svelte'; | ||||||
|  |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|  |   import type { CropAspectRatio } from '$lib/stores/asset-editor.store'; | ||||||
|  |  | ||||||
|  |   export let size: { | ||||||
|  |     icon: string; | ||||||
|  |     name: CropAspectRatio; | ||||||
|  |     viewBox: string; | ||||||
|  |     rotate?: boolean; | ||||||
|  |   }; | ||||||
|  |   export let selectedSize: CropAspectRatio; | ||||||
|  |   export let rotateHorizontal: boolean; | ||||||
|  |   export let selectType: (size: CropAspectRatio) => void; | ||||||
|  |  | ||||||
|  |   $: isSelected = selectedSize === size.name; | ||||||
|  |   $: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color; | ||||||
|  |  | ||||||
|  |   $: rotatedTitle = (title: string, toRotate: boolean) => { | ||||||
|  |     let sides = title.split(':'); | ||||||
|  |     if (toRotate) { | ||||||
|  |       sides.reverse(); | ||||||
|  |     } | ||||||
|  |     return sides.join(':'); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   $: toRotate = (def: boolean | undefined) => { | ||||||
|  |     if (def === false) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |     return (def && !rotateHorizontal) || (!def && rotateHorizontal); | ||||||
|  |   }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <li> | ||||||
|  |   <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}> | ||||||
|  |     <Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} /> | ||||||
|  |     <span>{rotatedTitle(size.name, rotateHorizontal)}</span> | ||||||
|  |   </Button> | ||||||
|  | </li> | ||||||
| @@ -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 }; | ||||||
|  | } | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | 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); | ||||||
|  | } | ||||||
| @@ -0,0 +1,151 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||||
|  |   import { | ||||||
|  |     cropAspectRatio, | ||||||
|  |     cropImageScale, | ||||||
|  |     cropImageSize, | ||||||
|  |     cropSettings, | ||||||
|  |     cropSettingsChanged, | ||||||
|  |     normaizedRorateDegrees, | ||||||
|  |     rotateDegrees, | ||||||
|  |     type CropAspectRatio, | ||||||
|  |   } from '$lib/stores/asset-editor.store'; | ||||||
|  |   import { mdiBackupRestore, mdiCropFree, mdiRotateLeft, mdiRotateRight, mdiSquareOutline } from '@mdi/js'; | ||||||
|  |   import { t } from 'svelte-i18n'; | ||||||
|  |   import { onImageLoad } from './image-loading'; | ||||||
|  |   import { tick } from 'svelte'; | ||||||
|  |   import CropPreset from './crop-preset.svelte'; | ||||||
|  |  | ||||||
|  |   $: rotateHorizontal = [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 = 'free'; | ||||||
|  |   $cropAspectRatio = selectedSize; | ||||||
|  |  | ||||||
|  |   $: sizesRows = [ | ||||||
|  |     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>{$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}</h2> | ||||||
|  |   </div> | ||||||
|  |   {#each sizesRows as sizesRow} | ||||||
|  |     <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>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2> | ||||||
|  |   </div> | ||||||
|  |   <ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center"> | ||||||
|  |     <li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li> | ||||||
|  |     <li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li> | ||||||
|  |   </ul> | ||||||
|  | </div> | ||||||
| @@ -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 | ||||||
|  |     ) | ||||||
|  |   `; | ||||||
|  | } | ||||||
| @@ -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)); | ||||||
|  | } | ||||||
| @@ -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<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); | ||||||
|  | } | ||||||
| @@ -0,0 +1,76 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import { websocketEvents } from '$lib/stores/websocket'; | ||||||
|  |   import { type AssetResponseDto } from '@immich/sdk'; | ||||||
|  |   import { mdiClose } from '@mdi/js'; | ||||||
|  |   import { onMount } from 'svelte'; | ||||||
|  |   import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte'; | ||||||
|  |   import { t } from 'svelte-i18n'; | ||||||
|  |   import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; | ||||||
|  |   import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; | ||||||
|  |   import { shortcut } from '$lib/actions/shortcut'; | ||||||
|  |  | ||||||
|  |   export let asset: AssetResponseDto; | ||||||
|  |  | ||||||
|  |   onMount(() => { | ||||||
|  |     return websocketEvents.on('on_asset_update', (assetUpdate) => { | ||||||
|  |       if (assetUpdate.id === asset.id) { | ||||||
|  |         asset = assetUpdate; | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   export let onUpdateSelectedType: (type: string) => void; | ||||||
|  |   export let onClose: () => void; | ||||||
|  |  | ||||||
|  |   let selectedType: string = editTypes[0].name; | ||||||
|  |   $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; | ||||||
|  |  | ||||||
|  |   setTimeout(() => { | ||||||
|  |     onUpdateSelectedType(selectedType); | ||||||
|  |   }, 1); | ||||||
|  |   function selectType(name: string) { | ||||||
|  |     selectedType = name; | ||||||
|  |     onUpdateSelectedType(selectedType); | ||||||
|  |   } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <svelte:window 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"> | ||||||
|  |     <CircleIconButton icon={mdiClose} title={$t('close')} on:click={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> | ||||||
|  |           <CircleIconButton | ||||||
|  |             color={etype.name === selectedType ? 'primary' : 'opaque'} | ||||||
|  |             icon={etype.icon} | ||||||
|  |             title={etype.name} | ||||||
|  |             on:click={() => selectType(etype.name)} | ||||||
|  |           /> | ||||||
|  |         </li> | ||||||
|  |       {/each} | ||||||
|  |     </ul> | ||||||
|  |   </section> | ||||||
|  |   <section> | ||||||
|  |     <svelte:component this={selectedTypeObj.component} /> | ||||||
|  |   </section> | ||||||
|  | </section> | ||||||
|  |  | ||||||
|  | {#if $showCancelConfirmDialog} | ||||||
|  |   <ConfirmDialog | ||||||
|  |     title={$t('editor_close_without_save_title')} | ||||||
|  |     prompt={$t('editor_close_without_save_prompt')} | ||||||
|  |     cancelText={$t('no')} | ||||||
|  |     cancelColor="secondary" | ||||||
|  |     confirmColor="red" | ||||||
|  |     confirmText={$t('close')} | ||||||
|  |     onCancel={() => { | ||||||
|  |       $showCancelConfirmDialog = false; | ||||||
|  |     }} | ||||||
|  |     onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())} | ||||||
|  |   /> | ||||||
|  | {/if} | ||||||
| @@ -359,6 +359,7 @@ | |||||||
|   "allow_edits": "Allow edits", |   "allow_edits": "Allow edits", | ||||||
|   "allow_public_user_to_download": "Allow public user to download", |   "allow_public_user_to_download": "Allow public user to download", | ||||||
|   "allow_public_user_to_upload": "Allow public user to upload", |   "allow_public_user_to_upload": "Allow public user to upload", | ||||||
|  |   "anti_clockwise": "Anti-clockwise", | ||||||
|   "api_key": "API Key", |   "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_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", |   "api_key_empty": "Your API Key name shouldn't be empty", | ||||||
| @@ -434,6 +435,7 @@ | |||||||
|   "clear_all_recent_searches": "Clear all recent searches", |   "clear_all_recent_searches": "Clear all recent searches", | ||||||
|   "clear_message": "Clear message", |   "clear_message": "Clear message", | ||||||
|   "clear_value": "Clear value", |   "clear_value": "Clear value", | ||||||
|  |   "clockwise": "Сlockwise", | ||||||
|   "close": "Close", |   "close": "Close", | ||||||
|   "collapse": "Collapse", |   "collapse": "Collapse", | ||||||
|   "collapse_all": "Collapse all", |   "collapse_all": "Collapse all", | ||||||
| @@ -535,6 +537,11 @@ | |||||||
|   "edit_title": "Edit Title", |   "edit_title": "Edit Title", | ||||||
|   "edit_user": "Edit user", |   "edit_user": "Edit user", | ||||||
|   "edited": "Edited", |   "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", |   "email": "Email", | ||||||
|   "empty_trash": "Empty trash", |   "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!", |   "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!", | ||||||
|   | |||||||
| @@ -360,6 +360,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": "Против часовой", | ||||||
|   "api_key": "API Ключ", |   "api_key": "API Ключ", | ||||||
|   "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", |   "api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.", | ||||||
|   "api_key_empty": "Ваш API ключ не должен быть пустым", |   "api_key_empty": "Ваш API ключ не должен быть пустым", | ||||||
| @@ -441,6 +442,7 @@ | |||||||
|   "clear_all_recent_searches": "Очистить все недавние результаты поиска", |   "clear_all_recent_searches": "Очистить все недавние результаты поиска", | ||||||
|   "clear_message": "Очистить сообщение", |   "clear_message": "Очистить сообщение", | ||||||
|   "clear_value": "Очистить значение", |   "clear_value": "Очистить значение", | ||||||
|  |   "clockwise": "По часовой", | ||||||
|   "close": "Закрыть", |   "close": "Закрыть", | ||||||
|   "collapse": "Свернуть", |   "collapse": "Свернуть", | ||||||
|   "collapse_all": "Свернуть всё", |   "collapse_all": "Свернуть всё", | ||||||
| @@ -550,6 +552,10 @@ | |||||||
|   "edit_user": "Редактировать пользователя", |   "edit_user": "Редактировать пользователя", | ||||||
|   "edited": "Отредактировано", |   "edited": "Отредактировано", | ||||||
|   "editor": "Редактор", |   "editor": "Редактор", | ||||||
|  |   "editor_close_without_save_prompt": "Изменения не будут сохранены", | ||||||
|  |   "editor_close_without_save_title": "Закрыть редактор?", | ||||||
|  |   "editor_crop_tool_h2_aspect_ratios": "Соотношения сторон", | ||||||
|  |   "editor_crop_tool_h2_rotation": "Вращение", | ||||||
|   "email": "Электронная почта", |   "email": "Электронная почта", | ||||||
|   "empty": "", |   "empty": "", | ||||||
|   "empty_album": "Пустой альбом", |   "empty_album": "Пустой альбом", | ||||||
|   | |||||||
							
								
								
									
										73
									
								
								web/src/lib/stores/asset-editor.store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								web/src/lib/stores/asset-editor.store.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<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 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; | ||||||
|  | }; | ||||||
		Reference in New Issue
	
	Block a user