11import { Flex } from '@invoke-ai/ui-library' ;
22import { useStore } from '@nanostores/react' ;
3+ import { objectEquals } from '@observ33r/object-equals' ;
34import { skipToken } from '@reduxjs/toolkit/query' ;
45import { useAppSelector , useAppStore } from 'app/store/storeHooks' ;
56import { UploadImageIconButton } from 'common/hooks/useImageUploadButton' ;
67import { bboxSizeOptimized , bboxSizeRecalled } from 'features/controlLayers/store/canvasSlice' ;
78import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice' ;
89import { sizeOptimized , sizeRecalled } from 'features/controlLayers/store/paramsSlice' ;
9- import type { ImageWithDims } from 'features/controlLayers/store/types' ;
10+ import type { CroppableImageWithDims } from 'features/controlLayers/store/types' ;
11+ import { imageDTOToCroppableImage , imageDTOToImageWithDims } from 'features/controlLayers/store/util' ;
12+ import { Editor } from 'features/cropper/lib/editor' ;
13+ import { cropImageModalApi } from 'features/cropper/store' ;
1014import type { setGlobalReferenceImageDndTarget , setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd' ;
1115import { DndDropTarget } from 'features/dnd/DndDropTarget' ;
1216import { DndImage } from 'features/dnd/DndImage' ;
1317import { DndImageIcon } from 'features/dnd/DndImageIcon' ;
1418import { selectActiveTab } from 'features/ui/store/uiSelectors' ;
1519import { memo , useCallback , useEffect } from 'react' ;
1620import { useTranslation } from 'react-i18next' ;
17- import { PiArrowCounterClockwiseBold , PiRulerBold } from 'react-icons/pi' ;
18- import { useGetImageDTOQuery } from 'services/api/endpoints/images' ;
21+ import { PiArrowCounterClockwiseBold , PiCropBold , PiRulerBold } from 'react-icons/pi' ;
22+ import { useGetImageDTOQuery , useUploadImageMutation } from 'services/api/endpoints/images' ;
1923import type { ImageDTO } from 'services/api/types' ;
2024import { $isConnected } from 'services/events/stores' ;
2125
2226type Props < T extends typeof setGlobalReferenceImageDndTarget | typeof setRegionalGuidanceReferenceImageDndTarget > = {
23- image : ImageWithDims | null ;
24- onChangeImage : ( imageDTO : ImageDTO | null ) => void ;
27+ image : CroppableImageWithDims | null ;
28+ onChangeImage : ( croppableImage : CroppableImageWithDims | null ) => void ;
2529 dndTarget : T ;
2630 dndTargetData : ReturnType < T [ 'getData' ] > ;
2731} ;
@@ -38,20 +42,28 @@ export const RefImageImage = memo(
3842 const isConnected = useStore ( $isConnected ) ;
3943 const tab = useAppSelector ( selectActiveTab ) ;
4044 const isStaging = useCanvasIsStaging ( ) ;
41- const { currentData : imageDTO , isError } = useGetImageDTOQuery ( image ?. image_name ?? skipToken ) ;
45+ const imageWithDims = image ?. crop ?. image ?? image ?. original . image ?? null ;
46+ const croppedImageDTOReq = useGetImageDTOQuery ( image ?. crop ?. image ?. image_name ?? skipToken ) ;
47+ const originalImageDTOReq = useGetImageDTOQuery ( image ?. original . image . image_name ?? skipToken ) ;
48+ const [ uploadImage ] = useUploadImageMutation ( ) ;
49+
50+ const originalImageDTO = originalImageDTOReq . currentData ;
51+ const croppedImageDTO = croppedImageDTOReq . currentData ;
52+ const imageDTO = croppedImageDTO ?? originalImageDTO ;
53+
4254 const handleResetControlImage = useCallback ( ( ) => {
4355 onChangeImage ( null ) ;
4456 } , [ onChangeImage ] ) ;
4557
4658 useEffect ( ( ) => {
47- if ( isConnected && isError ) {
59+ if ( ( isConnected && croppedImageDTOReq . isError ) || originalImageDTOReq . isError ) {
4860 handleResetControlImage ( ) ;
4961 }
50- } , [ handleResetControlImage , isError , isConnected ] ) ;
62+ } , [ handleResetControlImage , isConnected , croppedImageDTOReq . isError , originalImageDTOReq . isError ] ) ;
5163
5264 const onUpload = useCallback (
5365 ( imageDTO : ImageDTO ) => {
54- onChangeImage ( imageDTO ) ;
66+ onChangeImage ( imageDTOToCroppableImage ( imageDTO ) ) ;
5567 } ,
5668 [ onChangeImage ]
5769 ) ;
@@ -70,13 +82,67 @@ export const RefImageImage = memo(
7082 }
7183 } , [ imageDTO , isStaging , store , tab ] ) ;
7284
85+ const edit = useCallback ( ( ) => {
86+ if ( ! originalImageDTO ) {
87+ return ;
88+ }
89+
90+ // We will create a new editor instance each time the user wants to edit
91+ const editor = new Editor ( ) ;
92+
93+ // When the user applies the crop, we will upload the cropped image and store the applied crop box so if the user
94+ // re-opens the editor they see the same crop
95+ const onApplyCrop = async ( ) => {
96+ const box = editor . getCropBox ( ) ;
97+ if ( objectEquals ( box , image ?. crop ?. box ) ) {
98+ // If the box hasn't changed, don't do anything
99+ return ;
100+ }
101+ if ( ! box || objectEquals ( box , { x : 0 , y : 0 , width : originalImageDTO . width , height : originalImageDTO . height } ) ) {
102+ // There is a crop applied but it is the whole iamge - revert to original image
103+ onChangeImage ( imageDTOToCroppableImage ( originalImageDTO ) ) ;
104+ return ;
105+ }
106+ const blob = await editor . exportImage ( 'blob' ) ;
107+ const file = new File ( [ blob ] , 'image.png' , { type : 'image/png' } ) ;
108+
109+ const newCroppedImageDTO = await uploadImage ( {
110+ file,
111+ is_intermediate : true ,
112+ image_category : 'user' ,
113+ } ) . unwrap ( ) ;
114+
115+ onChangeImage (
116+ imageDTOToCroppableImage ( originalImageDTO , {
117+ image : imageDTOToImageWithDims ( newCroppedImageDTO ) ,
118+ box,
119+ ratio : editor . getCropAspectRatio ( ) ,
120+ } )
121+ ) ;
122+ } ;
123+
124+ const onReady = async ( ) => {
125+ const initial = image ?. crop ? { cropBox : image . crop . box , aspectRatio : image . crop . ratio } : undefined ;
126+ // Load the image into the editor and open the modal once it's ready
127+ await editor . loadImage ( originalImageDTO . image_url , initial ) ;
128+ } ;
129+
130+ cropImageModalApi . open ( { editor, onApplyCrop, onReady } ) ;
131+ } , [ image ?. crop , onChangeImage , originalImageDTO , uploadImage ] ) ;
132+
73133 return (
74- < Flex position = "relative" w = "full" h = "full" alignItems = "center" data-error = { ! imageDTO && ! image ?. image_name } >
134+ < Flex
135+ position = "relative"
136+ w = "full"
137+ h = "full"
138+ alignItems = "center"
139+ data-error = { ! imageDTO && ! imageWithDims ?. image_name }
140+ >
75141 { ! imageDTO && (
76142 < UploadImageIconButton
77143 w = "full"
78144 h = "full"
79- isError = { ! imageDTO && ! image ?. image_name }
145+ isError = { ! imageDTO && ! imageWithDims ?. image_name }
80146 onUpload = { onUpload }
81147 fontSize = { 36 }
82148 />
@@ -99,6 +165,15 @@ export const RefImageImage = memo(
99165 isDisabled = { ! imageDTO || ( tab === 'canvas' && isStaging ) }
100166 />
101167 </ Flex >
168+
169+ < Flex position = "absolute" flexDir = "column" top = { 2 } insetInlineStart = { 2 } gap = { 1 } >
170+ < DndImageIcon
171+ onClick = { edit }
172+ icon = { < PiCropBold size = { 16 } /> }
173+ tooltip = { t ( 'common.crop' ) }
174+ isDisabled = { ! imageDTO }
175+ />
176+ </ Flex >
102177 </ >
103178 ) }
104179 < DndDropTarget dndTarget = { dndTarget } dndTargetData = { dndTargetData } label = { t ( 'gallery.drop' ) } />
0 commit comments