Skip to content

Commit e9a79ae

Browse files
feat(ui): add concept for editable image state
1 parent fe54971 commit e9a79ae

File tree

9 files changed

+345
-125
lines changed

9 files changed

+345
-125
lines changed

invokeai/frontend/web/src/features/controlLayers/store/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ export const zImageWithDims = z.object({
3737
});
3838
export type ImageWithDims = z.infer<typeof zImageWithDims>;
3939

40+
const zCropBox = z.object({
41+
x: z.number().int().min(0),
42+
y: z.number().int().min(0),
43+
width: z.number().int().positive(),
44+
height: z.number().int().positive(),
45+
});
46+
export const zCroppableImage = z.object({
47+
original: zImageWithDims,
48+
crop: z
49+
.object({
50+
box: zCropBox,
51+
ratio: z.number().gt(0).nullable(),
52+
image: zImageWithDims,
53+
})
54+
.optional(),
55+
});
56+
export type CroppableImageWithDims = z.infer<typeof zCroppableImage>;
57+
4058
const zImageWithDimsDataURL = z.object({
4159
dataURL: z.string(),
4260
width: z.number().int().positive(),

invokeai/frontend/web/src/features/controlLayers/store/util.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
ChatGPT4oReferenceImageConfig,
1111
ControlLoRAConfig,
1212
ControlNetConfig,
13+
CroppableImageWithDims,
1314
FluxKontextReferenceImageConfig,
1415
FLUXReduxConfig,
1516
Gemini2_5ReferenceImageConfig,
@@ -45,6 +46,21 @@ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO)
4546
height,
4647
});
4748

49+
export const imageDTOToCroppableImage = (
50+
originalImageDTO: ImageDTO,
51+
crop?: CroppableImageWithDims['crop']
52+
): CroppableImageWithDims => {
53+
const { image_name, width, height } = originalImageDTO;
54+
const val: CroppableImageWithDims = {
55+
original: { image_name, width, height },
56+
};
57+
if (crop) {
58+
val.crop = deepClone(crop);
59+
}
60+
61+
return val;
62+
};
63+
4864
export const imageDTOToImageField = ({ image_name }: ImageDTO): ImageField => ({ image_name });
4965

