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 onPlaySlideshow: () => void; | ||||
|   export let onShowDetail: () => void; | ||||
|   // export let showEditorHandler: () => void; | ||||
|   export let onClose: () => void; | ||||
|  | ||||
|   const sharedLink = getSharedLink(); | ||||
|  | ||||
|   $: isOwner = $user && asset.ownerId === $user?.id; | ||||
|   $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; | ||||
|   // $: showEditorButton = | ||||
|   //   isOwner && | ||||
|   //   asset.type === AssetTypeEnum.Image && | ||||
|   //   !( | ||||
|   //     asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || | ||||
|   //     (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) | ||||
|   //   ) && | ||||
|   //   !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && | ||||
|   //   !asset.livePhotoVideoId; | ||||
| </script> | ||||
|  | ||||
| <div | ||||
| @@ -98,6 +108,15 @@ | ||||
|     {#if isOwner} | ||||
|       <FavoriteAction {asset} {onAction} /> | ||||
|     {/if} | ||||
|     <!-- {#if showEditorButton} | ||||
|       <CircleIconButton | ||||
|         color="opaque" | ||||
|         hideMobile={true} | ||||
|         icon={mdiImageEditOutline} | ||||
|         on:click={showEditorHandler} | ||||
|         title={$t('editor')} | ||||
|       /> | ||||
|     {/if} --> | ||||
|  | ||||
|     {#if isOwner} | ||||
|       <DeleteAction {asset} {onAction} /> | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <svelte:document bind:fullscreenElement /> | ||||
| @@ -393,7 +415,7 @@ | ||||
|   use:focusTrap | ||||
| > | ||||
|   <!-- Top navigation bar --> | ||||
|   {#if $slideshowState === SlideshowState.None} | ||||
|   {#if $slideshowState === SlideshowState.None && !isShowEditor} | ||||
|     <div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> | ||||
|       <AssetViewerNavBar | ||||
|         {asset} | ||||
| @@ -419,7 +441,7 @@ | ||||
|     </div> | ||||
|   {/if} | ||||
|  | ||||
|   {#if $slideshowState === SlideshowState.None && showNavigation} | ||||
|   {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} | ||||
|     <div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> | ||||
|       <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> | ||||
|     </div> | ||||
| @@ -487,6 +509,8 @@ | ||||
|                 .toLowerCase() | ||||
|                 .endsWith('.insp'))} | ||||
|             <PanoramaViewer {asset} /> | ||||
|           {:else if isShowEditor && selectedEditType === 'crop'} | ||||
|             <CropArea {asset} /> | ||||
|           {:else} | ||||
|             <PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} {sharedLink} /> | ||||
|           {/if} | ||||
| @@ -516,13 +540,13 @@ | ||||
|     {/if} | ||||
|   </div> | ||||
|  | ||||
|   {#if $slideshowState === SlideshowState.None && showNavigation} | ||||
|   {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor} | ||||
|     <div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> | ||||
|       <NextAssetAction onNextAsset={() => navigateAsset('next')} /> | ||||
|     </div> | ||||
|   {/if} | ||||
|  | ||||
|   {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail} | ||||
|   {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor} | ||||
|     <div | ||||
|       transition:fly={{ duration: 150 }} | ||||
|       id="detail-panel" | ||||
| @@ -533,6 +557,17 @@ | ||||
|     </div> | ||||
|   {/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} | ||||
|     <div | ||||
|       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_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!", | ||||
|   | ||||
| @@ -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": "Пустой альбом", | ||||
|   | ||||
							
								
								
									
										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