5066
const DEFAULT_RG_MASK_FILL_COLORS: RgbColor[] = [

invokeai/frontend/web/src/features/dnd/DndImage.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { singleImageDndSource } from 'features/dnd/dnd';
99
import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage';
1010
import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage';
1111
import { firefoxDndFix } from 'features/dnd/util';
12+
import { Editor } from 'features/editImageModal/lib/editor';
13+
import { openEditImageModal } from 'features/editImageModal/store';
1214
import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu';
13-
import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react';
15+
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
1416
import type { ImageDTO } from 'services/api/types';
1517

1618
const sx = {
@@ -26,12 +28,14 @@ const sx = {
2628
type Props = {
2729
imageDTO: ImageDTO;
2830
asThumbnail?: boolean;
31+
editable?: boolean;
2932
} & ImageProps;
3033

3134
export const DndImage = memo(
32-
forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => {
35+
forwardRef(({ imageDTO, asThumbnail, editable, ...rest }: Props, forwardedRef) => {
3336
const store = useAppStore();
3437
const crossOrigin = useStore($crossOrigin);
38+
const [previewDataURL, setPreviewDataURl] = useState<string | null>(null);
3539

3640
const [isDragging, setIsDragging] = useState(false);
3741
const ref = useRef<HTMLImageElement>(null);
@@ -69,18 +73,32 @@ export const DndImage = memo(
6973

7074
useImageContextMenu(imageDTO, ref);
7175

76+
const edit = useCallback(() => {
77+
if (!editable) {
78+
return;
79+
}
80+
81+
const editor = new Editor();
82+
editor.onCropApply(async () => {
83+
const previewDataURL = await editor.exportImage('dataURL', { withCropOverlay: true });
84+
setPreviewDataURl(previewDataURL);
85+
});
86+
openEditImageModal(imageDTO.image_name, editor);
87+
}, [editable, imageDTO.image_name]);
88+
7289
return (
7390
<>
7491
<Image
7592
role="button"
7693
ref={ref}
77-
src={asThumbnail ? imageDTO.thumbnail_url : imageDTO.image_url}
94+
src={previewDataURL ?? (asThumbnail ? imageDTO.thumbnail_url : imageDTO.image_url)}
7895
fallbackSrc={asThumbnail ? undefined : imageDTO.thumbnail_url}
7996
width={imageDTO.width}
8097
height={imageDTO.height}
8198
sx={sx}
8299
data-is-dragging={isDragging}
83100
crossOrigin={!asThumbnail ? crossOrigin : undefined}
101+
onClick={edit}
84102
{...rest}
85103
/>
86104
{dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null}

invokeai/frontend/web/src/features/dnd/dnd.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerH
44
import { getPrefixedId } from 'features/controlLayers/konva/util';
55
import { refImageAdded } from 'features/controlLayers/store/refImagesSlice';
66
import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types';
7-
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
7+
import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util';
88
import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common';
99
import type { BoardId } from 'features/gallery/store/types';
1010
import {
@@ -641,7 +641,7 @@ export const videoFrameFromImageDndTarget: DndTarget<VideoFrameFromImageDndTarge
641641
},
642642
handler: ({ sourceData, dispatch }) => {
643643
const { imageDTO } = sourceData.payload;
644-
dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
644+
dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO)));
645645
},
646646
};
647647
//#endregion

invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx

Lines changed: 57 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@ type Props = {
1212
imageName: string;
1313
};
1414

15+
const CROP_ASPECT_RATIO_MAP: Record<string, number> = {
16+
'16:9': 16 / 9,
17+
'3:2': 3 / 2,
18+
'4:3': 4 / 3,
19+
'1:1': 1,
20+
'3:4': 3 / 4,
21+
'2:3': 2 / 3,
22+
'9:16': 9 / 16,
23+
};
24+
25+
export const getAspectRatioString = (ratio: number | null) => {
26+
if (!ratio) {
27+
return 'free';
28+
}
29+
const entries = Object.entries(CROP_ASPECT_RATIO_MAP);
30+
for (const [key, value] of entries) {
31+
if (value === ratio) {
32+
return key;
33+
}
34+
}
35+
return 'free';
36+
};
37+
1538
export const EditorContainer = ({ editor, imageName }: Props) => {
1639
const containerRef = useRef<HTMLDivElement>(null);
1740
const [zoom, setZoom] = useState(100);
@@ -26,51 +49,44 @@ export const EditorContainer = ({ editor, imageName }: Props) => {
2649

2750
const setup = useCallback(
2851
async (imageDTO: ImageDTO, container: HTMLDivElement) => {
52+
console.log('Setting up editor');
2953
editor.init(container);
30-
editor.setCallbacks({
31-
onZoomChange: (zoom) => {
32-
setZoom(zoom);
33-
},
34-
onCropStart: () => {
35-
setCropInProgress(true);
36-
setCropBox(null);
37-
},
38-
onCropBoxChange: (crop) => {
39-
setCropBox(crop);
40-
},
41-
onCropApply: () => {
42-
setCropApplied(true);
43-
setCropInProgress(false);
44-
setCropBox(null);
45-
},
46-
onCropReset: () => {
47-
setCropApplied(true);
48-
setCropInProgress(false);
49-
setCropBox(null);
50-
},
51-
onCropCancel: () => {
52-
setCropInProgress(false);
53-
setCropBox(null);
54-
},
55-
onImageLoad: () => {
56-
// setCropInfo('');
57-
// setIsCropping(false);
58-
// setHasCropBbox(false);
59-
},
54+
editor.onZoomChange((zoom) => {
55+
setZoom(zoom);
56+
});
57+
editor.onCropStart(() => {
58+
setCropInProgress(true);
59+
setCropBox(null);
60+
});
61+
editor.onCropBoxChange((crop) => {
62+
setCropBox(crop);
63+
});
64+
editor.onCropApply(() => {
65+
setCropApplied(true);
66+
setCropInProgress(false);
67+
setCropBox(null);
68+
});
69+
editor.onCropReset(() => {
70+
setCropApplied(true);
71+
setCropInProgress(false);
72+
setCropBox(null);
73+
});
74+
editor.onCropCancel(() => {
75+
setCropInProgress(false);
76+
setCropBox(null);
77+
});
78+
editor.onImageLoad(() => {
79+
// setCropInfo('');
80+
// setIsCropping(false);
81+
// setHasCropBbox(false);
6082
});
6183
const blob = await convertImageUrlToBlob(imageDTO.image_url);
6284
if (!blob) {
6385
console.error('Failed to convert image to blob');
6486
return;
6587
}
66-
88+
setAspectRatio(getAspectRatioString(editor.getCropAspectRatio()));
6789
await editor.loadImage(imageDTO.image_url);
68-
editor.startCrop({
69-
x: 0,
70-
y: 0,
71-
width: imageDTO.width,
72-
height: imageDTO.height,
73-
});
7490
editor.fitToContainer();
7591
},
7692
[editor]
@@ -98,15 +114,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => {
98114
editor.startCrop();
99115
// Apply current aspect ratio if not free
100116
if (aspectRatio !== 'free') {
101-
const ratios: Record<string, number> = {
102-
'1:1': 1,
103-
'4:3': 4 / 3,
104-
'16:9': 16 / 9,
105-
'3:2': 3 / 2,
106-
'2:3': 2 / 3,
107-
'9:16': 9 / 16,
108-
};
109-
editor.setCropAspectRatio(ratios[aspectRatio]);
117+
editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[aspectRatio] ?? null);
110118
}
111119
}, [aspectRatio, editor]);
112120

@@ -116,17 +124,9 @@ export const EditorContainer = ({ editor, imageName }: Props) => {
116124
setAspectRatio(newRatio);
117125

118126
if (newRatio === 'free') {
119-
editor.setCropAspectRatio(undefined);
127+
editor.setCropAspectRatio(null);
120128
} else {
121-
const ratios: Record<string, number> = {
122-
'1:1': 1,
123-
'4:3': 4 / 3,
124-
'16:9': 16 / 9,
125-
'3:2': 3 / 2,
126-
'2:3': 2 / 3,
127-
'9:16': 9 / 16,
128-
};
129-
editor.setCropAspectRatio(ratios[newRatio]);
129+
editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[newRatio] ?? null);
130130
}
131131
},
132132
[editor]
@@ -146,7 +146,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => {
146146

147147
const handleExport = useCallback(async () => {
148148
try {
149-
const blob = await editor.exportImage('blob');
149+
const blob = await editor.exportImage('blob', { withCropOverlay: true });
150150
const file = new File([blob], 'image.png', { type: 'image/png' });
151151

152152
await uploadImage({

0 commit comments

Comments
 (0)