From 1f6a9a9611433dc3e63a0b1be253f0a4761a928a Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 10 Sep 2025 15:46:32 -0400 Subject: [PATCH 01/34] show warning state with tooltip if starting frame image aspect ratio does not match the video output aspect ratio' --- invokeai/frontend/web/public/locales/en.json | 1 + .../StartingFrameImage.tsx | 34 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0ecb003fd62..0be5afbf718 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1269,6 +1269,7 @@ "infillColorValue": "Fill Color", "info": "Info", "startingFrameImage": "Start Frame", + "startingFrameImageAspectRatioWarning": "Image aspect ratio does not match the video aspect ratio ({{videoAspectRatio}}). This could lead to unexpected cropping during video generation.", "invoke": { "addingImagesTo": "Adding images to", "modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your account settings to upgrade.", diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index a218afbf51f..4286d3129a6 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -1,6 +1,7 @@ -import { Flex, FormLabel, Text } from '@invoke-ai/ui-library'; +import { Flex, FormLabel, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { ASPECT_RATIO_MAP } from 'features/controlLayers/store/types'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -8,12 +9,13 @@ import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { selectStartingFrameImage, + selectVideoAspectRatio, selectVideoModelRequiresStartingFrame, startingFrameImageChanged, } from 'features/parameters/store/videoSlice'; import { t } from 'i18next'; -import { useCallback } from 'react'; -import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; +import { useCallback, useMemo } from 'react'; +import { PiArrowCounterClockwiseBold, PiWarningBold } from 'react-icons/pi'; import { useImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -24,6 +26,7 @@ export const StartingFrameImage = () => { const requiresStartingFrame = useAppSelector(selectVideoModelRequiresStartingFrame); const startingFrameImage = useAppSelector(selectStartingFrameImage); const imageDTO = useImageDTO(startingFrameImage?.image_name); + const videoAspectRatio = useAppSelector(selectVideoAspectRatio); const onReset = useCallback(() => { dispatch(startingFrameImageChanged(null)); @@ -36,10 +39,20 @@ export const StartingFrameImage = () => { [dispatch] ); + const fitsCurrentAspectRatio = useMemo(() => { + if (!imageDTO) { + return true; + } + console.log('imageDTO.width / imageDTO.height', imageDTO.width / imageDTO.height); + console.log('ASPECT_RATIO_MAP[videoAspectRatio]?.ratio', ASPECT_RATIO_MAP[videoAspectRatio]?.ratio); + console.log('fitsCurrentAspectRatio', imageDTO.width / imageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio); + return imageDTO.width / imageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio; + }, [imageDTO, videoAspectRatio]); + return ( {t('parameters.startingFrameImage')} - + {!imageDTO && ( { tooltip={t('common.reset')} /> + + {!fitsCurrentAspectRatio && + + + + } + Date: Wed, 10 Sep 2025 16:30:17 -0400 Subject: [PATCH 02/34] create editImageModal that takes an imageDTO, loads blob onto canvas, and allows cropping. cropped blob is uploaded as new asset --- .../app/components/GlobalModalIsolator.tsx | 2 + .../web/src/features/dnd/DndImageIcon.tsx | 4 +- .../components/EditImageModal.tsx | 29 + .../components/EditorContainer.tsx | 213 +++ .../editImageModal/hooks/useEditor.ts | 33 + .../src/features/editImageModal/lib/editor.ts | 1534 +++++++++++++++++ .../features/editImageModal/store/index.ts | 4 + .../StartingFrameImage.tsx | 54 +- 8 files changed, 1853 insertions(+), 20 deletions(-) create mode 100644 invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx create mode 100644 invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx create mode 100644 invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts create mode 100644 invokeai/frontend/web/src/features/editImageModal/lib/editor.ts create mode 100644 invokeai/frontend/web/src/features/editImageModal/store/index.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index b6e64dc86c6..2f0d080cb33 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -6,6 +6,7 @@ import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteIma import { DeleteVideoModal } from 'features/deleteVideoModal/components/DeleteVideoModal'; import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; +import { EditImageModal } from 'features/editImageModal/components/EditImageModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; import { ImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; import { VideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; @@ -58,6 +59,7 @@ export const GlobalModalIsolator = memo(() => { + ); }); diff --git a/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx index 6b634b898b4..192711c5441 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImageIcon.tsx @@ -3,7 +3,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import type { MouseEvent } from 'react'; import { memo } from 'react'; -const sx: SystemStyleObject = { +export const imageButtonSx: SystemStyleObject = { minW: 0, svg: { transitionProperty: 'common', @@ -31,7 +31,7 @@ export const DndImageIcon = memo((props: Props) => { aria-label={tooltip} icon={icon} variant="link" - sx={sx} + sx={imageButtonSx} data-testid={tooltip} {...rest} /> diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx new file mode 100644 index 00000000000..0b3b428e3cc --- /dev/null +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx @@ -0,0 +1,29 @@ +import { Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from "@invoke-ai/ui-library"; +import { useStore } from "@nanostores/react"; +import { $isOpen } from "features/editImageModal/store"; +import { useCallback } from "react"; + +import { EditorContainer } from "./EditorContainer"; + +export const EditImageModal = () => { + const isOpen = useStore($isOpen); + const onClose = useCallback(() => { + $isOpen.set(false); + }, []); + + return + + + Edit Image + + + + + ; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx new file mode 100644 index 00000000000..c3d544006a1 --- /dev/null +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -0,0 +1,213 @@ +import { Button, Flex, Select } from "@invoke-ai/ui-library"; +import { skipToken } from "@reduxjs/toolkit/query"; +import { useAppSelector } from "app/store/storeHooks"; +import { convertImageUrlToBlob } from "common/util/convertImageUrlToBlob"; +import { useEditor } from "features/editImageModal/hooks/useEditor"; +import { $imageName } from "features/editImageModal/store"; +import { selectAutoAddBoardId } from "features/gallery/store/gallerySelectors"; +import { useCallback,useEffect, useRef, useState } from "react"; +import { useGetImageDTOQuery, useUploadImageMutation } from "services/api/endpoints/images"; + +export const EditorContainer = () => { + const containerRef = useRef(null); + const editor = useEditor({ containerRef }); + const [zoomLevel, setZoomLevel] = useState(100); + const [cropInfo, setCropInfo] = useState(""); + const [isInCropMode, setIsInCropMode] = useState(false); + const [hasCrop, setHasCrop] = useState(false); + const [aspectRatio, setAspectRatio] = useState("free"); + const { data: imageDTO } = useGetImageDTOQuery($imageName.get() ?? skipToken); + const autoAddBoardId = useAppSelector(selectAutoAddBoardId); + + const [uploadImage, { isLoading }] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + + + const loadImage = useCallback(async () => { + if (!imageDTO) { + console.error("Image not found"); + return; + } + const blob = await convertImageUrlToBlob(imageDTO.image_url); + if (!blob) { + console.error("Failed to convert image to blob"); + return; + } + await editor.loadImage(blob); + }, [editor]); + + // Setup callbacks + useEffect(() => { + loadImage(); + editor.setCallbacks({ + onZoomChange: (zoom) => setZoomLevel(Math.round(zoom * 100)), + onCropChange: (crop) => setCropInfo(`Crop: ${Math.round(crop.x)}, ${Math.round(crop.y)} - ${Math.round(crop.width)}x${Math.round(crop.height)}`), + onImageLoad: () => { + setCropInfo(""); + setIsInCropMode(false); + setHasCrop(false); + } + }); + + + }, [editor, loadImage]); + + + + const handleStartCrop = () => { + editor.startCrop(); + setIsInCropMode(true); + // Apply current aspect ratio if not free + if (aspectRatio !== "free") { + const ratios: Record = { + "1:1": 1, + "4:3": 4 / 3, + "16:9": 16 / 9, + "3:2": 3 / 2, + "2:3": 2 / 3, + "9:16": 9 / 16, + }; + editor.setCropAspectRatio(ratios[aspectRatio]); + } + }; + + const handleAspectRatioChange = (e: React.ChangeEvent) => { + const newRatio = e.target.value; + setAspectRatio(newRatio); + + if (newRatio === "free") { + editor.setCropAspectRatio(undefined); + } else { + const ratios: Record = { + "1:1": 1, + "4:3": 4 / 3, + "16:9": 16 / 9, + "3:2": 3 / 2, + "2:3": 2 / 3, + "9:16": 9 / 16, + }; + editor.setCropAspectRatio(ratios[newRatio]); + } + }; + + const handleApplyCrop = () => { + editor.applyCrop(); + setIsInCropMode(false); + setHasCrop(true); + setCropInfo(""); + setAspectRatio("free"); + }; + + const handleCancelCrop = () => { + editor.cancelCrop(); + setIsInCropMode(false); + setCropInfo(""); + setAspectRatio("free"); + }; + + const handleResetCrop = () => { + editor.resetCrop(); + setHasCrop(false); + }; + + const handleExport = async () => { + try { + const blob = await editor.exportImage("blob") as Blob; + const file = new File([blob], "image.png", { type: "image/png" }); + + await uploadImage({ + file, + is_intermediate: false, + image_category: 'user', + board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, + }).unwrap(); + + + + } catch (err) { + console.error("Export failed:", err); + if (err instanceof Error && err.message.includes("tainted")) { + alert("Cannot export image: The image is from a different domain (CORS issue). To fix this:\n\n1. Load images from the same domain\n2. Use images from CORS-enabled sources\n3. Upload a local image file instead"); + } else { + alert(`Export failed: ${ err instanceof Error ? err.message : String(err)}`); + } + } + }; + + return ( + + + + + + {!isInCropMode && ( + <> + + {hasCrop && } + + )} + {isInCropMode && ( + <> + + + + + )} + + + + + + + + + + + + + Zoom: {zoomLevel}% + {cropInfo && {cropInfo}} + {hasCrop && ✓ Crop Applied} + + + + + + Mouse wheel: Zoom + Space + Drag: Pan + {isInCropMode && Drag crop box or handles to adjust} + {isInCropMode && aspectRatio !== "free" && Aspect ratio: {aspectRatio}} + + + + ); +} + diff --git a/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts b/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts new file mode 100644 index 00000000000..f753dd4a8c0 --- /dev/null +++ b/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts @@ -0,0 +1,33 @@ +import { Editor } from "features/editImageModal/lib/editor"; +import type { RefObject} from "react"; +import { useEffect,useState } from "react"; + +export const useEditor = (arg: { containerRef: RefObject }) => { + const editor = useState(() => new Editor())[0]; + + useEffect(() => { + const container = arg.containerRef.current; + if (container) { + editor.init(container); + + // Handle window resize + const handleResize = () => { + editor.resize(container.clientWidth, container.clientHeight); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + } + }, [arg.containerRef, editor]); + + // Clean up editor on unmount + useEffect(() => { + return () => { + editor.destroy(); + }; + }, [editor]); + + return editor; +}; diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts new file mode 100644 index 00000000000..3b15825aa9b --- /dev/null +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -0,0 +1,1534 @@ +import Konva from "konva"; + +interface CropConstraints { + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + aspectRatio?: number; +} + +interface EditorCallbacks { + onCropChange?: (crop: { + x: number; + y: number; + width: number; + height: number; + }) => void; + onZoomChange?: (zoom: number) => void; + onImageLoad?: () => void; +} + +interface CropData { + x: number; + y: number; + width: number; + height: number; +} + +interface KonvaObjects { + stage: Konva.Stage; + image?: { + layer: Konva.Layer; + node: Konva.Image; + }; + crop?: { + layer: Konva.Layer; + rect: Konva.Rect; + overlay: Konva.Group; + handles: Konva.Group; + guides: Konva.Group; + }; +} + +export class Editor { + private konva?: KonvaObjects; + private originalImage?: HTMLImageElement; + private isInCropMode = false; + private appliedCrop?: CropData; + + // Configuration + private zoomMin = 0.1; + private zoomMax = 10; + private cropConstraints: CropConstraints = { + minWidth: 64, + minHeight: 64, + }; + private callbacks: EditorCallbacks = {}; + + // State + private isPanning = false; + private lastPointerPosition?: { x: number; y: number }; + private isSpacePressed = false; + private keydownHandler?: (e: KeyboardEvent) => void; + private keyupHandler?: (e: KeyboardEvent) => void; + private contextMenuHandler?: (e: Event) => void; + private currentImageBlobUrl?: string; + private wheelHandler?: (e: WheelEvent) => void; + + init = (container: HTMLDivElement) => { + // Create stage + this.konva = { + stage: new Konva.Stage({ + container: container, + width: container.clientWidth, + height: container.clientHeight, + }), + }; + + // Setup mouse event handlers + this.setupStageEvents(); + }; + + private setupStageEvents = () => { + if (!this.konva) { +return; +} + const stage = this.konva.stage; + + // Zoom with mouse wheel + this.wheelHandler = (e: WheelEvent) => { + e.preventDefault(); + + const oldScale = stage.scaleX(); + const pointer = stage.getPointerPosition(); + + if (!pointer) { +return; +} + + const mousePointTo = { + x: (pointer.x - stage.x()) / oldScale, + y: (pointer.y - stage.y()) / oldScale, + }; + + const direction = e.deltaY > 0 ? -1 : 1; + const scaleBy = 1.1; + let newScale = + direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; + + // Apply zoom limits + newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); + + stage.scale({ x: newScale, y: newScale }); + + const newPos = { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, + }; + stage.position(newPos); + + // Update handle scaling to maintain constant screen size + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(newScale); + }; + + stage + .container() + .addEventListener("wheel", this.wheelHandler, { passive: false }); + + // Track Space key press + this.keydownHandler = (e: KeyboardEvent) => { + if (e.code === "Space" && !this.isSpacePressed) { + e.preventDefault(); + this.isSpacePressed = true; + if (stage) { + stage.container().style.cursor = "grab"; + } + } + }; + + this.keyupHandler = (e: KeyboardEvent) => { + if (e.code === "Space") { + e.preventDefault(); + this.isSpacePressed = false; + this.isPanning = false; + if (stage) { + stage.container().style.cursor = "default"; + } + } + }; + + window.addEventListener("keydown", this.keydownHandler); + window.addEventListener("keyup", this.keyupHandler); + + // Pan with Space + drag or middle mouse button + stage.on("mousedown", (e) => { + if (this.isSpacePressed || e.evt.button === 1) { + e.evt.preventDefault(); + e.evt.stopPropagation(); + this.isPanning = true; + this.lastPointerPosition = + stage.getPointerPosition() || undefined; + stage.container().style.cursor = "grabbing"; + + // Stop any active drags on crop elements + if (this.konva?.crop) { + if (this.konva.crop.rect.isDragging()) { + this.konva.crop.rect.stopDrag(); + } + this.konva.crop.handles.children.forEach((handle) => { + if (handle.isDragging()) { + handle.stopDrag(); + } + }); + } + } + }); + + stage.on("mousemove", () => { + if (!this.isPanning || !this.lastPointerPosition) { +return; +} + + const pointer = stage.getPointerPosition(); + if (!pointer) { +return; +} + + const dx = pointer.x - this.lastPointerPosition.x; + const dy = pointer.y - this.lastPointerPosition.y; + + stage.x(stage.x() + dx); + stage.y(stage.y() + dy); + + this.lastPointerPosition = pointer; + }); + + stage.on("mouseup", () => { + if (this.isPanning) { + this.isPanning = false; + stage.container().style.cursor = this.isSpacePressed + ? "grab" + : "default"; + } + }); + + // Prevent context menu on right click + this.contextMenuHandler = (e: Event) => e.preventDefault(); + stage + .container() + .addEventListener("contextmenu", this.contextMenuHandler); + }; + + // Image Management + loadImage = async (src: string | File | Blob): Promise => { + return new Promise((resolve, reject) => { + // Clean up previous blob URL if it exists + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; + } + + const img = new Image(); + + // Set crossOrigin to avoid CORS issues when exporting + if (typeof src === "string") { + img.crossOrigin = "anonymous"; + } + + img.onload = () => { + this.originalImage = img; + this.displayImage(); + this.callbacks.onImageLoad?.(); + resolve(); + }; + + img.onerror = () => { + // Clean up blob URL on error + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; + } + reject(new Error("Failed to load image")); + }; + + if (typeof src === "string") { + img.src = src; + } else if (src instanceof File || src instanceof Blob) { + const url = URL.createObjectURL(src); + this.currentImageBlobUrl = url; + img.src = url; + } + }); + }; + + private displayImage = () => { + if (!this.originalImage || !this.konva) { +return; +} + + // Clear existing image + if (this.konva.image) { + this.konva.image.node.destroy(); + this.konva.image.layer.destroy(); + this.konva.image = undefined; + } + + // Create image layer and node + const imageLayer = new Konva.Layer(); + let imageNode: Konva.Image; + + if (this.appliedCrop) { + imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.appliedCrop.width, + height: this.appliedCrop.height, + crop: { + x: this.appliedCrop.x, + y: this.appliedCrop.y, + width: this.appliedCrop.width, + height: this.appliedCrop.height, + }, + }); + } else { + imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.originalImage.width, + height: this.originalImage.height, + }); + } + + imageLayer.add(imageNode); + this.konva.stage.add(imageLayer); + + // Store references + this.konva.image = { + layer: imageLayer, + node: imageNode, + }; + + imageLayer.batchDraw(); + + // Center image at 100% zoom + this.resetView(); + }; + + // Crop Mode + startCrop = () => { + if (!this.konva?.image || this.isInCropMode) { +return; +} + + this.isInCropMode = true; + + // Calculate initial crop dimensions + let cropX: number; let cropY: number; let cropWidth: number; let cropHeight: number; + + if (this.appliedCrop) { + // When cropped, start with full visible area + cropX = 0; + cropY = 0; + cropWidth = this.appliedCrop.width; + cropHeight = this.appliedCrop.height; + } else { + // Create default crop box (centered, 80% of image) + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + cropWidth = imgWidth * 0.8; + cropHeight = imgHeight * 0.8; + cropX = (imgWidth - cropWidth) / 2; + cropY = (imgHeight - cropHeight) / 2; + } + + this.createCropBox(cropX, cropY, cropWidth, cropHeight); + }; + + private createCropBox = ( + x: number, + y: number, + width: number, + height: number, + ) => { + if (!this.konva?.image) { +return; +} + + // Clear existing crop if any + if (this.konva.crop) { + this.konva.crop.layer.destroy(); + this.konva.crop = undefined; + } + + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + // Create crop layer + const cropLayer = new Konva.Layer(); + + // Create overlay group with composite operation + const overlay = new Konva.Group(); + + // Create full overlay + const fullOverlay = new Konva.Rect({ + x: 0, + y: 0, + width: imgWidth, + height: imgHeight, + fill: "black", + opacity: 0.5, + }); + + // Create clear rectangle for crop area using composite operation + const clearRect = new Konva.Rect({ + x: x, + y: y, + width: width, + height: height, + fill: "black", + globalCompositeOperation: "destination-out", + }); + + overlay.add(fullOverlay); + overlay.add(clearRect); + + // Create crop rectangle + const rect = new Konva.Rect({ + x: x, + y: y, + width: width, + height: height, + stroke: "white", + strokeWidth: 1, + strokeScaleEnabled: false, + draggable: true, + }); + + // Create handles group + const handles = new Konva.Group(); + + // Create guides group + const guides = new Konva.Group(); + + // Store all crop objects together + this.konva.crop = { + layer: cropLayer, + rect: rect, + overlay: overlay, + handles: handles, + guides: guides, + }; + + // Create handles and guides + this.createCropHandles(); + this.createCropGuides(); + + // Setup crop box events + this.setupCropBoxEvents(); + + // Add to layer + cropLayer.add(overlay); + cropLayer.add(rect); + cropLayer.add(guides); + cropLayer.add(handles); + + // Add layer to stage + this.konva.stage.add(cropLayer); + + // Apply current scale to handles + this.updateHandleScale(); + + cropLayer.batchDraw(); + }; + + private createCropGuides = () => { + if (!this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + const guides = this.konva.crop.guides; + + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); + + const guideConfig = { + stroke: "rgba(255, 255, 255, 0.5)", + strokeWidth: 1, + strokeScaleEnabled: false, + listening: false, + }; + + // Vertical lines (thirds) + const verticalThird = width / 3; + guides.add( + new Konva.Line({ + points: [x + verticalThird, y, x + verticalThird, y + height], + ...guideConfig, + }), + ); + guides.add( + new Konva.Line({ + points: [ + x + verticalThird * 2, + y, + x + verticalThird * 2, + y + height, + ], + ...guideConfig, + }), + ); + + // Horizontal lines (thirds) + const horizontalThird = height / 3; + guides.add( + new Konva.Line({ + points: [ + x, + y + horizontalThird, + x + width, + y + horizontalThird, + ], + ...guideConfig, + }), + ); + guides.add( + new Konva.Line({ + points: [ + x, + y + horizontalThird * 2, + x + width, + y + horizontalThird * 2, + ], + ...guideConfig, + }), + ); + }; + + private createCropHandles = () => { + if (!this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + const handles = this.konva.crop.handles; + const scale = this.konva.stage.scaleX(); + const handleSize = 8 / scale; + const handleConfig = { + width: handleSize, + height: handleSize, + fill: "white", + stroke: "black", + strokeWidth: 1 / scale, + strokeScaleEnabled: false, + }; + + // Corner handles + const corners = [ + { name: "top-left", x: 0, y: 0 }, + { name: "top-right", x: 1, y: 0 }, + { name: "bottom-right", x: 1, y: 1 }, + { name: "bottom-left", x: 0, y: 1 }, + ]; + + corners.forEach((corner) => { + const handle = new Konva.Rect({ + ...handleConfig, + name: corner.name, + x: rect.x() + corner.x * rect.width() - handleSize / 2, + y: rect.y() + corner.y * rect.height() - handleSize / 2, + draggable: true, + }); + + this.setupHandleEvents(handle); + handles.add(handle); + }); + + // Edge handles + const edges = [ + { name: "top", x: 0.5, y: 0 }, + { name: "right", x: 1, y: 0.5 }, + { name: "bottom", x: 0.5, y: 1 }, + { name: "left", x: 0, y: 0.5 }, + ]; + + edges.forEach((edge) => { + const handle = new Konva.Rect({ + ...handleConfig, + name: edge.name, + x: rect.x() + edge.x * rect.width() - handleSize / 2, + y: rect.y() + edge.y * rect.height() - handleSize / 2, + draggable: true, + }); + + this.setupHandleEvents(handle); + handles.add(handle); + }); + }; + + private setupCropBoxEvents = () => { + if (!this.konva?.crop) { +return; +} + const stage = this.konva.stage; + const rect = this.konva.crop.rect; + const image = this.konva.image; + if (!image) { +return; +} + + // Prevent crop box dragging when panning + rect.on("dragstart", (e) => { + if (this.isSpacePressed || this.isPanning) { + e.target.stopDrag(); + return false; + } + }); + + // Crop box dragging + rect.on("dragmove", () => { + const imgWidth = image.node.width(); + const imgHeight = image.node.height(); + + // Constrain to image bounds + const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); + const y = Math.max( + 0, + Math.min(rect.y(), imgHeight - rect.height()), + ); + + rect.x(x); + rect.y(y); + + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + this.callbacks.onCropChange?.({ + x, + y, + width: rect.width(), + height: rect.height(), + }); + }); + + // Cursor styles + rect.on("mouseenter", () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = "move"; + } + }); + + rect.on("mouseleave", () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = "default"; + } + }); + }; + + private setupHandleEvents = (handle: Konva.Rect) => { + if (!this.konva) { +return; +} + const stage = this.konva.stage; + const handleName = handle.name(); + + // Prevent handle dragging when panning + handle.on("dragstart", (e) => { + if (this.isSpacePressed || this.isPanning) { + e.target.stopDrag(); + return false; + } + }); + + // Set cursor based on handle type + handle.on("mouseenter", () => { + if (!this.isSpacePressed) { + let cursor = "pointer"; + if ( + handleName.includes("top-left") || + handleName.includes("bottom-right") + ) { + cursor = "nwse-resize"; + } else if ( + handleName.includes("top-right") || + handleName.includes("bottom-left") + ) { + cursor = "nesw-resize"; + } else if ( + handleName.includes("top") || + handleName.includes("bottom") + ) { + cursor = "ns-resize"; + } else if ( + handleName.includes("left") || + handleName.includes("right") + ) { + cursor = "ew-resize"; + } + stage.container().style.cursor = cursor; + } + }); + + handle.on("mouseleave", () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = "default"; + } + }); + + // Handle dragging + handle.on("dragmove", () => { + this.resizeCropBox(handle); + }); + }; + + private resizeCropBox = (handle: Konva.Rect) => { + if (!this.konva?.crop || !this.konva?.image) { +return; +} + + const rect = this.konva.crop.rect; + const handleName = handle.name(); + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + let newX = rect.x(); + let newY = rect.y(); + let newWidth = rect.width(); + let newHeight = rect.height(); + + const handleX = handle.x() + handle.width() / 2; + const handleY = handle.y() + handle.height() / 2; + + const minWidth = this.cropConstraints.minWidth ?? 64; + const minHeight = this.cropConstraints.minHeight ?? 64; + + // Update dimensions based on handle type + if (handleName.includes("left")) { + const right = newX + newWidth; + newX = Math.max(0, Math.min(handleX, right - minWidth)); + newWidth = right - newX; + } + if (handleName.includes("right")) { + newWidth = Math.max( + minWidth, + Math.min(handleX - newX, imgWidth - newX), + ); + } + if (handleName.includes("top")) { + const bottom = newY + newHeight; + newY = Math.max(0, Math.min(handleY, bottom - minHeight)); + newHeight = bottom - newY; + } + if (handleName.includes("bottom")) { + newHeight = Math.max( + minHeight, + Math.min(handleY - newY, imgHeight - newY), + ); + } + + // Early boundary check for aspect ratio mode + // If we're at a boundary and have aspect ratio, we need special handling + if (this.cropConstraints.aspectRatio) { + const atLeftEdge = rect.x() <= 0; + const atRightEdge = rect.x() + rect.width() >= imgWidth; + const atTopEdge = rect.y() <= 0; + const atBottomEdge = rect.y() + rect.height() >= imgHeight; + + // For edge handles at boundaries, prevent invalid operations + if (handleName === "left" && atLeftEdge && handleX >= rect.x()) { + // Can't move left edge further left, only right (shrinking) + return; + } + if ( + handleName === "right" && + atRightEdge && + handleX <= rect.x() + rect.width() + ) { + // Can't move right edge further right, only left (shrinking) + return; + } + if (handleName === "top" && atTopEdge && handleY >= rect.y()) { + // Can't move top edge further up, only down (shrinking) + return; + } + if ( + handleName === "bottom" && + atBottomEdge && + handleY <= rect.y() + rect.height() + ) { + // Can't move bottom edge further down, only up (shrinking) + return; + } + } + + // Apply constraints + if (this.cropConstraints.maxWidth) { + newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); + } + if (this.cropConstraints.maxHeight) { + newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); + } + + // Apply aspect ratio if set + if (this.cropConstraints.aspectRatio) { + const ratio = this.cropConstraints.aspectRatio; + const oldX = rect.x(); + const oldY = rect.y(); + const oldWidth = rect.width(); + const oldHeight = rect.height(); + + // Define anchor points (opposite of the handle being dragged) + let anchorX = oldX; + let anchorY = oldY; + + if (handleName.includes("right")) { + anchorX = oldX; // Left edge is anchor + } else if (handleName.includes("left")) { + anchorX = oldX + oldWidth; // Right edge is anchor + } else { + anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom + } + + if (handleName.includes("bottom")) { + anchorY = oldY; // Top edge is anchor + } else if (handleName.includes("top")) { + anchorY = oldY + oldHeight; // Bottom edge is anchor + } else { + anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right + } + + // Calculate new dimensions maintaining aspect ratio + if (handleName === "left" || handleName === "right") { + // For left/right handles, adjust height to maintain ratio + newHeight = newWidth / ratio; + + // Use center Y as anchor point + newY = anchorY - newHeight / 2; + } else if (handleName === "top" || handleName === "bottom") { + // For top/bottom handles, adjust width to maintain ratio + newWidth = newHeight * ratio; + + // Use center X as anchor point + newX = anchorX - newWidth / 2; + } else { + // Corner handles - the anchor is the opposite corner + // Use mouse position relative to anchor to determine constraint + const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); + const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); + + // Calculate maximum possible dimensions based on anchor position and image bounds + let maxPossibleWidth; let maxPossibleHeight; + + if (handleName.includes("left")) { + // Anchor is on the right, max width is anchor X position + maxPossibleWidth = anchorX; + } else { + // Anchor is on the left, max width is image width minus anchor X + maxPossibleWidth = imgWidth - anchorX; + } + + if (handleName.includes("top")) { + // Anchor is on the bottom, max height is anchor Y position + maxPossibleHeight = anchorY; + } else { + // Anchor is on the top, max height is image height minus anchor Y + maxPossibleHeight = imgHeight - anchorY; + } + + // Constrain mouse distances to stay within image bounds + const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth); + const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight); + + // Determine which dimension should be the primary constraint + // based on which direction the mouse moved further from the anchor (after constraining) + const shouldConstrainByWidth = constrainedMouseDistanceX / ratio > constrainedMouseDistanceY; + + if (shouldConstrainByWidth) { + // Width is the primary dimension, calculate height from it + newWidth = constrainedMouseDistanceX; + newHeight = newWidth / ratio; + + // If calculated height exceeds bounds, switch to height constraint + if (newHeight > maxPossibleHeight) { + newHeight = maxPossibleHeight; + newWidth = newHeight * ratio; + } + } else { + // Height is the primary dimension, calculate width from it + newHeight = constrainedMouseDistanceY; + newWidth = newHeight * ratio; + + // If calculated width exceeds bounds, switch to width constraint + if (newWidth > maxPossibleWidth) { + newWidth = maxPossibleWidth; + newHeight = newWidth / ratio; + } + } + + // For corner handles, keep the opposite corner fixed + if (handleName.includes("left")) { + newX = anchorX - newWidth; + } else { + newX = anchorX; + } + + if (handleName.includes("top")) { + newY = anchorY - newHeight; + } else { + newY = anchorY; + } + } + + // Boundary checks and adjustments + // Check if we exceed image bounds and need to adjust + if (newX < 0) { + const adjustment = -newX; + newX = 0; + // If we're anchored on the right, we need to adjust width + if (handleName.includes("left")) { + newWidth -= adjustment; + newHeight = newWidth / ratio; + if (handleName !== "left") { + // For corner handles, also adjust Y to maintain anchor + newY = anchorY - newHeight; + } + } + } + + if (newY < 0) { + const adjustment = -newY; + newY = 0; + // If we're anchored on the bottom, we need to adjust height + if (handleName.includes("top")) { + newHeight -= adjustment; + newWidth = newHeight * ratio; + if (handleName !== "top") { + // For corner handles, also adjust X to maintain anchor + newX = anchorX - newWidth; + } + } + } + + if (newX + newWidth > imgWidth) { + const adjustment = newX + newWidth - imgWidth; + // If we're anchored on the left, we need to adjust width + if (handleName.includes("right")) { + newWidth -= adjustment; + newHeight = newWidth / ratio; + if (handleName !== "right") { + // For corner handles, maintain anchor + newY = anchorY - newHeight; + } + } else if (handleName === "top" || handleName === "bottom") { + // For vertical handles, recenter + newX = imgWidth - newWidth; + if (newX < 0) { + newWidth = imgWidth; + newHeight = newWidth / ratio; + newX = 0; + } + } + } + + if (newY + newHeight > imgHeight) { + const adjustment = newY + newHeight - imgHeight; + // If we're anchored on the top, we need to adjust height + if (handleName.includes("bottom")) { + newHeight -= adjustment; + newWidth = newHeight * ratio; + if (handleName !== "bottom") { + // For corner handles, maintain anchor + newX = anchorX - newWidth; + } + } else if (handleName === "left" || handleName === "right") { + // For horizontal handles, recenter + newY = imgHeight - newHeight; + if (newY < 0) { + newHeight = imgHeight; + newWidth = newHeight * ratio; + newY = 0; + } + } + } + + // Final check for minimum sizes + if (newWidth < minWidth) { + newWidth = minWidth; + newHeight = newWidth / ratio; + // Reposition based on anchor + if (handleName.includes("left")) { + newX = anchorX - newWidth; + } + if (handleName.includes("top")) { + newY = anchorY - newHeight; + } + } + if (newHeight < minHeight) { + newHeight = minHeight; + newWidth = newHeight * ratio; + // Reposition based on anchor + if (handleName.includes("left")) { + newX = anchorX - newWidth; + } + if (handleName.includes("top")) { + newY = anchorY - newHeight; + } + } + } + + // Update crop rect + rect.x(newX); + rect.y(newY); + rect.width(newWidth); + rect.height(newHeight); + + // Update overlay, handles, and guides + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + // Reset handle position to follow crop box + this.positionHandle(handle); + + this.callbacks.onCropChange?.({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + }; + + private positionHandle = (handle: Konva.Rect) => { + if (!this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + const handleName = handle.name(); + const handleSize = handle.width(); + + let x = rect.x(); + let y = rect.y(); + + if (handleName.includes("right")) { +x += rect.width(); +} else if (handleName.includes("left")) { +x += 0; +} else { +x += rect.width() / 2; +} + + if (handleName.includes("bottom")) { +y += rect.height(); +} else if (handleName.includes("top")) { +y += 0; +} else { +y += rect.height() / 2; +} + + handle.x(x - handleSize / 2); + handle.y(y - handleSize / 2); + }; + + private updateHandlePositions = () => { + if (!this.konva?.crop) { +return; +} + + this.konva.crop.handles.children.forEach((handle) => { + if (handle instanceof Konva.Rect) { + this.positionHandle(handle); + } + }); + }; + + private updateCropGuides = () => { + if (!this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); + + const lines = this.konva.crop.guides.children; + if (lines.length < 4) { +return; +} + + // Update vertical lines + const verticalThird = width / 3; + const line0 = lines[0]; + const line1 = lines[1]; + if (line0 instanceof Konva.Line) { + line0.points([x + verticalThird, y, x + verticalThird, y + height]); + } + if (line1 instanceof Konva.Line) { + line1.points([ + x + verticalThird * 2, + y, + x + verticalThird * 2, + y + height, + ]); + } + + // Update horizontal lines + const horizontalThird = height / 3; + const line2 = lines[2]; + const line3 = lines[3]; + if (line2 instanceof Konva.Line) { + line2.points([ + x, + y + horizontalThird, + x + width, + y + horizontalThird, + ]); + } + if (line3 instanceof Konva.Line) { + line3.points([ + x, + y + horizontalThird * 2, + x + width, + y + horizontalThird * 2, + ]); + } + }; + + private updateCropOverlay = () => { + if (!this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); + + const nodes = this.konva.crop.overlay.children; + + // Update clear rectangle position (the cutout) + if (nodes.length > 1) { + const clearRect = nodes[1]; + if (clearRect instanceof Konva.Rect) { + clearRect.x(x); + clearRect.y(y); + clearRect.width(width); + clearRect.height(height); + } + } + + this.konva.crop.layer.batchDraw(); + }; + + private updateHandleScale = () => { + if (!this.konva?.crop) { +return; +} + + const scale = this.konva.stage.scaleX(); + const handleSize = 8 / scale; + const strokeWidth = 1 / scale; + + // Update each handle's size and stroke to maintain constant screen size + this.konva.crop.handles.children.forEach((handle) => { + if (handle instanceof Konva.Rect) { + const currentX = handle.x(); + const currentY = handle.y(); + const oldSize = handle.width(); + + // Calculate center position + const centerX = currentX + oldSize / 2; + const centerY = currentY + oldSize / 2; + + // Update size and stroke + handle.width(handleSize); + handle.height(handleSize); + handle.strokeWidth(strokeWidth); + + // Reposition to maintain center + handle.x(centerX - handleSize / 2); + handle.y(centerY - handleSize / 2); + } + }); + + this.konva.crop.layer.batchDraw(); + }; + + cancelCrop = () => { + if (!this.isInCropMode || !this.konva?.crop) { +return; +} + + this.isInCropMode = false; + this.konva.crop.layer.destroy(); + this.konva.crop = undefined; + }; + + applyCrop = () => { + if (!this.isInCropMode || !this.konva?.crop) { +return; +} + + const rect = this.konva.crop.rect; + + // If there's already an applied crop, combine them + if (this.appliedCrop) { + // The new crop is relative to the already cropped image + this.appliedCrop = { + x: this.appliedCrop.x + rect.x(), + y: this.appliedCrop.y + rect.y(), + width: rect.width(), + height: rect.height(), + }; + } else { + this.appliedCrop = { + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + }; + } + + this.cancelCrop(); + + // Redisplay image with crop applied + this.displayImage(); + }; + + resetCrop = () => { + this.appliedCrop = undefined; + + // Redisplay image without crop + this.displayImage(); + }; + + hasCrop = (): boolean => { + return !!this.appliedCrop; + }; + + // Export + exportImage = async ( + format: "canvas" | "blob" | "dataURL" = "blob", + ): Promise => { + if (!this.originalImage) { + throw new Error("No image loaded"); + } + + // Create temporary canvas + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + try { + if (this.appliedCrop) { + canvas.width = this.appliedCrop.width; + canvas.height = this.appliedCrop.height; + + ctx.drawImage( + this.originalImage, + this.appliedCrop.x, + this.appliedCrop.y, + this.appliedCrop.width, + this.appliedCrop.height, + 0, + 0, + this.appliedCrop.width, + this.appliedCrop.height, + ); + } else { + canvas.width = this.originalImage.width; + canvas.height = this.originalImage.height; + ctx.drawImage(this.originalImage, 0, 0); + } + + if (format === "canvas") { + return canvas; + } else if (format === "dataURL") { + try { + return canvas.toDataURL("image/png"); + } catch (error) { + throw new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source."); + } + } else { + return new Promise((resolve, reject) => { + try { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to create blob")); + } + }, "image/png"); + } catch (error) { + reject(new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source.")); + } + }); + } + } catch (error) { + if (error instanceof Error && error.message.includes("tainted")) { + throw new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source."); + } + throw error; + } + }; + + // View Control + setZoom = (scale: number, point?: { x: number; y: number }) => { + if (!this.konva) { +return; +} + + scale = Math.max(this.zoomMin, Math.min(this.zoomMax, scale)); + + // If no point provided, use center of viewport + if (!point && this.konva.image) { + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + point = { + x: containerWidth / 2, + y: containerHeight / 2, + }; + } + + if (point) { + const oldScale = this.konva.stage.scaleX(); + const mousePointTo = { + x: (point.x - this.konva.stage.x()) / oldScale, + y: (point.y - this.konva.stage.y()) / oldScale, + }; + + this.konva.stage.scale({ x: scale, y: scale }); + + const newPos = { + x: point.x - mousePointTo.x * scale, + y: point.y - mousePointTo.y * scale, + }; + this.konva.stage.position(newPos); + } else { + this.konva.stage.scale({ x: scale, y: scale }); + } + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(scale); + }; + + getZoom = (): number => { + return this.konva?.stage.scaleX() || 1; + }; + + resetView = () => { + if (!this.konva?.image) { +return; +} + + this.konva.stage.scale({ x: 1, y: 1 }); + + // Center the image + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + const imageWidth = this.konva.image.node.width(); + const imageHeight = this.konva.image.node.height(); + + this.konva.stage.position({ + x: (containerWidth - imageWidth) / 2, + y: (containerHeight - imageHeight) / 2, + }); + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(1); + }; + + fitToContainer = () => { + if (!this.konva?.image) { +return; +} + + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + const imageWidth = this.konva.image.node.width(); + const imageHeight = this.konva.image.node.height(); + + const scale = + Math.min( + containerWidth / imageWidth, + containerHeight / imageHeight, + ) * 0.9; // 90% to add some padding + + this.konva.stage.scale({ x: scale, y: scale }); + + // Center the image + const scaledWidth = imageWidth * scale; + const scaledHeight = imageHeight * scale; + + this.konva.stage.position({ + x: (containerWidth - scaledWidth) / 2, + y: (containerHeight - scaledHeight) / 2, + }); + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(scale); + }; + + // Configuration + setCallbacks = (callbacks: EditorCallbacks) => { + this.callbacks = { ...this.callbacks, ...callbacks }; + }; + + setCropAspectRatio = (ratio: number | undefined) => { + // Update the constraint + this.cropConstraints.aspectRatio = ratio; + + // If we're currently cropping, adjust the crop box to match the new ratio + if (this.isInCropMode && this.konva?.crop && this.konva?.image) { + const rect = this.konva.crop.rect; + const currentWidth = rect.width(); + const currentHeight = rect.height(); + const currentArea = currentWidth * currentHeight; + + if (ratio === undefined) { + // Just removed the aspect ratio constraint, no need to adjust + return; + } + + // Calculate new dimensions maintaining the same area + // area = width * height + // ratio = width / height + // So: area = width * (width / ratio) + // Therefore: width = sqrt(area * ratio) + let newWidth = Math.sqrt(currentArea * ratio); + let newHeight = newWidth / ratio; + + // Get image bounds + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + // Check if the new dimensions would exceed image bounds + if (newWidth > imgWidth || newHeight > imgHeight) { + // Scale down to fit within image bounds while maintaining ratio + const scaleX = imgWidth / newWidth; + const scaleY = imgHeight / newHeight; + const scale = Math.min(scaleX, scaleY); + newWidth *= scale; + newHeight *= scale; + } + + // Apply minimum size constraints + const minWidth = this.cropConstraints.minWidth ?? 64; + const minHeight = this.cropConstraints.minHeight ?? 64; + + if (newWidth < minWidth) { + newWidth = minWidth; + newHeight = newWidth / ratio; + } + if (newHeight < minHeight) { + newHeight = minHeight; + newWidth = newHeight * ratio; + } + + // Center the new crop box at the same position as the old one + const currentCenterX = rect.x() + currentWidth / 2; + const currentCenterY = rect.y() + currentHeight / 2; + + let newX = currentCenterX - newWidth / 2; + let newY = currentCenterY - newHeight / 2; + + // Ensure the crop box stays within image bounds + newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); + newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); + + // Update the crop box + rect.x(newX); + rect.y(newY); + rect.width(newWidth); + rect.height(newHeight); + + // Update all visual elements + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + // Notify callback + this.callbacks.onCropChange?.({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + + // Force a redraw + this.konva.crop.layer.batchDraw(); + } + }; + + getCropAspectRatio = (): number | undefined => { + return this.cropConstraints.aspectRatio; + }; + + // Utility + resize = (width: number, height: number) => { + if (!this.konva) { +return; +} + + this.konva.stage.width(width); + this.konva.stage.height(height); + }; + + destroy = () => { + // Remove window event listeners + if (this.keydownHandler) { + window.removeEventListener("keydown", this.keydownHandler); + this.keydownHandler = undefined; + } + if (this.keyupHandler) { + window.removeEventListener("keyup", this.keyupHandler); + this.keyupHandler = undefined; + } + + // Remove stage container event listeners + if (this.konva) { + const container = this.konva.stage.container(); + if (this.contextMenuHandler) { + container.removeEventListener( + "contextmenu", + this.contextMenuHandler, + ); + this.contextMenuHandler = undefined; + } + if (this.wheelHandler) { + container.removeEventListener("wheel", this.wheelHandler); + this.wheelHandler = undefined; + } + } + + // Clean up blob URL if it exists + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; + } + + // Cancel any ongoing crop operation + if (this.isInCropMode) { + this.cancelCrop(); + } + + // Remove all Konva event listeners by destroying the stage + // This automatically removes all Konva event handlers + this.konva?.stage.destroy(); + + // Clear all references + this.konva = undefined; + this.originalImage = undefined; + this.appliedCrop = undefined; + this.callbacks = {}; + }; +} \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts new file mode 100644 index 00000000000..6338f34bc1f --- /dev/null +++ b/invokeai/frontend/web/src/features/editImageModal/store/index.ts @@ -0,0 +1,4 @@ +import { atom } from "nanostores"; + +export const $isOpen = atom(false); +export const $imageName = atom(null); \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 4286d3129a6..c329c4f0427 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -1,4 +1,4 @@ -import { Flex, FormLabel, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; +import { Box, Flex, FormLabel, Icon, IconButton, Text, Tooltip } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { ASPECT_RATIO_MAP } from 'features/controlLayers/store/types'; @@ -6,7 +6,8 @@ import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; -import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { DndImageIcon, imageButtonSx } from 'features/dnd/DndImageIcon'; +import { $imageName, $isOpen } from 'features/editImageModal/store'; import { selectStartingFrameImage, selectVideoAspectRatio, @@ -15,7 +16,7 @@ import { } from 'features/parameters/store/videoSlice'; import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; -import { PiArrowCounterClockwiseBold, PiWarningBold } from 'react-icons/pi'; +import { PiArrowCounterClockwiseBold, PiCropBold, PiWarningBold } from 'react-icons/pi'; import { useImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; @@ -39,19 +40,35 @@ export const StartingFrameImage = () => { [dispatch] ); + const onOpenEditImageModal = useCallback(() => { + $isOpen.set(true); + $imageName.set(imageDTO?.image_name ?? null); + }, [imageDTO]); + + const fitsCurrentAspectRatio = useMemo(() => { if (!imageDTO) { return true; } - console.log('imageDTO.width / imageDTO.height', imageDTO.width / imageDTO.height); - console.log('ASPECT_RATIO_MAP[videoAspectRatio]?.ratio', ASPECT_RATIO_MAP[videoAspectRatio]?.ratio); - console.log('fitsCurrentAspectRatio', imageDTO.width / imageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio); + return imageDTO.width / imageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio; }, [imageDTO, videoAspectRatio]); return ( - {t('parameters.startingFrameImage')} + {t('parameters.startingFrameImage')} + + + + + + {!imageDTO && ( { /> - {!fitsCurrentAspectRatio && - - - - } + + } + tooltip={t('common.crop')} + /> + + + Date: Fri, 12 Sep 2025 12:55:54 +1000 Subject: [PATCH 03/34] chore(ui): lint --- .../src/features/editImageModal/lib/editor.ts | 2853 ++++++++--------- 1 file changed, 1386 insertions(+), 1467 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 3b15825aa9b..b32f1cfd7dd 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1,1534 +1,1453 @@ -import Konva from "konva"; - -interface CropConstraints { - minWidth?: number; - minHeight?: number; - maxWidth?: number; - maxHeight?: number; - aspectRatio?: number; -} - -interface EditorCallbacks { - onCropChange?: (crop: { - x: number; - y: number; - width: number; - height: number; - }) => void; - onZoomChange?: (zoom: number) => void; - onImageLoad?: () => void; -} - -interface CropData { - x: number; - y: number; - width: number; - height: number; -} - -interface KonvaObjects { - stage: Konva.Stage; - image?: { - layer: Konva.Layer; - node: Konva.Image; - }; - crop?: { - layer: Konva.Layer; - rect: Konva.Rect; - overlay: Konva.Group; - handles: Konva.Group; - guides: Konva.Group; - }; -} +import Konva from 'konva'; + +type CropConstraints = { + minWidth?: number; + minHeight?: number; + maxWidth?: number; + maxHeight?: number; + aspectRatio?: number; +}; + +type EditorCallbacks = { + onCropChange?: (crop: { x: number; y: number; width: number; height: number }) => void; + onZoomChange?: (zoom: number) => void; + onImageLoad?: () => void; +}; + +type CropData = { + x: number; + y: number; + width: number; + height: number; +}; + +type KonvaObjects = { + stage: Konva.Stage; + image?: { + layer: Konva.Layer; + node: Konva.Image; + }; + crop?: { + layer: Konva.Layer; + rect: Konva.Rect; + overlay: Konva.Group; + handles: Konva.Group; + guides: Konva.Group; + }; +}; export class Editor { - private konva?: KonvaObjects; - private originalImage?: HTMLImageElement; - private isInCropMode = false; - private appliedCrop?: CropData; - - // Configuration - private zoomMin = 0.1; - private zoomMax = 10; - private cropConstraints: CropConstraints = { - minWidth: 64, - minHeight: 64, - }; - private callbacks: EditorCallbacks = {}; - - // State - private isPanning = false; - private lastPointerPosition?: { x: number; y: number }; - private isSpacePressed = false; - private keydownHandler?: (e: KeyboardEvent) => void; - private keyupHandler?: (e: KeyboardEvent) => void; - private contextMenuHandler?: (e: Event) => void; - private currentImageBlobUrl?: string; - private wheelHandler?: (e: WheelEvent) => void; - - init = (container: HTMLDivElement) => { - // Create stage - this.konva = { - stage: new Konva.Stage({ - container: container, - width: container.clientWidth, - height: container.clientHeight, - }), - }; - - // Setup mouse event handlers - this.setupStageEvents(); + private konva?: KonvaObjects; + private originalImage?: HTMLImageElement; + private isCropping = false; + private appliedCrop?: CropData; + + // Configuration + private zoomMin = 0.1; + private zoomMax = 10; + private cropConstraints: CropConstraints = { + minWidth: 64, + minHeight: 64, + }; + private callbacks: EditorCallbacks = {}; + + // State + private isPanning = false; + private lastPointerPosition?: { x: number; y: number }; + private isSpacePressed = false; + private keydownHandler?: (e: KeyboardEvent) => void; + private keyupHandler?: (e: KeyboardEvent) => void; + private contextMenuHandler?: (e: Event) => void; + private currentImageBlobUrl?: string; + private wheelHandler?: (e: WheelEvent) => void; + + init = (container: HTMLDivElement) => { + // Create stage + this.konva = { + stage: new Konva.Stage({ + container: container, + width: container.clientWidth, + height: container.clientHeight, + }), }; - private setupStageEvents = () => { - if (!this.konva) { -return; -} - const stage = this.konva.stage; + // Setup mouse event handlers + this.setupStageEvents(); + }; - // Zoom with mouse wheel - this.wheelHandler = (e: WheelEvent) => { - e.preventDefault(); + private setupStageEvents = () => { + if (!this.konva) { + return; + } + const stage = this.konva.stage; - const oldScale = stage.scaleX(); - const pointer = stage.getPointerPosition(); + // Zoom with mouse wheel + this.wheelHandler = (e: WheelEvent) => { + e.preventDefault(); - if (!pointer) { -return; -} + const oldScale = stage.scaleX(); + const pointer = stage.getPointerPosition(); - const mousePointTo = { - x: (pointer.x - stage.x()) / oldScale, - y: (pointer.y - stage.y()) / oldScale, - }; - - const direction = e.deltaY > 0 ? -1 : 1; - const scaleBy = 1.1; - let newScale = - direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; - - // Apply zoom limits - newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); - - stage.scale({ x: newScale, y: newScale }); - - const newPos = { - x: pointer.x - mousePointTo.x * newScale, - y: pointer.y - mousePointTo.y * newScale, - }; - stage.position(newPos); - - // Update handle scaling to maintain constant screen size - this.updateHandleScale(); - - this.callbacks.onZoomChange?.(newScale); - }; - - stage - .container() - .addEventListener("wheel", this.wheelHandler, { passive: false }); - - // Track Space key press - this.keydownHandler = (e: KeyboardEvent) => { - if (e.code === "Space" && !this.isSpacePressed) { - e.preventDefault(); - this.isSpacePressed = true; - if (stage) { - stage.container().style.cursor = "grab"; - } - } - }; - - this.keyupHandler = (e: KeyboardEvent) => { - if (e.code === "Space") { - e.preventDefault(); - this.isSpacePressed = false; - this.isPanning = false; - if (stage) { - stage.container().style.cursor = "default"; - } - } - }; - - window.addEventListener("keydown", this.keydownHandler); - window.addEventListener("keyup", this.keyupHandler); - - // Pan with Space + drag or middle mouse button - stage.on("mousedown", (e) => { - if (this.isSpacePressed || e.evt.button === 1) { - e.evt.preventDefault(); - e.evt.stopPropagation(); - this.isPanning = true; - this.lastPointerPosition = - stage.getPointerPosition() || undefined; - stage.container().style.cursor = "grabbing"; - - // Stop any active drags on crop elements - if (this.konva?.crop) { - if (this.konva.crop.rect.isDragging()) { - this.konva.crop.rect.stopDrag(); - } - this.konva.crop.handles.children.forEach((handle) => { - if (handle.isDragging()) { - handle.stopDrag(); - } - }); - } - } - }); + if (!pointer) { + return; + } - stage.on("mousemove", () => { - if (!this.isPanning || !this.lastPointerPosition) { -return; -} - - const pointer = stage.getPointerPosition(); - if (!pointer) { -return; -} - - const dx = pointer.x - this.lastPointerPosition.x; - const dy = pointer.y - this.lastPointerPosition.y; + const mousePointTo = { + x: (pointer.x - stage.x()) / oldScale, + y: (pointer.y - stage.y()) / oldScale, + }; - stage.x(stage.x() + dx); - stage.y(stage.y() + dy); + const direction = e.deltaY > 0 ? -1 : 1; + const scaleBy = 1.1; + let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; - this.lastPointerPosition = pointer; - }); + // Apply zoom limits + newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); - stage.on("mouseup", () => { - if (this.isPanning) { - this.isPanning = false; - stage.container().style.cursor = this.isSpacePressed - ? "grab" - : "default"; - } - }); - - // Prevent context menu on right click - this.contextMenuHandler = (e: Event) => e.preventDefault(); - stage - .container() - .addEventListener("contextmenu", this.contextMenuHandler); - }; + stage.scale({ x: newScale, y: newScale }); - // Image Management - loadImage = async (src: string | File | Blob): Promise => { - return new Promise((resolve, reject) => { - // Clean up previous blob URL if it exists - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; - } + const newPos = { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, + }; + stage.position(newPos); - const img = new Image(); - - // Set crossOrigin to avoid CORS issues when exporting - if (typeof src === "string") { - img.crossOrigin = "anonymous"; - } + // Update handle scaling to maintain constant screen size + this.updateHandleScale(); - img.onload = () => { - this.originalImage = img; - this.displayImage(); - this.callbacks.onImageLoad?.(); - resolve(); - }; - - img.onerror = () => { - // Clean up blob URL on error - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; - } - reject(new Error("Failed to load image")); - }; - - if (typeof src === "string") { - img.src = src; - } else if (src instanceof File || src instanceof Blob) { - const url = URL.createObjectURL(src); - this.currentImageBlobUrl = url; - img.src = url; - } - }); + this.callbacks.onZoomChange?.(newScale); }; - private displayImage = () => { - if (!this.originalImage || !this.konva) { -return; -} + stage.container().addEventListener('wheel', this.wheelHandler, { passive: false }); - // Clear existing image - if (this.konva.image) { - this.konva.image.node.destroy(); - this.konva.image.layer.destroy(); - this.konva.image = undefined; + // Track Space key press + this.keydownHandler = (e: KeyboardEvent) => { + if (e.code === 'Space' && !this.isSpacePressed) { + e.preventDefault(); + this.isSpacePressed = true; + if (stage) { + stage.container().style.cursor = 'grab'; } - - // Create image layer and node - const imageLayer = new Konva.Layer(); - let imageNode: Konva.Image; - - if (this.appliedCrop) { - imageNode = new Konva.Image({ - image: this.originalImage, - x: 0, - y: 0, - width: this.appliedCrop.width, - height: this.appliedCrop.height, - crop: { - x: this.appliedCrop.x, - y: this.appliedCrop.y, - width: this.appliedCrop.width, - height: this.appliedCrop.height, - }, - }); - } else { - imageNode = new Konva.Image({ - image: this.originalImage, - x: 0, - y: 0, - width: this.originalImage.width, - height: this.originalImage.height, - }); - } - - imageLayer.add(imageNode); - this.konva.stage.add(imageLayer); - - // Store references - this.konva.image = { - layer: imageLayer, - node: imageNode, - }; - - imageLayer.batchDraw(); - - // Center image at 100% zoom - this.resetView(); - }; - - // Crop Mode - startCrop = () => { - if (!this.konva?.image || this.isInCropMode) { -return; -} - - this.isInCropMode = true; - - // Calculate initial crop dimensions - let cropX: number; let cropY: number; let cropWidth: number; let cropHeight: number; - - if (this.appliedCrop) { - // When cropped, start with full visible area - cropX = 0; - cropY = 0; - cropWidth = this.appliedCrop.width; - cropHeight = this.appliedCrop.height; - } else { - // Create default crop box (centered, 80% of image) - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - cropWidth = imgWidth * 0.8; - cropHeight = imgHeight * 0.8; - cropX = (imgWidth - cropWidth) / 2; - cropY = (imgHeight - cropHeight) / 2; - } - - this.createCropBox(cropX, cropY, cropWidth, cropHeight); + } }; - private createCropBox = ( - x: number, - y: number, - width: number, - height: number, - ) => { - if (!this.konva?.image) { -return; -} - - // Clear existing crop if any - if (this.konva.crop) { - this.konva.crop.layer.destroy(); - this.konva.crop = undefined; + this.keyupHandler = (e: KeyboardEvent) => { + if (e.code === 'Space') { + e.preventDefault(); + this.isSpacePressed = false; + this.isPanning = false; + if (stage) { + stage.container().style.cursor = 'default'; } - - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - // Create crop layer - const cropLayer = new Konva.Layer(); - - // Create overlay group with composite operation - const overlay = new Konva.Group(); - - // Create full overlay - const fullOverlay = new Konva.Rect({ - x: 0, - y: 0, - width: imgWidth, - height: imgHeight, - fill: "black", - opacity: 0.5, - }); - - // Create clear rectangle for crop area using composite operation - const clearRect = new Konva.Rect({ - x: x, - y: y, - width: width, - height: height, - fill: "black", - globalCompositeOperation: "destination-out", - }); - - overlay.add(fullOverlay); - overlay.add(clearRect); - - // Create crop rectangle - const rect = new Konva.Rect({ - x: x, - y: y, - width: width, - height: height, - stroke: "white", - strokeWidth: 1, - strokeScaleEnabled: false, - draggable: true, - }); - - // Create handles group - const handles = new Konva.Group(); - - // Create guides group - const guides = new Konva.Group(); - - // Store all crop objects together - this.konva.crop = { - layer: cropLayer, - rect: rect, - overlay: overlay, - handles: handles, - guides: guides, - }; - - // Create handles and guides - this.createCropHandles(); - this.createCropGuides(); - - // Setup crop box events - this.setupCropBoxEvents(); - - // Add to layer - cropLayer.add(overlay); - cropLayer.add(rect); - cropLayer.add(guides); - cropLayer.add(handles); - - // Add layer to stage - this.konva.stage.add(cropLayer); - - // Apply current scale to handles - this.updateHandleScale(); - - cropLayer.batchDraw(); - }; - - private createCropGuides = () => { - if (!this.konva?.crop) { -return; -} - - const rect = this.konva.crop.rect; - const guides = this.konva.crop.guides; - - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const guideConfig = { - stroke: "rgba(255, 255, 255, 0.5)", - strokeWidth: 1, - strokeScaleEnabled: false, - listening: false, - }; - - // Vertical lines (thirds) - const verticalThird = width / 3; - guides.add( - new Konva.Line({ - points: [x + verticalThird, y, x + verticalThird, y + height], - ...guideConfig, - }), - ); - guides.add( - new Konva.Line({ - points: [ - x + verticalThird * 2, - y, - x + verticalThird * 2, - y + height, - ], - ...guideConfig, - }), - ); - - // Horizontal lines (thirds) - const horizontalThird = height / 3; - guides.add( - new Konva.Line({ - points: [ - x, - y + horizontalThird, - x + width, - y + horizontalThird, - ], - ...guideConfig, - }), - ); - guides.add( - new Konva.Line({ - points: [ - x, - y + horizontalThird * 2, - x + width, - y + horizontalThird * 2, - ], - ...guideConfig, - }), - ); - }; - - private createCropHandles = () => { - if (!this.konva?.crop) { -return; -} - - const rect = this.konva.crop.rect; - const handles = this.konva.crop.handles; - const scale = this.konva.stage.scaleX(); - const handleSize = 8 / scale; - const handleConfig = { - width: handleSize, - height: handleSize, - fill: "white", - stroke: "black", - strokeWidth: 1 / scale, - strokeScaleEnabled: false, - }; - - // Corner handles - const corners = [ - { name: "top-left", x: 0, y: 0 }, - { name: "top-right", x: 1, y: 0 }, - { name: "bottom-right", x: 1, y: 1 }, - { name: "bottom-left", x: 0, y: 1 }, - ]; - - corners.forEach((corner) => { - const handle = new Konva.Rect({ - ...handleConfig, - name: corner.name, - x: rect.x() + corner.x * rect.width() - handleSize / 2, - y: rect.y() + corner.y * rect.height() - handleSize / 2, - draggable: true, - }); - - this.setupHandleEvents(handle); - handles.add(handle); - }); - - // Edge handles - const edges = [ - { name: "top", x: 0.5, y: 0 }, - { name: "right", x: 1, y: 0.5 }, - { name: "bottom", x: 0.5, y: 1 }, - { name: "left", x: 0, y: 0.5 }, - ]; - - edges.forEach((edge) => { - const handle = new Konva.Rect({ - ...handleConfig, - name: edge.name, - x: rect.x() + edge.x * rect.width() - handleSize / 2, - y: rect.y() + edge.y * rect.height() - handleSize / 2, - draggable: true, - }); - - this.setupHandleEvents(handle); - handles.add(handle); - }); - }; - - private setupCropBoxEvents = () => { - if (!this.konva?.crop) { -return; -} - const stage = this.konva.stage; - const rect = this.konva.crop.rect; - const image = this.konva.image; - if (!image) { -return; -} - - // Prevent crop box dragging when panning - rect.on("dragstart", (e) => { - if (this.isSpacePressed || this.isPanning) { - e.target.stopDrag(); - return false; - } - }); - - // Crop box dragging - rect.on("dragmove", () => { - const imgWidth = image.node.width(); - const imgHeight = image.node.height(); - - // Constrain to image bounds - const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); - const y = Math.max( - 0, - Math.min(rect.y(), imgHeight - rect.height()), - ); - - rect.x(x); - rect.y(y); - - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - this.callbacks.onCropChange?.({ - x, - y, - width: rect.width(), - height: rect.height(), - }); - }); - - // Cursor styles - rect.on("mouseenter", () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = "move"; - } - }); - - rect.on("mouseleave", () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = "default"; - } - }); + } }; - private setupHandleEvents = (handle: Konva.Rect) => { - if (!this.konva) { -return; -} - const stage = this.konva.stage; - const handleName = handle.name(); - - // Prevent handle dragging when panning - handle.on("dragstart", (e) => { - if (this.isSpacePressed || this.isPanning) { - e.target.stopDrag(); - return false; - } - }); - - // Set cursor based on handle type - handle.on("mouseenter", () => { - if (!this.isSpacePressed) { - let cursor = "pointer"; - if ( - handleName.includes("top-left") || - handleName.includes("bottom-right") - ) { - cursor = "nwse-resize"; - } else if ( - handleName.includes("top-right") || - handleName.includes("bottom-left") - ) { - cursor = "nesw-resize"; - } else if ( - handleName.includes("top") || - handleName.includes("bottom") - ) { - cursor = "ns-resize"; - } else if ( - handleName.includes("left") || - handleName.includes("right") - ) { - cursor = "ew-resize"; - } - stage.container().style.cursor = cursor; - } - }); - - handle.on("mouseleave", () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = "default"; - } - }); - - // Handle dragging - handle.on("dragmove", () => { - this.resizeCropBox(handle); - }); - }; - - private resizeCropBox = (handle: Konva.Rect) => { - if (!this.konva?.crop || !this.konva?.image) { -return; -} - - const rect = this.konva.crop.rect; - const handleName = handle.name(); - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - let newX = rect.x(); - let newY = rect.y(); - let newWidth = rect.width(); - let newHeight = rect.height(); - - const handleX = handle.x() + handle.width() / 2; - const handleY = handle.y() + handle.height() / 2; - - const minWidth = this.cropConstraints.minWidth ?? 64; - const minHeight = this.cropConstraints.minHeight ?? 64; - - // Update dimensions based on handle type - if (handleName.includes("left")) { - const right = newX + newWidth; - newX = Math.max(0, Math.min(handleX, right - minWidth)); - newWidth = right - newX; - } - if (handleName.includes("right")) { - newWidth = Math.max( - minWidth, - Math.min(handleX - newX, imgWidth - newX), - ); - } - if (handleName.includes("top")) { - const bottom = newY + newHeight; - newY = Math.max(0, Math.min(handleY, bottom - minHeight)); - newHeight = bottom - newY; - } - if (handleName.includes("bottom")) { - newHeight = Math.max( - minHeight, - Math.min(handleY - newY, imgHeight - newY), - ); - } - - // Early boundary check for aspect ratio mode - // If we're at a boundary and have aspect ratio, we need special handling - if (this.cropConstraints.aspectRatio) { - const atLeftEdge = rect.x() <= 0; - const atRightEdge = rect.x() + rect.width() >= imgWidth; - const atTopEdge = rect.y() <= 0; - const atBottomEdge = rect.y() + rect.height() >= imgHeight; - - // For edge handles at boundaries, prevent invalid operations - if (handleName === "left" && atLeftEdge && handleX >= rect.x()) { - // Can't move left edge further left, only right (shrinking) - return; - } - if ( - handleName === "right" && - atRightEdge && - handleX <= rect.x() + rect.width() - ) { - // Can't move right edge further right, only left (shrinking) - return; - } - if (handleName === "top" && atTopEdge && handleY >= rect.y()) { - // Can't move top edge further up, only down (shrinking) - return; - } - if ( - handleName === "bottom" && - atBottomEdge && - handleY <= rect.y() + rect.height() - ) { - // Can't move bottom edge further down, only up (shrinking) - return; + window.addEventListener('keydown', this.keydownHandler); + window.addEventListener('keyup', this.keyupHandler); + + // Pan with Space + drag or middle mouse button + stage.on('mousedown', (e) => { + if (this.isSpacePressed || e.evt.button === 1) { + e.evt.preventDefault(); + e.evt.stopPropagation(); + this.isPanning = true; + this.lastPointerPosition = stage.getPointerPosition() || undefined; + stage.container().style.cursor = 'grabbing'; + + // Stop any active drags on crop elements + if (this.konva?.crop) { + if (this.konva.crop.rect.isDragging()) { + this.konva.crop.rect.stopDrag(); + } + this.konva.crop.handles.children.forEach((handle) => { + if (handle.isDragging()) { + handle.stopDrag(); } + }); } + } + }); + + stage.on('mousemove', () => { + if (!this.isPanning || !this.lastPointerPosition) { + return; + } + + const pointer = stage.getPointerPosition(); + if (!pointer) { + return; + } + + const dx = pointer.x - this.lastPointerPosition.x; + const dy = pointer.y - this.lastPointerPosition.y; + + stage.x(stage.x() + dx); + stage.y(stage.y() + dy); + + this.lastPointerPosition = pointer; + }); + + stage.on('mouseup', () => { + if (this.isPanning) { + this.isPanning = false; + stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default'; + } + }); + + // Prevent context menu on right click + this.contextMenuHandler = (e: Event) => e.preventDefault(); + stage.container().addEventListener('contextmenu', this.contextMenuHandler); + }; + + // Image Management + loadImage = (src: string | File | Blob): Promise => { + return new Promise((resolve, reject) => { + // Clean up previous blob URL if it exists + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; + } + + const img = new Image(); + + // Set crossOrigin to avoid CORS issues when exporting + if (typeof src === 'string') { + img.crossOrigin = 'anonymous'; + } + + img.onload = () => { + this.originalImage = img; + this.displayImage(); + this.callbacks.onImageLoad?.(); + resolve(); + }; - // Apply constraints - if (this.cropConstraints.maxWidth) { - newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); - } - if (this.cropConstraints.maxHeight) { - newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); + img.onerror = () => { + // Clean up blob URL on error + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; } + reject(new Error('Failed to load image')); + }; + + if (typeof src === 'string') { + img.src = src; + } else if (src instanceof File || src instanceof Blob) { + const url = URL.createObjectURL(src); + this.currentImageBlobUrl = url; + img.src = url; + } + }); + }; + + private displayImage = () => { + if (!this.originalImage || !this.konva) { + return; + } + + // Clear existing image + if (this.konva.image) { + this.konva.image.node.destroy(); + this.konva.image.layer.destroy(); + this.konva.image = undefined; + } + + // Create image layer and node + const imageLayer = new Konva.Layer(); + let imageNode: Konva.Image; + + if (this.appliedCrop) { + imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.appliedCrop.width, + height: this.appliedCrop.height, + crop: { + x: this.appliedCrop.x, + y: this.appliedCrop.y, + width: this.appliedCrop.width, + height: this.appliedCrop.height, + }, + }); + } else { + imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.originalImage.width, + height: this.originalImage.height, + }); + } + + imageLayer.add(imageNode); + this.konva.stage.add(imageLayer); + + // Store references + this.konva.image = { + layer: imageLayer, + node: imageNode, + }; - // Apply aspect ratio if set - if (this.cropConstraints.aspectRatio) { - const ratio = this.cropConstraints.aspectRatio; - const oldX = rect.x(); - const oldY = rect.y(); - const oldWidth = rect.width(); - const oldHeight = rect.height(); - - // Define anchor points (opposite of the handle being dragged) - let anchorX = oldX; - let anchorY = oldY; - - if (handleName.includes("right")) { - anchorX = oldX; // Left edge is anchor - } else if (handleName.includes("left")) { - anchorX = oldX + oldWidth; // Right edge is anchor - } else { - anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom - } - - if (handleName.includes("bottom")) { - anchorY = oldY; // Top edge is anchor - } else if (handleName.includes("top")) { - anchorY = oldY + oldHeight; // Bottom edge is anchor - } else { - anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right - } - - // Calculate new dimensions maintaining aspect ratio - if (handleName === "left" || handleName === "right") { - // For left/right handles, adjust height to maintain ratio - newHeight = newWidth / ratio; - - // Use center Y as anchor point - newY = anchorY - newHeight / 2; - } else if (handleName === "top" || handleName === "bottom") { - // For top/bottom handles, adjust width to maintain ratio - newWidth = newHeight * ratio; - - // Use center X as anchor point - newX = anchorX - newWidth / 2; - } else { - // Corner handles - the anchor is the opposite corner - // Use mouse position relative to anchor to determine constraint - const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); - const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); - - // Calculate maximum possible dimensions based on anchor position and image bounds - let maxPossibleWidth; let maxPossibleHeight; - - if (handleName.includes("left")) { - // Anchor is on the right, max width is anchor X position - maxPossibleWidth = anchorX; - } else { - // Anchor is on the left, max width is image width minus anchor X - maxPossibleWidth = imgWidth - anchorX; - } - - if (handleName.includes("top")) { - // Anchor is on the bottom, max height is anchor Y position - maxPossibleHeight = anchorY; - } else { - // Anchor is on the top, max height is image height minus anchor Y - maxPossibleHeight = imgHeight - anchorY; - } - - // Constrain mouse distances to stay within image bounds - const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth); - const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight); - - // Determine which dimension should be the primary constraint - // based on which direction the mouse moved further from the anchor (after constraining) - const shouldConstrainByWidth = constrainedMouseDistanceX / ratio > constrainedMouseDistanceY; - - if (shouldConstrainByWidth) { - // Width is the primary dimension, calculate height from it - newWidth = constrainedMouseDistanceX; - newHeight = newWidth / ratio; - - // If calculated height exceeds bounds, switch to height constraint - if (newHeight > maxPossibleHeight) { - newHeight = maxPossibleHeight; - newWidth = newHeight * ratio; - } - } else { - // Height is the primary dimension, calculate width from it - newHeight = constrainedMouseDistanceY; - newWidth = newHeight * ratio; - - // If calculated width exceeds bounds, switch to width constraint - if (newWidth > maxPossibleWidth) { - newWidth = maxPossibleWidth; - newHeight = newWidth / ratio; - } - } - - // For corner handles, keep the opposite corner fixed - if (handleName.includes("left")) { - newX = anchorX - newWidth; - } else { - newX = anchorX; - } - - if (handleName.includes("top")) { - newY = anchorY - newHeight; - } else { - newY = anchorY; - } - } - - // Boundary checks and adjustments - // Check if we exceed image bounds and need to adjust - if (newX < 0) { - const adjustment = -newX; - newX = 0; - // If we're anchored on the right, we need to adjust width - if (handleName.includes("left")) { - newWidth -= adjustment; - newHeight = newWidth / ratio; - if (handleName !== "left") { - // For corner handles, also adjust Y to maintain anchor - newY = anchorY - newHeight; - } - } - } - - if (newY < 0) { - const adjustment = -newY; - newY = 0; - // If we're anchored on the bottom, we need to adjust height - if (handleName.includes("top")) { - newHeight -= adjustment; - newWidth = newHeight * ratio; - if (handleName !== "top") { - // For corner handles, also adjust X to maintain anchor - newX = anchorX - newWidth; - } - } - } - - if (newX + newWidth > imgWidth) { - const adjustment = newX + newWidth - imgWidth; - // If we're anchored on the left, we need to adjust width - if (handleName.includes("right")) { - newWidth -= adjustment; - newHeight = newWidth / ratio; - if (handleName !== "right") { - // For corner handles, maintain anchor - newY = anchorY - newHeight; - } - } else if (handleName === "top" || handleName === "bottom") { - // For vertical handles, recenter - newX = imgWidth - newWidth; - if (newX < 0) { - newWidth = imgWidth; - newHeight = newWidth / ratio; - newX = 0; - } - } - } - - if (newY + newHeight > imgHeight) { - const adjustment = newY + newHeight - imgHeight; - // If we're anchored on the top, we need to adjust height - if (handleName.includes("bottom")) { - newHeight -= adjustment; - newWidth = newHeight * ratio; - if (handleName !== "bottom") { - // For corner handles, maintain anchor - newX = anchorX - newWidth; - } - } else if (handleName === "left" || handleName === "right") { - // For horizontal handles, recenter - newY = imgHeight - newHeight; - if (newY < 0) { - newHeight = imgHeight; - newWidth = newHeight * ratio; - newY = 0; - } - } - } - - // Final check for minimum sizes - if (newWidth < minWidth) { - newWidth = minWidth; - newHeight = newWidth / ratio; - // Reposition based on anchor - if (handleName.includes("left")) { - newX = anchorX - newWidth; - } - if (handleName.includes("top")) { - newY = anchorY - newHeight; - } - } - if (newHeight < minHeight) { - newHeight = minHeight; - newWidth = newHeight * ratio; - // Reposition based on anchor - if (handleName.includes("left")) { - newX = anchorX - newWidth; - } - if (handleName.includes("top")) { - newY = anchorY - newHeight; - } - } - } + imageLayer.batchDraw(); + + // Center image at 100% zoom + this.resetView(); + }; + + // Crop Mode + startCrop = () => { + if (!this.konva?.image || this.isCropping) { + return; + } + + this.isCropping = true; + + // Calculate initial crop dimensions + let cropX: number; + let cropY: number; + let cropWidth: number; + let cropHeight: number; + + if (this.appliedCrop) { + // When cropped, start with full visible area + cropX = 0; + cropY = 0; + cropWidth = this.appliedCrop.width; + cropHeight = this.appliedCrop.height; + } else { + // Create default crop box (centered, 80% of image) + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + cropWidth = imgWidth * 0.8; + cropHeight = imgHeight * 0.8; + cropX = (imgWidth - cropWidth) / 2; + cropY = (imgHeight - cropHeight) / 2; + } + + this.createCropBox(cropX, cropY, cropWidth, cropHeight); + }; + + private createCropBox = (x: number, y: number, width: number, height: number) => { + if (!this.konva?.image) { + return; + } + + // Clear existing crop if any + if (this.konva.crop) { + this.konva.crop.layer.destroy(); + this.konva.crop = undefined; + } + + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + // Create crop layer + const cropLayer = new Konva.Layer(); + + // Create overlay group with composite operation + const overlay = new Konva.Group(); + + // Create full overlay + const fullOverlay = new Konva.Rect({ + x: 0, + y: 0, + width: imgWidth, + height: imgHeight, + fill: 'black', + opacity: 0.5, + }); + + // Create clear rectangle for crop area using composite operation + const clearRect = new Konva.Rect({ + x: x, + y: y, + width: width, + height: height, + fill: 'black', + globalCompositeOperation: 'destination-out', + }); + + overlay.add(fullOverlay); + overlay.add(clearRect); + + // Create crop rectangle + const rect = new Konva.Rect({ + x: x, + y: y, + width: width, + height: height, + stroke: 'white', + strokeWidth: 1, + strokeScaleEnabled: false, + draggable: true, + }); + + // Create handles group + const handles = new Konva.Group(); + + // Create guides group + const guides = new Konva.Group(); + + // Store all crop objects together + this.konva.crop = { + layer: cropLayer, + rect: rect, + overlay: overlay, + handles: handles, + guides: guides, + }; - // Update crop rect - rect.x(newX); - rect.y(newY); - rect.width(newWidth); - rect.height(newHeight); + // Create handles and guides + this.createCropHandles(); + this.createCropGuides(); - // Update overlay, handles, and guides - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); + // Setup crop box events + this.setupCropBoxEvents(); - // Reset handle position to follow crop box - this.positionHandle(handle); + // Add to layer + cropLayer.add(overlay); + cropLayer.add(rect); + cropLayer.add(guides); + cropLayer.add(handles); - this.callbacks.onCropChange?.({ - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }); - }; + // Add layer to stage + this.konva.stage.add(cropLayer); - private positionHandle = (handle: Konva.Rect) => { - if (!this.konva?.crop) { -return; -} + // Apply current scale to handles + this.updateHandleScale(); - const rect = this.konva.crop.rect; - const handleName = handle.name(); - const handleSize = handle.width(); + cropLayer.batchDraw(); + }; - let x = rect.x(); - let y = rect.y(); + private createCropGuides = () => { + if (!this.konva?.crop) { + return; + } - if (handleName.includes("right")) { -x += rect.width(); -} else if (handleName.includes("left")) { -x += 0; -} else { -x += rect.width() / 2; -} + const rect = this.konva.crop.rect; + const guides = this.konva.crop.guides; - if (handleName.includes("bottom")) { -y += rect.height(); -} else if (handleName.includes("top")) { -y += 0; -} else { -y += rect.height() / 2; -} + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); - handle.x(x - handleSize / 2); - handle.y(y - handleSize / 2); + const guideConfig = { + stroke: 'rgba(255, 255, 255, 0.5)', + strokeWidth: 1, + strokeScaleEnabled: false, + listening: false, }; - private updateHandlePositions = () => { - if (!this.konva?.crop) { -return; -} - - this.konva.crop.handles.children.forEach((handle) => { - if (handle instanceof Konva.Rect) { - this.positionHandle(handle); - } - }); + // Vertical lines (thirds) + const verticalThird = width / 3; + guides.add( + new Konva.Line({ + points: [x + verticalThird, y, x + verticalThird, y + height], + ...guideConfig, + }) + ); + guides.add( + new Konva.Line({ + points: [x + verticalThird * 2, y, x + verticalThird * 2, y + height], + ...guideConfig, + }) + ); + + // Horizontal lines (thirds) + const horizontalThird = height / 3; + guides.add( + new Konva.Line({ + points: [x, y + horizontalThird, x + width, y + horizontalThird], + ...guideConfig, + }) + ); + guides.add( + new Konva.Line({ + points: [x, y + horizontalThird * 2, x + width, y + horizontalThird * 2], + ...guideConfig, + }) + ); + }; + + private createCropHandles = () => { + if (!this.konva?.crop) { + return; + } + + const rect = this.konva.crop.rect; + const handles = this.konva.crop.handles; + const scale = this.konva.stage.scaleX(); + const handleSize = 8 / scale; + const handleConfig = { + width: handleSize, + height: handleSize, + fill: 'white', + stroke: 'black', + strokeWidth: 1 / scale, + strokeScaleEnabled: false, }; - private updateCropGuides = () => { - if (!this.konva?.crop) { -return; -} - - const rect = this.konva.crop.rect; - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const lines = this.konva.crop.guides.children; - if (lines.length < 4) { -return; -} - - // Update vertical lines - const verticalThird = width / 3; - const line0 = lines[0]; - const line1 = lines[1]; - if (line0 instanceof Konva.Line) { - line0.points([x + verticalThird, y, x + verticalThird, y + height]); + // Corner handles + const corners = [ + { name: 'top-left', x: 0, y: 0 }, + { name: 'top-right', x: 1, y: 0 }, + { name: 'bottom-right', x: 1, y: 1 }, + { name: 'bottom-left', x: 0, y: 1 }, + ]; + + corners.forEach((corner) => { + const handle = new Konva.Rect({ + ...handleConfig, + name: corner.name, + x: rect.x() + corner.x * rect.width() - handleSize / 2, + y: rect.y() + corner.y * rect.height() - handleSize / 2, + draggable: true, + }); + + this.setupHandleEvents(handle); + handles.add(handle); + }); + + // Edge handles + const edges = [ + { name: 'top', x: 0.5, y: 0 }, + { name: 'right', x: 1, y: 0.5 }, + { name: 'bottom', x: 0.5, y: 1 }, + { name: 'left', x: 0, y: 0.5 }, + ]; + + edges.forEach((edge) => { + const handle = new Konva.Rect({ + ...handleConfig, + name: edge.name, + x: rect.x() + edge.x * rect.width() - handleSize / 2, + y: rect.y() + edge.y * rect.height() - handleSize / 2, + draggable: true, + }); + + this.setupHandleEvents(handle); + handles.add(handle); + }); + }; + + private setupCropBoxEvents = () => { + if (!this.konva?.crop) { + return; + } + const stage = this.konva.stage; + const rect = this.konva.crop.rect; + const image = this.konva.image; + if (!image) { + return; + } + + // Prevent crop box dragging when panning + rect.on('dragstart', (e) => { + if (this.isSpacePressed || this.isPanning) { + e.target.stopDrag(); + return false; + } + }); + + // Crop box dragging + rect.on('dragmove', () => { + const imgWidth = image.node.width(); + const imgHeight = image.node.height(); + + // Constrain to image bounds + const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); + const y = Math.max(0, Math.min(rect.y(), imgHeight - rect.height())); + + rect.x(x); + rect.y(y); + + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + this.callbacks.onCropChange?.({ + x, + y, + width: rect.width(), + height: rect.height(), + }); + }); + + // Cursor styles + rect.on('mouseenter', () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = 'move'; + } + }); + + rect.on('mouseleave', () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = 'default'; + } + }); + }; + + private setupHandleEvents = (handle: Konva.Rect) => { + if (!this.konva) { + return; + } + const stage = this.konva.stage; + const handleName = handle.name(); + + // Prevent handle dragging when panning + handle.on('dragstart', (e) => { + if (this.isSpacePressed || this.isPanning) { + e.target.stopDrag(); + return false; + } + }); + + // Set cursor based on handle type + handle.on('mouseenter', () => { + if (!this.isSpacePressed) { + let cursor = 'pointer'; + if (handleName.includes('top-left') || handleName.includes('bottom-right')) { + cursor = 'nwse-resize'; + } else if (handleName.includes('top-right') || handleName.includes('bottom-left')) { + cursor = 'nesw-resize'; + } else if (handleName.includes('top') || handleName.includes('bottom')) { + cursor = 'ns-resize'; + } else if (handleName.includes('left') || handleName.includes('right')) { + cursor = 'ew-resize'; } - if (line1 instanceof Konva.Line) { - line1.points([ - x + verticalThird * 2, - y, - x + verticalThird * 2, - y + height, - ]); - } - - // Update horizontal lines - const horizontalThird = height / 3; - const line2 = lines[2]; - const line3 = lines[3]; - if (line2 instanceof Konva.Line) { - line2.points([ - x, - y + horizontalThird, - x + width, - y + horizontalThird, - ]); - } - if (line3 instanceof Konva.Line) { - line3.points([ - x, - y + horizontalThird * 2, - x + width, - y + horizontalThird * 2, - ]); + stage.container().style.cursor = cursor; + } + }); + + handle.on('mouseleave', () => { + if (!this.isSpacePressed) { + stage.container().style.cursor = 'default'; + } + }); + + // Handle dragging + handle.on('dragmove', () => { + this.resizeCropBox(handle); + }); + }; + + private resizeCropBox = (handle: Konva.Rect) => { + if (!this.konva?.crop || !this.konva?.image) { + return; + } + + const rect = this.konva.crop.rect; + const handleName = handle.name(); + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + let newX = rect.x(); + let newY = rect.y(); + let newWidth = rect.width(); + let newHeight = rect.height(); + + const handleX = handle.x() + handle.width() / 2; + const handleY = handle.y() + handle.height() / 2; + + const minWidth = this.cropConstraints.minWidth ?? 64; + const minHeight = this.cropConstraints.minHeight ?? 64; + + // Update dimensions based on handle type + if (handleName.includes('left')) { + const right = newX + newWidth; + newX = Math.max(0, Math.min(handleX, right - minWidth)); + newWidth = right - newX; + } + if (handleName.includes('right')) { + newWidth = Math.max(minWidth, Math.min(handleX - newX, imgWidth - newX)); + } + if (handleName.includes('top')) { + const bottom = newY + newHeight; + newY = Math.max(0, Math.min(handleY, bottom - minHeight)); + newHeight = bottom - newY; + } + if (handleName.includes('bottom')) { + newHeight = Math.max(minHeight, Math.min(handleY - newY, imgHeight - newY)); + } + + // Early boundary check for aspect ratio mode + // If we're at a boundary and have aspect ratio, we need special handling + if (this.cropConstraints.aspectRatio) { + const atLeftEdge = rect.x() <= 0; + const atRightEdge = rect.x() + rect.width() >= imgWidth; + const atTopEdge = rect.y() <= 0; + const atBottomEdge = rect.y() + rect.height() >= imgHeight; + + // For edge handles at boundaries, prevent invalid operations + if (handleName === 'left' && atLeftEdge && handleX >= rect.x()) { + // Can't move left edge further left, only right (shrinking) + return; + } + if (handleName === 'right' && atRightEdge && handleX <= rect.x() + rect.width()) { + // Can't move right edge further right, only left (shrinking) + return; + } + if (handleName === 'top' && atTopEdge && handleY >= rect.y()) { + // Can't move top edge further up, only down (shrinking) + return; + } + if (handleName === 'bottom' && atBottomEdge && handleY <= rect.y() + rect.height()) { + // Can't move bottom edge further down, only up (shrinking) + return; + } + } + + // Apply constraints + if (this.cropConstraints.maxWidth) { + newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); + } + if (this.cropConstraints.maxHeight) { + newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); + } + + // Apply aspect ratio if set + if (this.cropConstraints.aspectRatio) { + const ratio = this.cropConstraints.aspectRatio; + const oldX = rect.x(); + const oldY = rect.y(); + const oldWidth = rect.width(); + const oldHeight = rect.height(); + + // Define anchor points (opposite of the handle being dragged) + let anchorX = oldX; + let anchorY = oldY; + + if (handleName.includes('right')) { + anchorX = oldX; // Left edge is anchor + } else if (handleName.includes('left')) { + anchorX = oldX + oldWidth; // Right edge is anchor + } else { + anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom + } + + if (handleName.includes('bottom')) { + anchorY = oldY; // Top edge is anchor + } else if (handleName.includes('top')) { + anchorY = oldY + oldHeight; // Bottom edge is anchor + } else { + anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right + } + + // Calculate new dimensions maintaining aspect ratio + if (handleName === 'left' || handleName === 'right') { + // For left/right handles, adjust height to maintain ratio + newHeight = newWidth / ratio; + + // Use center Y as anchor point + newY = anchorY - newHeight / 2; + } else if (handleName === 'top' || handleName === 'bottom') { + // For top/bottom handles, adjust width to maintain ratio + newWidth = newHeight * ratio; + + // Use center X as anchor point + newX = anchorX - newWidth / 2; + } else { + // Corner handles - the anchor is the opposite corner + // Use mouse position relative to anchor to determine constraint + const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); + const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); + + // Calculate maximum possible dimensions based on anchor position and image bounds + let maxPossibleWidth; + let maxPossibleHeight; + + if (handleName.includes('left')) { + // Anchor is on the right, max width is anchor X position + maxPossibleWidth = anchorX; + } else { + // Anchor is on the left, max width is image width minus anchor X + maxPossibleWidth = imgWidth - anchorX; } - }; - - private updateCropOverlay = () => { - if (!this.konva?.crop) { -return; -} - const rect = this.konva.crop.rect; - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const nodes = this.konva.crop.overlay.children; - - // Update clear rectangle position (the cutout) - if (nodes.length > 1) { - const clearRect = nodes[1]; - if (clearRect instanceof Konva.Rect) { - clearRect.x(x); - clearRect.y(y); - clearRect.width(width); - clearRect.height(height); - } + if (handleName.includes('top')) { + // Anchor is on the bottom, max height is anchor Y position + maxPossibleHeight = anchorY; + } else { + // Anchor is on the top, max height is image height minus anchor Y + maxPossibleHeight = imgHeight - anchorY; } - this.konva.crop.layer.batchDraw(); - }; - - private updateHandleScale = () => { - if (!this.konva?.crop) { -return; -} - - const scale = this.konva.stage.scaleX(); - const handleSize = 8 / scale; - const strokeWidth = 1 / scale; - - // Update each handle's size and stroke to maintain constant screen size - this.konva.crop.handles.children.forEach((handle) => { - if (handle instanceof Konva.Rect) { - const currentX = handle.x(); - const currentY = handle.y(); - const oldSize = handle.width(); - - // Calculate center position - const centerX = currentX + oldSize / 2; - const centerY = currentY + oldSize / 2; - - // Update size and stroke - handle.width(handleSize); - handle.height(handleSize); - handle.strokeWidth(strokeWidth); - - // Reposition to maintain center - handle.x(centerX - handleSize / 2); - handle.y(centerY - handleSize / 2); - } - }); - - this.konva.crop.layer.batchDraw(); - }; - - cancelCrop = () => { - if (!this.isInCropMode || !this.konva?.crop) { -return; -} + // Constrain mouse distances to stay within image bounds + const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth); + const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight); - this.isInCropMode = false; - this.konva.crop.layer.destroy(); - this.konva.crop = undefined; - }; - - applyCrop = () => { - if (!this.isInCropMode || !this.konva?.crop) { -return; -} + // Determine which dimension should be the primary constraint + // based on which direction the mouse moved further from the anchor (after constraining) + const shouldConstrainByWidth = constrainedMouseDistanceX / ratio > constrainedMouseDistanceY; - const rect = this.konva.crop.rect; + if (shouldConstrainByWidth) { + // Width is the primary dimension, calculate height from it + newWidth = constrainedMouseDistanceX; + newHeight = newWidth / ratio; - // If there's already an applied crop, combine them - if (this.appliedCrop) { - // The new crop is relative to the already cropped image - this.appliedCrop = { - x: this.appliedCrop.x + rect.x(), - y: this.appliedCrop.y + rect.y(), - width: rect.width(), - height: rect.height(), - }; + // If calculated height exceeds bounds, switch to height constraint + if (newHeight > maxPossibleHeight) { + newHeight = maxPossibleHeight; + newWidth = newHeight * ratio; + } } else { - this.appliedCrop = { - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - }; + // Height is the primary dimension, calculate width from it + newHeight = constrainedMouseDistanceY; + newWidth = newHeight * ratio; + + // If calculated width exceeds bounds, switch to width constraint + if (newWidth > maxPossibleWidth) { + newWidth = maxPossibleWidth; + newHeight = newWidth / ratio; + } } - this.cancelCrop(); - - // Redisplay image with crop applied - this.displayImage(); - }; - - resetCrop = () => { - this.appliedCrop = undefined; - - // Redisplay image without crop - this.displayImage(); - }; - - hasCrop = (): boolean => { - return !!this.appliedCrop; - }; - - // Export - exportImage = async ( - format: "canvas" | "blob" | "dataURL" = "blob", - ): Promise => { - if (!this.originalImage) { - throw new Error("No image loaded"); + // For corner handles, keep the opposite corner fixed + if (handleName.includes('left')) { + newX = anchorX - newWidth; + } else { + newX = anchorX; } - // Create temporary canvas - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Failed to get canvas context"); + if (handleName.includes('top')) { + newY = anchorY - newHeight; + } else { + newY = anchorY; } - - try { - if (this.appliedCrop) { - canvas.width = this.appliedCrop.width; - canvas.height = this.appliedCrop.height; - - ctx.drawImage( - this.originalImage, - this.appliedCrop.x, - this.appliedCrop.y, - this.appliedCrop.width, - this.appliedCrop.height, - 0, - 0, - this.appliedCrop.width, - this.appliedCrop.height, - ); - } else { - canvas.width = this.originalImage.width; - canvas.height = this.originalImage.height; - ctx.drawImage(this.originalImage, 0, 0); - } - - if (format === "canvas") { - return canvas; - } else if (format === "dataURL") { - try { - return canvas.toDataURL("image/png"); - } catch (error) { - throw new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source."); - } - } else { - return new Promise((resolve, reject) => { - try { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to create blob")); - } - }, "image/png"); - } catch (error) { - reject(new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source.")); - } - }); - } - } catch (error) { - if (error instanceof Error && error.message.includes("tainted")) { - throw new Error("Cannot export image: Canvas is tainted by cross-origin data. Try loading the image from the same domain or use a CORS-enabled source."); - } - throw error; + } + + // Boundary checks and adjustments + // Check if we exceed image bounds and need to adjust + if (newX < 0) { + const adjustment = -newX; + newX = 0; + // If we're anchored on the right, we need to adjust width + if (handleName.includes('left')) { + newWidth -= adjustment; + newHeight = newWidth / ratio; + if (handleName !== 'left') { + // For corner handles, also adjust Y to maintain anchor + newY = anchorY - newHeight; + } } - }; - - // View Control - setZoom = (scale: number, point?: { x: number; y: number }) => { - if (!this.konva) { -return; -} - - scale = Math.max(this.zoomMin, Math.min(this.zoomMax, scale)); - - // If no point provided, use center of viewport - if (!point && this.konva.image) { - const containerWidth = this.konva.stage.width(); - const containerHeight = this.konva.stage.height(); - point = { - x: containerWidth / 2, - y: containerHeight / 2, - }; + } + + if (newY < 0) { + const adjustment = -newY; + newY = 0; + // If we're anchored on the bottom, we need to adjust height + if (handleName.includes('top')) { + newHeight -= adjustment; + newWidth = newHeight * ratio; + if (handleName !== 'top') { + // For corner handles, also adjust X to maintain anchor + newX = anchorX - newWidth; + } } - - if (point) { - const oldScale = this.konva.stage.scaleX(); - const mousePointTo = { - x: (point.x - this.konva.stage.x()) / oldScale, - y: (point.y - this.konva.stage.y()) / oldScale, - }; - - this.konva.stage.scale({ x: scale, y: scale }); - - const newPos = { - x: point.x - mousePointTo.x * scale, - y: point.y - mousePointTo.y * scale, - }; - this.konva.stage.position(newPos); - } else { - this.konva.stage.scale({ x: scale, y: scale }); + } + + if (newX + newWidth > imgWidth) { + const adjustment = newX + newWidth - imgWidth; + // If we're anchored on the left, we need to adjust width + if (handleName.includes('right')) { + newWidth -= adjustment; + newHeight = newWidth / ratio; + if (handleName !== 'right') { + // For corner handles, maintain anchor + newY = anchorY - newHeight; + } + } else if (handleName === 'top' || handleName === 'bottom') { + // For vertical handles, recenter + newX = imgWidth - newWidth; + if (newX < 0) { + newWidth = imgWidth; + newHeight = newWidth / ratio; + newX = 0; + } } - - // Update handle scaling - this.updateHandleScale(); - - this.callbacks.onZoomChange?.(scale); - }; - - getZoom = (): number => { - return this.konva?.stage.scaleX() || 1; - }; - - resetView = () => { - if (!this.konva?.image) { -return; -} - - this.konva.stage.scale({ x: 1, y: 1 }); - - // Center the image - const containerWidth = this.konva.stage.width(); - const containerHeight = this.konva.stage.height(); - const imageWidth = this.konva.image.node.width(); - const imageHeight = this.konva.image.node.height(); - - this.konva.stage.position({ - x: (containerWidth - imageWidth) / 2, - y: (containerHeight - imageHeight) / 2, - }); - - // Update handle scaling - this.updateHandleScale(); - - this.callbacks.onZoomChange?.(1); - }; - - fitToContainer = () => { - if (!this.konva?.image) { -return; -} - - const containerWidth = this.konva.stage.width(); - const containerHeight = this.konva.stage.height(); - const imageWidth = this.konva.image.node.width(); - const imageHeight = this.konva.image.node.height(); - - const scale = - Math.min( - containerWidth / imageWidth, - containerHeight / imageHeight, - ) * 0.9; // 90% to add some padding - - this.konva.stage.scale({ x: scale, y: scale }); - - // Center the image - const scaledWidth = imageWidth * scale; - const scaledHeight = imageHeight * scale; - - this.konva.stage.position({ - x: (containerWidth - scaledWidth) / 2, - y: (containerHeight - scaledHeight) / 2, - }); - - // Update handle scaling - this.updateHandleScale(); - - this.callbacks.onZoomChange?.(scale); - }; - - // Configuration - setCallbacks = (callbacks: EditorCallbacks) => { - this.callbacks = { ...this.callbacks, ...callbacks }; - }; - - setCropAspectRatio = (ratio: number | undefined) => { - // Update the constraint - this.cropConstraints.aspectRatio = ratio; - - // If we're currently cropping, adjust the crop box to match the new ratio - if (this.isInCropMode && this.konva?.crop && this.konva?.image) { - const rect = this.konva.crop.rect; - const currentWidth = rect.width(); - const currentHeight = rect.height(); - const currentArea = currentWidth * currentHeight; - - if (ratio === undefined) { - // Just removed the aspect ratio constraint, no need to adjust - return; - } - - // Calculate new dimensions maintaining the same area - // area = width * height - // ratio = width / height - // So: area = width * (width / ratio) - // Therefore: width = sqrt(area * ratio) - let newWidth = Math.sqrt(currentArea * ratio); - let newHeight = newWidth / ratio; - - // Get image bounds - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - // Check if the new dimensions would exceed image bounds - if (newWidth > imgWidth || newHeight > imgHeight) { - // Scale down to fit within image bounds while maintaining ratio - const scaleX = imgWidth / newWidth; - const scaleY = imgHeight / newHeight; - const scale = Math.min(scaleX, scaleY); - newWidth *= scale; - newHeight *= scale; - } - - // Apply minimum size constraints - const minWidth = this.cropConstraints.minWidth ?? 64; - const minHeight = this.cropConstraints.minHeight ?? 64; - - if (newWidth < minWidth) { - newWidth = minWidth; - newHeight = newWidth / ratio; - } - if (newHeight < minHeight) { - newHeight = minHeight; - newWidth = newHeight * ratio; - } - - // Center the new crop box at the same position as the old one - const currentCenterX = rect.x() + currentWidth / 2; - const currentCenterY = rect.y() + currentHeight / 2; - - let newX = currentCenterX - newWidth / 2; - let newY = currentCenterY - newHeight / 2; - - // Ensure the crop box stays within image bounds - newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); - newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); - - // Update the crop box - rect.x(newX); - rect.y(newY); - rect.width(newWidth); - rect.height(newHeight); - - // Update all visual elements - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - // Notify callback - this.callbacks.onCropChange?.({ - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }); - - // Force a redraw - this.konva.crop.layer.batchDraw(); + } + + if (newY + newHeight > imgHeight) { + const adjustment = newY + newHeight - imgHeight; + // If we're anchored on the top, we need to adjust height + if (handleName.includes('bottom')) { + newHeight -= adjustment; + newWidth = newHeight * ratio; + if (handleName !== 'bottom') { + // For corner handles, maintain anchor + newX = anchorX - newWidth; + } + } else if (handleName === 'left' || handleName === 'right') { + // For horizontal handles, recenter + newY = imgHeight - newHeight; + if (newY < 0) { + newHeight = imgHeight; + newWidth = newHeight * ratio; + newY = 0; + } } - }; - - getCropAspectRatio = (): number | undefined => { - return this.cropConstraints.aspectRatio; - }; - - // Utility - resize = (width: number, height: number) => { - if (!this.konva) { -return; -} - - this.konva.stage.width(width); - this.konva.stage.height(height); - }; - - destroy = () => { - // Remove window event listeners - if (this.keydownHandler) { - window.removeEventListener("keydown", this.keydownHandler); - this.keydownHandler = undefined; + } + + // Final check for minimum sizes + if (newWidth < minWidth) { + newWidth = minWidth; + newHeight = newWidth / ratio; + // Reposition based on anchor + if (handleName.includes('left')) { + newX = anchorX - newWidth; } - if (this.keyupHandler) { - window.removeEventListener("keyup", this.keyupHandler); - this.keyupHandler = undefined; + if (handleName.includes('top')) { + newY = anchorY - newHeight; } - - // Remove stage container event listeners - if (this.konva) { - const container = this.konva.stage.container(); - if (this.contextMenuHandler) { - container.removeEventListener( - "contextmenu", - this.contextMenuHandler, - ); - this.contextMenuHandler = undefined; - } - if (this.wheelHandler) { - container.removeEventListener("wheel", this.wheelHandler); - this.wheelHandler = undefined; - } + } + if (newHeight < minHeight) { + newHeight = minHeight; + newWidth = newHeight * ratio; + // Reposition based on anchor + if (handleName.includes('left')) { + newX = anchorX - newWidth; } - - // Clean up blob URL if it exists - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; + if (handleName.includes('top')) { + newY = anchorY - newHeight; } - - // Cancel any ongoing crop operation - if (this.isInCropMode) { - this.cancelCrop(); + } + } + + // Update crop rect + rect.x(newX); + rect.y(newY); + rect.width(newWidth); + rect.height(newHeight); + + // Update overlay, handles, and guides + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + // Reset handle position to follow crop box + this.positionHandle(handle); + + this.callbacks.onCropChange?.({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + }; + + private positionHandle = (handle: Konva.Rect) => { + if (!this.konva?.crop) { + return; + } + + const rect = this.konva.crop.rect; + const handleName = handle.name(); + const handleSize = handle.width(); + + let x = rect.x(); + let y = rect.y(); + + if (handleName.includes('right')) { + x += rect.width(); + } else if (handleName.includes('left')) { + x += 0; + } else { + x += rect.width() / 2; + } + + if (handleName.includes('bottom')) { + y += rect.height(); + } else if (handleName.includes('top')) { + y += 0; + } else { + y += rect.height() / 2; + } + + handle.x(x - handleSize / 2); + handle.y(y - handleSize / 2); + }; + + private updateHandlePositions = () => { + if (!this.konva?.crop) { + return; + } + + this.konva.crop.handles.children.forEach((handle) => { + if (handle instanceof Konva.Rect) { + this.positionHandle(handle); + } + }); + }; + + private updateCropGuides = () => { + if (!this.konva?.crop) { + return; + } + + const rect = this.konva.crop.rect; + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); + + const lines = this.konva.crop.guides.children; + if (lines.length < 4) { + return; + } + + // Update vertical lines + const verticalThird = width / 3; + const line0 = lines[0]; + const line1 = lines[1]; + if (line0 instanceof Konva.Line) { + line0.points([x + verticalThird, y, x + verticalThird, y + height]); + } + if (line1 instanceof Konva.Line) { + line1.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]); + } + + // Update horizontal lines + const horizontalThird = height / 3; + const line2 = lines[2]; + const line3 = lines[3]; + if (line2 instanceof Konva.Line) { + line2.points([x, y + horizontalThird, x + width, y + horizontalThird]); + } + if (line3 instanceof Konva.Line) { + line3.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); + } + }; + + private updateCropOverlay = () => { + if (!this.konva?.crop) { + return; + } + + const rect = this.konva.crop.rect; + const x = rect.x(); + const y = rect.y(); + const width = rect.width(); + const height = rect.height(); + + const nodes = this.konva.crop.overlay.children; + + // Update clear rectangle position (the cutout) + if (nodes.length > 1) { + const clearRect = nodes[1]; + if (clearRect instanceof Konva.Rect) { + clearRect.x(x); + clearRect.y(y); + clearRect.width(width); + clearRect.height(height); + } + } + + this.konva.crop.layer.batchDraw(); + }; + + private updateHandleScale = () => { + if (!this.konva?.crop) { + return; + } + + const scale = this.konva.stage.scaleX(); + const handleSize = 8 / scale; + const strokeWidth = 1 / scale; + + // Update each handle's size and stroke to maintain constant screen size + this.konva.crop.handles.children.forEach((handle) => { + if (handle instanceof Konva.Rect) { + const currentX = handle.x(); + const currentY = handle.y(); + const oldSize = handle.width(); + + // Calculate center position + const centerX = currentX + oldSize / 2; + const centerY = currentY + oldSize / 2; + + // Update size and stroke + handle.width(handleSize); + handle.height(handleSize); + handle.strokeWidth(strokeWidth); + + // Reposition to maintain center + handle.x(centerX - handleSize / 2); + handle.y(centerY - handleSize / 2); + } + }); + + this.konva.crop.layer.batchDraw(); + }; + + cancelCrop = () => { + if (!this.isCropping || !this.konva?.crop) { + return; + } + + this.isCropping = false; + this.konva.crop.layer.destroy(); + this.konva.crop = undefined; + }; + + applyCrop = () => { + if (!this.isCropping || !this.konva?.crop) { + return; + } + + const rect = this.konva.crop.rect; + + // If there's already an applied crop, combine them + if (this.appliedCrop) { + // The new crop is relative to the already cropped image + this.appliedCrop = { + x: this.appliedCrop.x + rect.x(), + y: this.appliedCrop.y + rect.y(), + width: rect.width(), + height: rect.height(), + }; + } else { + this.appliedCrop = { + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + }; + } + + this.cancelCrop(); + + // Redisplay image with crop applied + this.displayImage(); + }; + + resetCrop = () => { + this.appliedCrop = undefined; + + // Redisplay image without crop + this.displayImage(); + }; + + hasCrop = (): boolean => { + return !!this.appliedCrop; + }; + + // Export + exportImage = (format: 'canvas' | 'blob' | 'dataURL' = 'blob'): Promise => { + return new Promise((resolve, reject) => { + if (!this.originalImage) { + throw new Error('No image loaded'); + } + + // Create temporary canvas + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + try { + if (this.appliedCrop) { + canvas.width = this.appliedCrop.width; + canvas.height = this.appliedCrop.height; + + ctx.drawImage( + this.originalImage, + this.appliedCrop.x, + this.appliedCrop.y, + this.appliedCrop.width, + this.appliedCrop.height, + 0, + 0, + this.appliedCrop.width, + this.appliedCrop.height + ); + } else { + canvas.width = this.originalImage.width; + canvas.height = this.originalImage.height; + ctx.drawImage(this.originalImage, 0, 0); } - // Remove all Konva event listeners by destroying the stage - // This automatically removes all Konva event handlers - this.konva?.stage.destroy(); - - // Clear all references - this.konva = undefined; - this.originalImage = undefined; - this.appliedCrop = undefined; - this.callbacks = {}; - }; -} \ No newline at end of file + if (format === 'canvas') { + resolve(canvas); + } else if (format === 'dataURL') { + try { + resolve(canvas.toDataURL('image/png')); + } catch (error) { + reject(error); + } + } else { + try { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to create blob')); + } + }, 'image/png'); + } catch (error) { + reject(error); + } + } + } catch (error) { + reject(error); + } + }); + }; + + // View Control + setZoom = (scale: number, point?: { x: number; y: number }) => { + if (!this.konva) { + return; + } + + scale = Math.max(this.zoomMin, Math.min(this.zoomMax, scale)); + + // If no point provided, use center of viewport + if (!point && this.konva.image) { + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + point = { + x: containerWidth / 2, + y: containerHeight / 2, + }; + } + + if (point) { + const oldScale = this.konva.stage.scaleX(); + const mousePointTo = { + x: (point.x - this.konva.stage.x()) / oldScale, + y: (point.y - this.konva.stage.y()) / oldScale, + }; + + this.konva.stage.scale({ x: scale, y: scale }); + + const newPos = { + x: point.x - mousePointTo.x * scale, + y: point.y - mousePointTo.y * scale, + }; + this.konva.stage.position(newPos); + } else { + this.konva.stage.scale({ x: scale, y: scale }); + } + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(scale); + }; + + getZoom = (): number => { + return this.konva?.stage.scaleX() || 1; + }; + + resetView = () => { + if (!this.konva?.image) { + return; + } + + this.konva.stage.scale({ x: 1, y: 1 }); + + // Center the image + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + const imageWidth = this.konva.image.node.width(); + const imageHeight = this.konva.image.node.height(); + + this.konva.stage.position({ + x: (containerWidth - imageWidth) / 2, + y: (containerHeight - imageHeight) / 2, + }); + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(1); + }; + + fitToContainer = () => { + if (!this.konva?.image) { + return; + } + + const containerWidth = this.konva.stage.width(); + const containerHeight = this.konva.stage.height(); + const imageWidth = this.konva.image.node.width(); + const imageHeight = this.konva.image.node.height(); + + const scale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * 0.9; // 90% to add some padding + + this.konva.stage.scale({ x: scale, y: scale }); + + // Center the image + const scaledWidth = imageWidth * scale; + const scaledHeight = imageHeight * scale; + + this.konva.stage.position({ + x: (containerWidth - scaledWidth) / 2, + y: (containerHeight - scaledHeight) / 2, + }); + + // Update handle scaling + this.updateHandleScale(); + + this.callbacks.onZoomChange?.(scale); + }; + + // Configuration + setCallbacks = (callbacks: EditorCallbacks, replace = false) => { + if (replace) { + this.callbacks = callbacks; + } else { + this.callbacks = { ...this.callbacks, ...callbacks }; + } + }; + + setCropAspectRatio = (ratio: number | undefined) => { + // Update the constraint + this.cropConstraints.aspectRatio = ratio; + + // If we're currently cropping, adjust the crop box to match the new ratio + if (this.isCropping && this.konva?.crop && this.konva?.image) { + const rect = this.konva.crop.rect; + const currentWidth = rect.width(); + const currentHeight = rect.height(); + const currentArea = currentWidth * currentHeight; + + if (ratio === undefined) { + // Just removed the aspect ratio constraint, no need to adjust + return; + } + + // Calculate new dimensions maintaining the same area + // area = width * height + // ratio = width / height + // So: area = width * (width / ratio) + // Therefore: width = sqrt(area * ratio) + let newWidth = Math.sqrt(currentArea * ratio); + let newHeight = newWidth / ratio; + + // Get image bounds + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + // Check if the new dimensions would exceed image bounds + if (newWidth > imgWidth || newHeight > imgHeight) { + // Scale down to fit within image bounds while maintaining ratio + const scaleX = imgWidth / newWidth; + const scaleY = imgHeight / newHeight; + const scale = Math.min(scaleX, scaleY); + newWidth *= scale; + newHeight *= scale; + } + + // Apply minimum size constraints + const minWidth = this.cropConstraints.minWidth ?? 64; + const minHeight = this.cropConstraints.minHeight ?? 64; + + if (newWidth < minWidth) { + newWidth = minWidth; + newHeight = newWidth / ratio; + } + if (newHeight < minHeight) { + newHeight = minHeight; + newWidth = newHeight * ratio; + } + + // Center the new crop box at the same position as the old one + const currentCenterX = rect.x() + currentWidth / 2; + const currentCenterY = rect.y() + currentHeight / 2; + + let newX = currentCenterX - newWidth / 2; + let newY = currentCenterY - newHeight / 2; + + // Ensure the crop box stays within image bounds + newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); + newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); + + // Update the crop box + rect.x(newX); + rect.y(newY); + rect.width(newWidth); + rect.height(newHeight); + + // Update all visual elements + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + // Notify callback + this.callbacks.onCropChange?.({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + + // Force a redraw + this.konva.crop.layer.batchDraw(); + } + }; + + getCropAspectRatio = (): number | undefined => { + return this.cropConstraints.aspectRatio; + }; + + // Utility + resize = (width: number, height: number) => { + if (!this.konva) { + return; + } + + this.konva.stage.width(width); + this.konva.stage.height(height); + }; + + destroy = () => { + // Remove window event listeners + if (this.keydownHandler) { + window.removeEventListener('keydown', this.keydownHandler); + this.keydownHandler = undefined; + } + if (this.keyupHandler) { + window.removeEventListener('keyup', this.keyupHandler); + this.keyupHandler = undefined; + } + + // Remove stage container event listeners + if (this.konva) { + const container = this.konva.stage.container(); + if (this.contextMenuHandler) { + container.removeEventListener('contextmenu', this.contextMenuHandler); + this.contextMenuHandler = undefined; + } + if (this.wheelHandler) { + container.removeEventListener('wheel', this.wheelHandler); + this.wheelHandler = undefined; + } + } + + // Clean up blob URL if it exists + if (this.currentImageBlobUrl) { + URL.revokeObjectURL(this.currentImageBlobUrl); + this.currentImageBlobUrl = undefined; + } + + // Cancel any ongoing crop operation + if (this.isCropping) { + this.cancelCrop(); + } + + // Remove all Konva event listeners by destroying the stage + // This automatically removes all Konva event handlers + this.konva?.stage.destroy(); + + // Clear all references + this.konva = undefined; + this.originalImage = undefined; + this.appliedCrop = undefined; + this.callbacks = {}; + }; +} From 1d7ca99676988de8d8db5f92894353922b6a284a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:33:08 +1000 Subject: [PATCH 04/34] tidy(ui): editor cleanup --- .../src/features/editImageModal/lib/editor.ts | 294 ++++++++++-------- 1 file changed, 157 insertions(+), 137 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index b32f1cfd7dd..0b7af27bbbc 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1,4 +1,5 @@ import Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; type CropConstraints = { minWidth?: number; @@ -8,12 +9,6 @@ type CropConstraints = { aspectRatio?: number; }; -type EditorCallbacks = { - onCropChange?: (crop: { x: number; y: number; width: number; height: number }) => void; - onZoomChange?: (zoom: number) => void; - onImageLoad?: () => void; -}; - type CropData = { x: number; y: number; @@ -21,6 +16,12 @@ type CropData = { height: number; }; +type EditorCallbacks = { + onCropChange?: (crop: CropData | null) => void; + onZoomChange?: (zoom: number) => void; + onImageLoad?: () => void; +}; + type KonvaObjects = { stage: Konva.Stage; image?: { @@ -37,10 +38,11 @@ type KonvaObjects = { }; export class Editor { - private konva?: KonvaObjects; - private originalImage?: HTMLImageElement; + private konva: KonvaObjects | null = null; + private originalImage: HTMLImageElement | null = null; private isCropping = false; - private appliedCrop?: CropData; + private appliedCrop: CropData | null = null; + private currentImageBlobUrl: string | null = null; // Configuration private zoomMin = 0.1; @@ -53,13 +55,10 @@ export class Editor { // State private isPanning = false; - private lastPointerPosition?: { x: number; y: number }; + private lastPointerPosition: { x: number; y: number } | null = null; private isSpacePressed = false; - private keydownHandler?: (e: KeyboardEvent) => void; - private keyupHandler?: (e: KeyboardEvent) => void; - private contextMenuHandler?: (e: Event) => void; - private currentImageBlobUrl?: string; - private wheelHandler?: (e: WheelEvent) => void; + + subscriptions: Set<() => void> = new Set(); init = (container: HTMLDivElement) => { // Create stage @@ -81,131 +80,171 @@ export class Editor { } const stage = this.konva.stage; - // Zoom with mouse wheel - this.wheelHandler = (e: WheelEvent) => { - e.preventDefault(); - - const oldScale = stage.scaleX(); - const pointer = stage.getPointerPosition(); + stage.container().addEventListener('wheel', this.onWheel, { passive: false }); + this.subscriptions.add(() => { + stage.container().removeEventListener('wheel', this.onWheel); + }); + stage.container().addEventListener('contextmenu', this.onContextMenu); + this.subscriptions.add(() => { + stage.container().removeEventListener('contextmenu', this.onContextMenu); + }); - if (!pointer) { - return; - } + stage.on('pointerdown', this.onPointerDown); + this.subscriptions.add(() => { + stage.off('contextmenu', this.onContextMenu); + }); + stage.on('pointerup', this.onPointerUp); + this.subscriptions.add(() => { + stage.off('pointerup', this.onPointerUp); + }); + stage.on('pointermove', this.onPointerMove); + this.subscriptions.add(() => { + stage.off('pointermove', this.onPointerMove); + }); - const mousePointTo = { - x: (pointer.x - stage.x()) / oldScale, - y: (pointer.y - stage.y()) / oldScale, - }; + window.addEventListener('keydown', this.onKeyDown); + this.subscriptions.add(() => { + window.removeEventListener('keydown', this.onKeyDown); + }); - const direction = e.deltaY > 0 ? -1 : 1; - const scaleBy = 1.1; - let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; + window.addEventListener('keyup', this.onKeyUp); + this.subscriptions.add(() => { + window.removeEventListener('keyup', this.onKeyUp); + }); + }; - // Apply zoom limits - newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); + // Track Space key press + onKeyDown = (e: KeyboardEvent) => { + if (!this.konva?.stage) { + return; + } + if (e.code === 'Space' && !this.isSpacePressed) { + e.preventDefault(); + this.isSpacePressed = true; + this.konva.stage.container().style.cursor = 'grab'; + } + }; - stage.scale({ x: newScale, y: newScale }); + // Zoom with mouse wheel + onWheel = (e: WheelEvent) => { + if (!this.konva?.stage) { + return; + } + e.preventDefault(); - const newPos = { - x: pointer.x - mousePointTo.x * newScale, - y: pointer.y - mousePointTo.y * newScale, - }; - stage.position(newPos); + const oldScale = this.konva.stage.scaleX(); + const pointer = this.konva.stage.getPointerPosition(); - // Update handle scaling to maintain constant screen size - this.updateHandleScale(); + if (!pointer) { + return; + } - this.callbacks.onZoomChange?.(newScale); + const mousePointTo = { + x: (pointer.x - this.konva.stage.x()) / oldScale, + y: (pointer.y - this.konva.stage.y()) / oldScale, }; - stage.container().addEventListener('wheel', this.wheelHandler, { passive: false }); + const direction = e.deltaY > 0 ? -1 : 1; + const scaleBy = 1.1; + let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; - // Track Space key press - this.keydownHandler = (e: KeyboardEvent) => { - if (e.code === 'Space' && !this.isSpacePressed) { - e.preventDefault(); - this.isSpacePressed = true; - if (stage) { - stage.container().style.cursor = 'grab'; - } - } - }; + // Apply zoom limits + newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); - this.keyupHandler = (e: KeyboardEvent) => { - if (e.code === 'Space') { - e.preventDefault(); - this.isSpacePressed = false; - this.isPanning = false; - if (stage) { - stage.container().style.cursor = 'default'; - } - } + this.konva.stage.scale({ x: newScale, y: newScale }); + + const newPos = { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, }; + this.konva.stage.position(newPos); - window.addEventListener('keydown', this.keydownHandler); - window.addEventListener('keyup', this.keyupHandler); - - // Pan with Space + drag or middle mouse button - stage.on('mousedown', (e) => { - if (this.isSpacePressed || e.evt.button === 1) { - e.evt.preventDefault(); - e.evt.stopPropagation(); - this.isPanning = true; - this.lastPointerPosition = stage.getPointerPosition() || undefined; - stage.container().style.cursor = 'grabbing'; - - // Stop any active drags on crop elements - if (this.konva?.crop) { - if (this.konva.crop.rect.isDragging()) { - this.konva.crop.rect.stopDrag(); - } - this.konva.crop.handles.children.forEach((handle) => { - if (handle.isDragging()) { - handle.stopDrag(); - } - }); - } - } - }); + // Update handle scaling to maintain constant screen size + this.updateHandleScale(); - stage.on('mousemove', () => { - if (!this.isPanning || !this.lastPointerPosition) { - return; - } + this.callbacks.onZoomChange?.(newScale); + }; - const pointer = stage.getPointerPosition(); - if (!pointer) { - return; + onKeyUp = (e: KeyboardEvent) => { + if (!this.konva?.stage) { + return; + } + if (e.code === 'Space') { + e.preventDefault(); + this.isSpacePressed = false; + this.isPanning = false; + this.konva.stage.container().style.cursor = 'grab'; + } + }; + + // Pan with Space + drag or middle mouse button + onPointerDown = (e: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (this.isSpacePressed || e.evt.button === 1) { + e.evt.preventDefault(); + e.evt.stopPropagation(); + this.isPanning = true; + this.lastPointerPosition = this.konva.stage.getPointerPosition(); + this.konva.stage.container().style.cursor = 'grabbing'; + + // Stop any active drags on crop elements + if (this.konva.crop) { + if (this.konva.crop.rect.isDragging()) { + this.konva.crop.rect.stopDrag(); + } + this.konva.crop.handles.children.forEach((handle) => { + if (handle.isDragging()) { + handle.stopDrag(); + } + }); } + } + }; - const dx = pointer.x - this.lastPointerPosition.x; - const dy = pointer.y - this.lastPointerPosition.y; + onPointerMove = (_: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (!this.isPanning || !this.lastPointerPosition) { + return; + } + + const pointer = this.konva.stage.getPointerPosition(); + if (!pointer) { + return; + } - stage.x(stage.x() + dx); - stage.y(stage.y() + dy); + const dx = pointer.x - this.lastPointerPosition.x; + const dy = pointer.y - this.lastPointerPosition.y; - this.lastPointerPosition = pointer; - }); + this.konva.stage.x(this.konva.stage.x() + dx); + this.konva.stage.y(this.konva.stage.y() + dy); - stage.on('mouseup', () => { - if (this.isPanning) { - this.isPanning = false; - stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default'; - } - }); + this.lastPointerPosition = pointer; + }; - // Prevent context menu on right click - this.contextMenuHandler = (e: Event) => e.preventDefault(); - stage.container().addEventListener('contextmenu', this.contextMenuHandler); + onPointerUp = (_: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (this.isPanning) { + this.isPanning = false; + this.konva.stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default'; + } }; + // Prevent context menu on right click + onContextMenu = (e: MouseEvent) => e.preventDefault(); + // Image Management loadImage = (src: string | File | Blob): Promise => { return new Promise((resolve, reject) => { // Clean up previous blob URL if it exists if (this.currentImageBlobUrl) { URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; + this.currentImageBlobUrl = null; } const img = new Image(); @@ -226,7 +265,7 @@ export class Editor { // Clean up blob URL on error if (this.currentImageBlobUrl) { URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; + this.currentImageBlobUrl = null; } reject(new Error('Failed to load image')); }; @@ -1124,7 +1163,7 @@ export class Editor { }; resetCrop = () => { - this.appliedCrop = undefined; + this.appliedCrop = null; // Redisplay image without crop this.displayImage(); @@ -1406,33 +1445,14 @@ export class Editor { }; destroy = () => { - // Remove window event listeners - if (this.keydownHandler) { - window.removeEventListener('keydown', this.keydownHandler); - this.keydownHandler = undefined; - } - if (this.keyupHandler) { - window.removeEventListener('keyup', this.keyupHandler); - this.keyupHandler = undefined; - } - - // Remove stage container event listeners - if (this.konva) { - const container = this.konva.stage.container(); - if (this.contextMenuHandler) { - container.removeEventListener('contextmenu', this.contextMenuHandler); - this.contextMenuHandler = undefined; - } - if (this.wheelHandler) { - container.removeEventListener('wheel', this.wheelHandler); - this.wheelHandler = undefined; - } + for (const unsubscribe of this.subscriptions) { + unsubscribe(); } // Clean up blob URL if it exists if (this.currentImageBlobUrl) { URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = undefined; + this.currentImageBlobUrl = null; } // Cancel any ongoing crop operation @@ -1445,9 +1465,9 @@ export class Editor { this.konva?.stage.destroy(); // Clear all references - this.konva = undefined; - this.originalImage = undefined; - this.appliedCrop = undefined; + this.konva = null; + this.originalImage = null; + this.appliedCrop = null; this.callbacks = {}; }; } From 01ae8bc104e33b80bd6700986239b013d0e5b492 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:33:15 +1000 Subject: [PATCH 05/34] tidy(ui): editor component cleanup --- .../components/EditorContainer.tsx | 417 +++++++++--------- 1 file changed, 209 insertions(+), 208 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index c3d544006a1..2b70d5bab8b 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -1,213 +1,214 @@ -import { Button, Flex, Select } from "@invoke-ai/ui-library"; -import { skipToken } from "@reduxjs/toolkit/query"; -import { useAppSelector } from "app/store/storeHooks"; -import { convertImageUrlToBlob } from "common/util/convertImageUrlToBlob"; -import { useEditor } from "features/editImageModal/hooks/useEditor"; -import { $imageName } from "features/editImageModal/store"; -import { selectAutoAddBoardId } from "features/gallery/store/gallerySelectors"; -import { useCallback,useEffect, useRef, useState } from "react"; -import { useGetImageDTOQuery, useUploadImageMutation } from "services/api/endpoints/images"; +import { Button, Flex, Select } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; +import { useEditor } from 'features/editImageModal/hooks/useEditor'; +import { $imageName } from 'features/editImageModal/store'; +import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; export const EditorContainer = () => { - const containerRef = useRef(null); - const editor = useEditor({ containerRef }); - const [zoomLevel, setZoomLevel] = useState(100); - const [cropInfo, setCropInfo] = useState(""); - const [isInCropMode, setIsInCropMode] = useState(false); - const [hasCrop, setHasCrop] = useState(false); - const [aspectRatio, setAspectRatio] = useState("free"); - const { data: imageDTO } = useGetImageDTOQuery($imageName.get() ?? skipToken); - const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - - const [uploadImage, { isLoading }] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); - - - const loadImage = useCallback(async () => { - if (!imageDTO) { - console.error("Image not found"); - return; + const containerRef = useRef(null); + const editor = useEditor({ containerRef }); + const [zoomLevel, setZoomLevel] = useState(100); + const [cropInfo, setCropInfo] = useState(''); + const [isCropping, setIsCropping] = useState(false); + const [hasCropBbox, setHasCropBbox] = useState(false); + const [aspectRatio, setAspectRatio] = useState('free'); + const { data: imageDTO } = useGetImageDTOQuery($imageName.get() ?? skipToken); + const autoAddBoardId = useAppSelector(selectAutoAddBoardId); + + const [uploadImage, { isLoading }] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + + const loadImage = useCallback(async () => { + if (!imageDTO) { + console.error('Image not found'); + return; + } + const blob = await convertImageUrlToBlob(imageDTO.image_url); + if (!blob) { + console.error('Failed to convert image to blob'); + return; + } + await editor.loadImage(blob); + }, [editor, imageDTO]); + + // Setup callbacks + useEffect(() => { + loadImage(); + editor.setCallbacks({ + onZoomChange: (zoom) => setZoomLevel(Math.round(zoom * 100)), + onCropChange: (crop) => { + if (!crop) { + setCropInfo(''); + return; } - const blob = await convertImageUrlToBlob(imageDTO.image_url); - if (!blob) { - console.error("Failed to convert image to blob"); - return; - } - await editor.loadImage(blob); - }, [editor]); - - // Setup callbacks - useEffect(() => { - loadImage(); - editor.setCallbacks({ - onZoomChange: (zoom) => setZoomLevel(Math.round(zoom * 100)), - onCropChange: (crop) => setCropInfo(`Crop: ${Math.round(crop.x)}, ${Math.round(crop.y)} - ${Math.round(crop.width)}x${Math.round(crop.height)}`), - onImageLoad: () => { - setCropInfo(""); - setIsInCropMode(false); - setHasCrop(false); - } - }); - - - }, [editor, loadImage]); - - - - const handleStartCrop = () => { - editor.startCrop(); - setIsInCropMode(true); - // Apply current aspect ratio if not free - if (aspectRatio !== "free") { - const ratios: Record = { - "1:1": 1, - "4:3": 4 / 3, - "16:9": 16 / 9, - "3:2": 3 / 2, - "2:3": 2 / 3, - "9:16": 9 / 16, - }; - editor.setCropAspectRatio(ratios[aspectRatio]); - } - }; - - const handleAspectRatioChange = (e: React.ChangeEvent) => { - const newRatio = e.target.value; - setAspectRatio(newRatio); - - if (newRatio === "free") { - editor.setCropAspectRatio(undefined); - } else { - const ratios: Record = { - "1:1": 1, - "4:3": 4 / 3, - "16:9": 16 / 9, - "3:2": 3 / 2, - "2:3": 2 / 3, - "9:16": 9 / 16, - }; - editor.setCropAspectRatio(ratios[newRatio]); - } - }; - - const handleApplyCrop = () => { - editor.applyCrop(); - setIsInCropMode(false); - setHasCrop(true); - setCropInfo(""); - setAspectRatio("free"); - }; - - const handleCancelCrop = () => { - editor.cancelCrop(); - setIsInCropMode(false); - setCropInfo(""); - setAspectRatio("free"); - }; - - const handleResetCrop = () => { - editor.resetCrop(); - setHasCrop(false); - }; - - const handleExport = async () => { - try { - const blob = await editor.exportImage("blob") as Blob; - const file = new File([blob], "image.png", { type: "image/png" }); - - await uploadImage({ - file, - is_intermediate: false, - image_category: 'user', - board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, - }).unwrap(); - - - - } catch (err) { - console.error("Export failed:", err); - if (err instanceof Error && err.message.includes("tainted")) { - alert("Cannot export image: The image is from a different domain (CORS issue). To fix this:\n\n1. Load images from the same domain\n2. Use images from CORS-enabled sources\n3. Upload a local image file instead"); - } else { - alert(`Export failed: ${ err instanceof Error ? err.message : String(err)}`); - } - } - }; - - return ( - - - - - - {!isInCropMode && ( - <> - - {hasCrop && } - - )} - {isInCropMode && ( - <> - - - - - )} - - - - - - - - - - - - - Zoom: {zoomLevel}% - {cropInfo && {cropInfo}} - {hasCrop && ✓ Crop Applied} - - - - - - Mouse wheel: Zoom - Space + Drag: Pan - {isInCropMode && Drag crop box or handles to adjust} - {isInCropMode && aspectRatio !== "free" && Aspect ratio: {aspectRatio}} - - + setCropInfo( + `Crop: ${Math.round(crop.x)}, ${Math.round(crop.y)} - ${Math.round(crop.width)}x${Math.round(crop.height)}` + ); + }, + onImageLoad: () => { + setCropInfo(''); + setIsCropping(false); + setHasCropBbox(false); + }, + }); + }, [editor, loadImage]); + + const handleStartCrop = () => { + editor.startCrop(); + setIsCropping(true); + // Apply current aspect ratio if not free + if (aspectRatio !== 'free') { + const ratios: Record = { + '1:1': 1, + '4:3': 4 / 3, + '16:9': 16 / 9, + '3:2': 3 / 2, + '2:3': 2 / 3, + '9:16': 9 / 16, + }; + editor.setCropAspectRatio(ratios[aspectRatio]); + } + }; + + const handleAspectRatioChange = (e: React.ChangeEvent) => { + const newRatio = e.target.value; + setAspectRatio(newRatio); + + if (newRatio === 'free') { + editor.setCropAspectRatio(undefined); + } else { + const ratios: Record = { + '1:1': 1, + '4:3': 4 / 3, + '16:9': 16 / 9, + '3:2': 3 / 2, + '2:3': 2 / 3, + '9:16': 9 / 16, + }; + editor.setCropAspectRatio(ratios[newRatio]); + } + }; + + const handleApplyCrop = () => { + editor.applyCrop(); + setIsCropping(false); + setHasCropBbox(true); + setCropInfo(''); + setAspectRatio('free'); + }; + + const handleCancelCrop = () => { + editor.cancelCrop(); + setIsCropping(false); + setCropInfo(''); + setAspectRatio('free'); + }; + + const handleResetCrop = () => { + editor.resetCrop(); + setHasCropBbox(false); + }; + + const handleExport = async () => { + try { + const blob = (await editor.exportImage('blob')) as Blob; + const file = new File([blob], 'image.png', { type: 'image/png' }); + + await uploadImage({ + file, + is_intermediate: false, + image_category: 'user', + board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, + }).unwrap(); + } catch (err) { + console.error('Export failed:', err); + if (err instanceof Error && err.message.includes('tainted')) { + alert( + 'Cannot export image: The image is from a different domain (CORS issue). To fix this:\n\n1. Load images from the same domain\n2. Use images from CORS-enabled sources\n3. Upload a local image file instead' + ); + } else { + alert(`Export failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + return ( + + + + {!isCropping && ( + <> + + {hasCropBbox && } + + )} + {isCropping && ( + <> + + + + + )} - ); -} + + + + + + + + + + + Zoom: {zoomLevel}% + {cropInfo && {cropInfo}} + {hasCropBbox && ✓ Crop Applied} + + + + + + Mouse wheel: Zoom + Space + Drag: Pan + {isCropping && Drag crop box or handles to adjust} + {isCropping && aspectRatio !== 'free' && Aspect ratio: {aspectRatio}} + + + + ); +}; From 625ff1f6c994c84e5dc7beb25209a1fea93e4d49 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:42:34 +1000 Subject: [PATCH 06/34] feat(ui): tweak editor konva styles --- .../src/features/editImageModal/lib/editor.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 0b7af27bbbc..2aed8d1f958 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -395,7 +395,7 @@ export class Editor { width: imgWidth, height: imgHeight, fill: 'black', - opacity: 0.5, + opacity: 0.7, }); // Create clear rectangle for crop area using composite operation @@ -525,8 +525,8 @@ export class Editor { height: handleSize, fill: 'white', stroke: 'black', - strokeWidth: 1 / scale, - strokeScaleEnabled: false, + strokeWidth: 1, + strokeScaleEnabled: true, }; // Corner handles @@ -1282,6 +1282,16 @@ export class Editor { return this.konva?.stage.scaleX() || 1; }; + zoomIn = (factor = 1.2, point?: { x: number; y: number }) => { + const currentZoom = this.getZoom(); + this.setZoom(currentZoom * factor, point); + }; + + zoomOut = (factor = 1.2, point?: { x: number; y: number }) => { + const currentZoom = this.getZoom(); + this.setZoom(currentZoom / factor, point); + }; + resetView = () => { if (!this.konva?.image) { return; From 3b27902d82f9babbf509e2387d3857304cc72ead Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:59:40 +1000 Subject: [PATCH 07/34] feat(ui): image editor bg checkerboard pattern --- .../src/features/editImageModal/lib/editor.ts | 67 ++++++++++++++++--- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 2aed8d1f958..6613ad2a65e 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1,3 +1,5 @@ +import { $authToken } from 'app/store/nanostores/authToken'; +import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -24,6 +26,10 @@ type EditorCallbacks = { type KonvaObjects = { stage: Konva.Stage; + bg: { + layer: Konva.Layer; + patternRect: Konva.Rect; + }; image?: { layer: Konva.Layer; node: Konva.Image; @@ -61,19 +67,56 @@ export class Editor { subscriptions: Set<() => void> = new Set(); init = (container: HTMLDivElement) => { - // Create stage - this.konva = { - stage: new Konva.Stage({ - container: container, - width: container.clientWidth, - height: container.clientHeight, - }), + const stage = new Konva.Stage({ + container: container, + width: container.clientWidth, + height: container.clientHeight, + }); + + const bgLayer = new Konva.Layer(); + const bgPatternRect = new Konva.Rect(); + bgLayer.add(bgPatternRect); + const bgImage = new Image(); + bgImage.onload = () => { + bgPatternRect.fillPatternImage(bgImage); + this.renderBg(); }; + bgImage.src = $authToken.get() ? 'use-credentials' : 'anonymous'; + bgImage.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; + stage.add(bgLayer); + + this.konva = { + stage, + bg: { + layer: bgLayer, + patternRect: bgPatternRect, + }, + }; // Setup mouse event handlers this.setupStageEvents(); }; + renderBg = () => { + if (!this.konva) { + return; + } + const scale = this.konva.stage.scaleX(); + const patternScale = 1 / scale; + const { x, y } = this.konva.stage.getPosition(); + const { width, height } = this.konva.stage.size(); + + this.konva.bg.patternRect.setAttrs({ + visible: true, + x: Math.floor(-x / scale), + y: Math.floor(-y / scale), + width: Math.ceil(width / scale), + height: Math.ceil(height / scale), + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale, + }); + }; + private setupStageEvents = () => { if (!this.konva) { return; @@ -161,7 +204,7 @@ export class Editor { // Update handle scaling to maintain constant screen size this.updateHandleScale(); - + this.renderBg(); this.callbacks.onZoomChange?.(newScale); }; @@ -222,6 +265,8 @@ export class Editor { this.konva.stage.x(this.konva.stage.x() + dx); this.konva.stage.y(this.konva.stage.y() + dy); + this.renderBg(); + this.lastPointerPosition = pointer; }; @@ -1275,6 +1320,8 @@ export class Editor { // Update handle scaling this.updateHandleScale(); + this.renderBg(); + this.callbacks.onZoomChange?.(scale); }; @@ -1313,6 +1360,8 @@ export class Editor { // Update handle scaling this.updateHandleScale(); + this.renderBg(); + this.callbacks.onZoomChange?.(1); }; @@ -1452,6 +1501,8 @@ export class Editor { this.konva.stage.width(width); this.konva.stage.height(height); + + this.renderBg(); }; destroy = () => { From 5bdb854178262c4ec62c402642172de53618b8f5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:02:33 +1000 Subject: [PATCH 08/34] tidy(ui): lint/react conventions for editor component --- .../components/EditorContainer.tsx | 131 +++++++++--------- 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 2b70d5bab8b..bffecd83cb1 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -5,7 +5,7 @@ import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import { useEditor } from 'features/editImageModal/hooks/useEditor'; import { $imageName } from 'features/editImageModal/store'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; export const EditorContainer = () => { @@ -56,7 +56,7 @@ export const EditorContainer = () => { }); }, [editor, loadImage]); - const handleStartCrop = () => { + const handleStartCrop = useCallback(() => { editor.startCrop(); setIsCropping(true); // Apply current aspect ratio if not free @@ -71,48 +71,51 @@ export const EditorContainer = () => { }; editor.setCropAspectRatio(ratios[aspectRatio]); } - }; + }, [aspectRatio, editor]); - const handleAspectRatioChange = (e: React.ChangeEvent) => { - const newRatio = e.target.value; - setAspectRatio(newRatio); + const handleAspectRatioChange = useCallback( + (e: React.ChangeEvent) => { + const newRatio = e.target.value; + setAspectRatio(newRatio); - if (newRatio === 'free') { - editor.setCropAspectRatio(undefined); - } else { - const ratios: Record = { - '1:1': 1, - '4:3': 4 / 3, - '16:9': 16 / 9, - '3:2': 3 / 2, - '2:3': 2 / 3, - '9:16': 9 / 16, - }; - editor.setCropAspectRatio(ratios[newRatio]); - } - }; + if (newRatio === 'free') { + editor.setCropAspectRatio(undefined); + } else { + const ratios: Record = { + '1:1': 1, + '4:3': 4 / 3, + '16:9': 16 / 9, + '3:2': 3 / 2, + '2:3': 2 / 3, + '9:16': 9 / 16, + }; + editor.setCropAspectRatio(ratios[newRatio]); + } + }, + [editor] + ); - const handleApplyCrop = () => { + const handleApplyCrop = useCallback(() => { editor.applyCrop(); setIsCropping(false); setHasCropBbox(true); setCropInfo(''); setAspectRatio('free'); - }; + }, [editor]); - const handleCancelCrop = () => { + const handleCancelCrop = useCallback(() => { editor.cancelCrop(); setIsCropping(false); setCropInfo(''); setAspectRatio('free'); - }; + }, [editor]); - const handleResetCrop = () => { + const handleResetCrop = useCallback(() => { editor.resetCrop(); setHasCropBbox(false); - }; + }, [editor]); - const handleExport = async () => { + const handleExport = useCallback(async () => { try { const blob = (await editor.exportImage('blob')) as Blob; const file = new File([blob], 'image.png', { type: 'image/png' }); @@ -133,24 +136,28 @@ export const EditorContainer = () => { alert(`Export failed: ${err instanceof Error ? err.message : String(err)}`); } } - }; + }, [autoAddBoardId, editor, uploadImage]); + + const zoomIn = useCallback(() => { + editor.zoomIn(); + }, [editor]); + + const zoomOut = useCallback(() => { + editor.zoomOut(); + }, [editor]); + + const fitToContainer = useCallback(() => { + editor.fitToContainer(); + }, [editor]); + + const resetView = useCallback(() => { + editor.resetView(); + }, [editor]); return ( - - + + {!isCropping && ( <> @@ -159,7 +166,7 @@ export const EditorContainer = () => { )} {isCropping && ( <> - @@ -174,40 +181,30 @@ export const EditorContainer = () => { )} - - - - - + + + + + - + Zoom: {zoomLevel}% {cropInfo && {cropInfo}} {hasCropBbox && ✓ Crop Applied} - - - Mouse wheel: Zoom - Space + Drag: Pan - {isCropping && Drag crop box or handles to adjust} - {isCropping && aspectRatio !== 'free' && Aspect ratio: {aspectRatio}} - + + Mouse wheel: Zoom + Space + Drag: Pan + {isCropping && Drag crop box or handles to adjust} + {isCropping && aspectRatio !== 'free' && Aspect ratio: {aspectRatio}} + + + ); From 9575a55b97804c48538e0e60e34afcfea8fd32df Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:06:23 +1000 Subject: [PATCH 09/34] feat(ui): type narrowing for editor output types --- .../components/EditorContainer.tsx | 2 +- .../src/features/editImageModal/lib/editor.ts | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index bffecd83cb1..cd83fd15f49 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -117,7 +117,7 @@ export const EditorContainer = () => { const handleExport = useCallback(async () => { try { - const blob = (await editor.exportImage('blob')) as Blob; + const blob = await editor.exportImage('blob'); const file = new File([blob], 'image.png', { type: 'image/png' }); await uploadImage({ diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 6613ad2a65e..6d7c94a5773 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -43,6 +43,16 @@ type KonvaObjects = { }; }; +type OutputFormat = 'canvas' | 'blob' | 'dataURL'; + +type OutputFormatToOutputMap = T extends 'canvas' + ? HTMLCanvasElement + : T extends 'blob' + ? Blob + : T extends 'dataURL' + ? string + : never; + export class Editor { private konva: KonvaObjects | null = null; private originalImage: HTMLImageElement | null = null; @@ -1219,7 +1229,11 @@ export class Editor { }; // Export - exportImage = (format: 'canvas' | 'blob' | 'dataURL' = 'blob'): Promise => { + exportImage = ( + format: T = 'blob' as T + ): Promise< + T extends 'canvas' ? HTMLCanvasElement : T extends 'blob' ? Blob : T extends 'dataURL' ? string : never + > => { return new Promise((resolve, reject) => { if (!this.originalImage) { throw new Error('No image loaded'); @@ -1255,10 +1269,10 @@ export class Editor { } if (format === 'canvas') { - resolve(canvas); + resolve(canvas as OutputFormatToOutputMap); } else if (format === 'dataURL') { try { - resolve(canvas.toDataURL('image/png')); + resolve(canvas.toDataURL('image/png') as OutputFormatToOutputMap); } catch (error) { reject(error); } @@ -1266,7 +1280,7 @@ export class Editor { try { canvas.toBlob((blob) => { if (blob) { - resolve(blob); + resolve(blob as OutputFormatToOutputMap); } else { reject(new Error('Failed to create blob')); } From 33dcb34ed0a7ac7d3e01c20c239e8d46af8886ac Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 15:38:57 +1000 Subject: [PATCH 10/34] feat(ui): misc iterate on editor --- .../components/EditImageModal.tsx | 42 ++-- .../components/EditorContainer.tsx | 186 +++++++++++------- .../src/features/editImageModal/lib/editor.ts | 56 ++++-- .../features/editImageModal/store/index.ts | 40 +++- .../StartingFrameImage.tsx | 37 ++-- 5 files changed, 227 insertions(+), 134 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx index 0b3b428e3cc..73788a21501 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx @@ -1,29 +1,21 @@ -import { Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from "@invoke-ai/ui-library"; -import { useStore } from "@nanostores/react"; -import { $isOpen } from "features/editImageModal/store"; -import { useCallback } from "react"; +import { Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $editImageModalState, closeEditImageModal } from 'features/editImageModal/store'; -import { EditorContainer } from "./EditorContainer"; +import { EditorContainer } from './EditorContainer'; export const EditImageModal = () => { - const isOpen = useStore($isOpen); - const onClose = useCallback(() => { - $isOpen.set(false); - }, []); + const state = useStore($editImageModalState); - return - - - Edit Image - - - - - ; -}; \ No newline at end of file + return ( + + + + Edit Image + + {state.isOpen && } + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index cd83fd15f49..f50b672116d 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -1,25 +1,45 @@ -import { Button, Flex, Select } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; +import { Button, Divider, Flex, Select, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; -import { useEditor } from 'features/editImageModal/hooks/useEditor'; -import { $imageName } from 'features/editImageModal/store'; +import type { CropBox, Editor } from 'features/editImageModal/lib/editor'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; -export const EditorContainer = () => { +type Props = { + editor: Editor; + imageName: string; +}; + +export const EditorContainer = ({ editor, imageName }: Props) => { const containerRef = useRef(null); - const editor = useEditor({ containerRef }); - const [zoomLevel, setZoomLevel] = useState(100); - const [cropInfo, setCropInfo] = useState(''); - const [isCropping, setIsCropping] = useState(false); - const [hasCropBbox, setHasCropBbox] = useState(false); + const [zoom, setZoom] = useState(100); + const [cropInProgress, setCropInProgress] = useState(false); + const [cropBox, setCropBox] = useState(null); + const [cropApplied, setCropApplied] = useState(false); const [aspectRatio, setAspectRatio] = useState('free'); - const { data: imageDTO } = useGetImageDTOQuery($imageName.get() ?? skipToken); + const { data: imageDTO } = useGetImageDTOQuery(imageName); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); - const [uploadImage, { isLoading }] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + editor.init(container); + const handleResize = () => { + editor.resize(container.clientWidth, container.clientHeight); + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(container); + return () => { + resizeObserver.disconnect(); + editor.destroy(); + }; + }, [editor]); const loadImage = useCallback(async () => { if (!imageDTO) { @@ -38,27 +58,40 @@ export const EditorContainer = () => { useEffect(() => { loadImage(); editor.setCallbacks({ - onZoomChange: (zoom) => setZoomLevel(Math.round(zoom * 100)), - onCropChange: (crop) => { - if (!crop) { - setCropInfo(''); - return; - } - setCropInfo( - `Crop: ${Math.round(crop.x)}, ${Math.round(crop.y)} - ${Math.round(crop.width)}x${Math.round(crop.height)}` - ); + onZoomChange: (zoom) => { + setZoom(zoom); + }, + onCropStart: () => { + setCropInProgress(true); + setCropBox(null); + }, + onCropBoxChange: (crop) => { + setCropBox(crop); + }, + onCropApply: () => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }, + onCropReset: () => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }, + onCropCancel: () => { + setCropInProgress(false); + setCropBox(null); }, onImageLoad: () => { - setCropInfo(''); - setIsCropping(false); - setHasCropBbox(false); + // setCropInfo(''); + // setIsCropping(false); + // setHasCropBbox(false); }, }); }, [editor, loadImage]); const handleStartCrop = useCallback(() => { editor.startCrop(); - setIsCropping(true); // Apply current aspect ratio if not free if (aspectRatio !== 'free') { const ratios: Record = { @@ -97,22 +130,22 @@ export const EditorContainer = () => { const handleApplyCrop = useCallback(() => { editor.applyCrop(); - setIsCropping(false); - setHasCropBbox(true); - setCropInfo(''); + // setIsCropping(false); + // setHasCropBbox(true); + // setCropInfo(''); setAspectRatio('free'); }, [editor]); const handleCancelCrop = useCallback(() => { editor.cancelCrop(); - setIsCropping(false); - setCropInfo(''); + // setIsCropping(false); + // setCropInfo(''); setAspectRatio('free'); }, [editor]); const handleResetCrop = useCallback(() => { editor.resetCrop(); - setHasCropBbox(false); + // setHasCropBbox(false); }, [editor]); const handleExport = useCallback(async () => { @@ -155,57 +188,60 @@ export const EditorContainer = () => { }, [editor]); return ( - - - - {!isCropping && ( - <> - - {hasCropBbox && } - - )} - {isCropping && ( - <> - - - - - )} - - - - - - - - + + + {!cropInProgress && } + {cropApplied && } + {cropInProgress && ( + <> + + + + + )} + + + + + - - - Zoom: {zoomLevel}% - {cropInfo && {cropInfo}} - {hasCropBbox && ✓ Crop Applied} - - - Mouse wheel: Zoom - Space + Drag: Pan - {isCropping && Drag crop box or handles to adjust} - {isCropping && aspectRatio !== 'free' && Aspect ratio: {aspectRatio}} - + + + Mouse wheel: Zoom + + Space + Drag: Pan + {cropInProgress && ( + <> + + Drag crop box or handles to adjust + + )} + {cropInProgress && cropBox && ( + <> + + + X: {Math.round(cropBox.x)}, Y: {Math.round(cropBox.y)}, Width: {Math.round(cropBox.width)}, Height:{' '} + {Math.round(cropBox.height)} + + + )} + + Zoom: {Math.round(zoom * 100)}% + ); }; diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 6d7c94a5773..48e3ae83da1 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -11,7 +11,7 @@ type CropConstraints = { aspectRatio?: number; }; -type CropData = { +export type CropBox = { x: number; y: number; width: number; @@ -19,7 +19,11 @@ type CropData = { }; type EditorCallbacks = { - onCropChange?: (crop: CropData | null) => void; + onCropStart?: () => void; + onCropBoxChange?: (crop: CropBox) => void; + onCropApply?: (crop: CropBox) => void; + onCropReset?: () => void; + onCropCancel?: () => void; onZoomChange?: (zoom: number) => void; onImageLoad?: () => void; }; @@ -57,7 +61,7 @@ export class Editor { private konva: KonvaObjects | null = null; private originalImage: HTMLImageElement | null = null; private isCropping = false; - private appliedCrop: CropData | null = null; + private appliedCrop: CropBox | null = null; private currentImageBlobUrl: string | null = null; // Configuration @@ -391,7 +395,7 @@ export class Editor { }; // Crop Mode - startCrop = () => { + startCrop = (crop?: CropBox) => { if (!this.konva?.image || this.isCropping) { return; } @@ -404,7 +408,12 @@ export class Editor { let cropWidth: number; let cropHeight: number; - if (this.appliedCrop) { + if (crop) { + cropX = crop.x; + cropY = crop.y; + cropWidth = crop.width; + cropHeight = crop.height; + } else if (this.appliedCrop) { // When cropped, start with full visible area cropX = 0; cropY = 0; @@ -421,6 +430,14 @@ export class Editor { } this.createCropBox(cropX, cropY, cropWidth, cropHeight); + + this.callbacks.onCropStart?.(); + this.callbacks.onCropBoxChange?.({ + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight, + }); }; private createCropBox = (x: number, y: number, width: number, height: number) => { @@ -662,7 +679,7 @@ export class Editor { this.updateHandlePositions(); this.updateCropGuides(); - this.callbacks.onCropChange?.({ + this.callbacks.onCropBoxChange?.({ x, y, width: rect.width(), @@ -1024,7 +1041,7 @@ export class Editor { // Reset handle position to follow crop box this.positionHandle(handle); - this.callbacks.onCropChange?.({ + this.callbacks.onCropBoxChange?.({ x: newX, y: newY, width: newWidth, @@ -1176,14 +1193,20 @@ export class Editor { this.konva.crop.layer.batchDraw(); }; + resetEphemeralCropState = () => { + this.isCropping = false; + if (this.konva?.crop) { + this.konva.crop.layer.destroy(); + this.konva.crop = undefined; + } + }; + cancelCrop = () => { if (!this.isCropping || !this.konva?.crop) { return; } - - this.isCropping = false; - this.konva.crop.layer.destroy(); - this.konva.crop = undefined; + this.resetEphemeralCropState(); + this.callbacks.onCropCancel?.(); }; applyCrop = () => { @@ -1211,10 +1234,11 @@ export class Editor { }; } - this.cancelCrop(); - // Redisplay image with crop applied this.displayImage(); + + this.resetEphemeralCropState(); + this.callbacks.onCropApply?.(this.appliedCrop); }; resetCrop = () => { @@ -1222,6 +1246,8 @@ export class Editor { // Redisplay image without crop this.displayImage(); + + this.callbacks.onCropReset?.(); }; hasCrop = (): boolean => { @@ -1405,6 +1431,8 @@ export class Editor { // Update handle scaling this.updateHandleScale(); + this.renderBg(); + this.callbacks.onZoomChange?.(scale); }; @@ -1491,7 +1519,7 @@ export class Editor { this.updateCropGuides(); // Notify callback - this.callbacks.onCropChange?.({ + this.callbacks.onCropBoxChange?.({ x: newX, y: newY, width: newWidth, diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts index 6338f34bc1f..00c937a1a46 100644 --- a/invokeai/frontend/web/src/features/editImageModal/store/index.ts +++ b/invokeai/frontend/web/src/features/editImageModal/store/index.ts @@ -1,4 +1,38 @@ -import { atom } from "nanostores"; +import { Editor } from 'features/editImageModal/lib/editor'; +import { atom } from 'nanostores'; -export const $isOpen = atom(false); -export const $imageName = atom(null); \ No newline at end of file +type EditImageModalState = + | { + isOpen: false; + imageName: null; + editor: null; + } + | { + isOpen: true; + imageName: string; + editor: Editor; + }; + +export const $editImageModalState = atom({ + isOpen: false, + imageName: null, + editor: null, +}); + +export const openEditImageModal = (imageName: string) => { + $editImageModalState.set({ + isOpen: true, + imageName, + editor: new Editor(), + }); +}; + +export const closeEditImageModal = () => { + const { editor } = $editImageModalState.get(); + editor?.destroy(); + $editImageModalState.set({ + isOpen: false, + imageName: null, + editor: null, + }); +}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index c329c4f0427..edd24341961 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -7,7 +7,7 @@ import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon, imageButtonSx } from 'features/dnd/DndImageIcon'; -import { $imageName, $isOpen } from 'features/editImageModal/store'; +import { openEditImageModal } from 'features/editImageModal/store'; import { selectStartingFrameImage, selectVideoAspectRatio, @@ -41,11 +41,12 @@ export const StartingFrameImage = () => { ); const onOpenEditImageModal = useCallback(() => { - $isOpen.set(true); - $imageName.set(imageDTO?.image_name ?? null); + if (!imageDTO) { + return; + } + openEditImageModal(imageDTO.image_name); }, [imageDTO]); - const fitsCurrentAspectRatio = useMemo(() => { if (!imageDTO) { return true; @@ -56,20 +57,24 @@ export const StartingFrameImage = () => { return ( - {t('parameters.startingFrameImage')} - - - + + {t('parameters.startingFrameImage')} + + + - + {!imageDTO && ( { /> - - Date: Fri, 12 Sep 2025 16:05:55 +1000 Subject: [PATCH 11/34] tidy(ui): cleanup --- .../src/features/editImageModal/lib/editor.ts | 463 ++++++++---------- 1 file changed, 196 insertions(+), 267 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 48e3ae83da1..fd5755653bc 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1,4 +1,3 @@ -import { $authToken } from 'app/store/nanostores/authToken'; import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -64,12 +63,24 @@ export class Editor { private appliedCrop: CropBox | null = null; private currentImageBlobUrl: string | null = null; + // Constants + private readonly MIN_CROP_DIMENSION = 64; + private readonly ZOOM_WHEEL_FACTOR = 1.1; + private readonly ZOOM_BUTTON_FACTOR = 1.2; + private readonly CROP_HANDLE_SIZE = 8; + private readonly CROP_HANDLE_STROKE_WIDTH = 1; + private readonly FIT_TO_CONTAINER_PADDING = 0.9; + private readonly DEFAULT_CROP_BOX_SCALE = 0.8; + private readonly CORNER_HANDLE_NAMES = ['top-left', 'top-right', 'bottom-right', 'bottom-left']; + private readonly EDGE_HANDLE_NAMES = ['top', 'right', 'bottom', 'left']; + // Configuration - private zoomMin = 0.1; - private zoomMax = 10; + private readonly ZOOM_MIN = 0.1; + private readonly ZOOM_MAX = 10; + private cropConstraints: CropConstraints = { - minWidth: 64, - minHeight: 64, + minWidth: this.MIN_CROP_DIMENSION, + minHeight: this.MIN_CROP_DIMENSION, }; private callbacks: EditorCallbacks = {}; @@ -95,7 +106,6 @@ export class Editor { bgPatternRect.fillPatternImage(bgImage); this.renderBg(); }; - bgImage.src = $authToken.get() ? 'use-credentials' : 'anonymous'; bgImage.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; stage.add(bgLayer); @@ -148,7 +158,7 @@ export class Editor { stage.on('pointerdown', this.onPointerDown); this.subscriptions.add(() => { - stage.off('contextmenu', this.onContextMenu); + stage.off('pointerdown', this.onPointerDown); }); stage.on('pointerup', this.onPointerUp); this.subscriptions.add(() => { @@ -202,11 +212,11 @@ export class Editor { }; const direction = e.deltaY > 0 ? -1 : 1; - const scaleBy = 1.1; + const scaleBy = this.ZOOM_WHEEL_FACTOR; let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; // Apply zoom limits - newScale = Math.max(this.zoomMin, Math.min(this.zoomMax, newScale)); + newScale = Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, newScale)); this.konva.stage.scale({ x: newScale, y: newScale }); @@ -230,7 +240,8 @@ export class Editor { e.preventDefault(); this.isSpacePressed = false; this.isPanning = false; - this.konva.stage.container().style.cursor = 'grab'; + // Revert cursor to default; mouseenter events will set it correctly if over an interactive element. + this.konva.stage.container().style.cursor = 'default'; } }; @@ -423,8 +434,8 @@ export class Editor { // Create default crop box (centered, 80% of image) const imgWidth = this.konva.image.node.width(); const imgHeight = this.konva.image.node.height(); - cropWidth = imgWidth * 0.8; - cropHeight = imgHeight * 0.8; + cropWidth = imgWidth * this.DEFAULT_CROP_BOX_SCALE; + cropHeight = imgHeight * this.DEFAULT_CROP_BOX_SCALE; cropX = (imgWidth - cropWidth) / 2; cropY = (imgHeight - cropHeight) / 2; } @@ -490,7 +501,7 @@ export class Editor { width: width, height: height, stroke: 'white', - strokeWidth: 1, + strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, strokeScaleEnabled: false, draggable: true, }); @@ -547,7 +558,7 @@ export class Editor { const guideConfig = { stroke: 'rgba(255, 255, 255, 0.5)', - strokeWidth: 1, + strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, strokeScaleEnabled: false, listening: false, }; @@ -591,13 +602,13 @@ export class Editor { const rect = this.konva.crop.rect; const handles = this.konva.crop.handles; const scale = this.konva.stage.scaleX(); - const handleSize = 8 / scale; + const handleSize = this.CROP_HANDLE_SIZE / scale; const handleConfig = { width: handleSize, height: handleSize, fill: 'white', stroke: 'black', - strokeWidth: 1, + strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, strokeScaleEnabled: true, }; @@ -750,6 +761,46 @@ export class Editor { return; } + const rect = this.konva.crop.rect; + + let { newX, newY, newWidth, newHeight } = this.cropConstraints.aspectRatio + ? this._resizeCropBoxWithAspectRatio(handle) + : this._resizeCropBoxFree(handle); + + // Apply general constraints + if (this.cropConstraints.maxWidth) { + newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); + } + if (this.cropConstraints.maxHeight) { + newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); + } + + // Update crop rect + rect.x(newX); + rect.y(newY); + rect.width(newWidth); + rect.height(newHeight); + + // Update overlay, handles, and guides + this.updateCropOverlay(); + this.updateHandlePositions(); + this.updateCropGuides(); + + // Reset handle position to follow crop box + this.positionHandle(handle); + + this.callbacks.onCropBoxChange?.({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); + }; + + private _resizeCropBoxFree = (handle: Konva.Rect) => { + if (!this.konva?.crop || !this.konva?.image) { + throw new Error('Crop box or image not found'); + } const rect = this.konva.crop.rect; const handleName = handle.name(); const imgWidth = this.konva.image.node.width(); @@ -763,8 +814,8 @@ export class Editor { const handleX = handle.x() + handle.width() / 2; const handleY = handle.y() + handle.height() / 2; - const minWidth = this.cropConstraints.minWidth ?? 64; - const minHeight = this.cropConstraints.minHeight ?? 64; + const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; // Update dimensions based on handle type if (handleName.includes('left')) { @@ -784,269 +835,151 @@ export class Editor { newHeight = Math.max(minHeight, Math.min(handleY - newY, imgHeight - newY)); } - // Early boundary check for aspect ratio mode - // If we're at a boundary and have aspect ratio, we need special handling - if (this.cropConstraints.aspectRatio) { - const atLeftEdge = rect.x() <= 0; - const atRightEdge = rect.x() + rect.width() >= imgWidth; - const atTopEdge = rect.y() <= 0; - const atBottomEdge = rect.y() + rect.height() >= imgHeight; - - // For edge handles at boundaries, prevent invalid operations - if (handleName === 'left' && atLeftEdge && handleX >= rect.x()) { - // Can't move left edge further left, only right (shrinking) - return; - } - if (handleName === 'right' && atRightEdge && handleX <= rect.x() + rect.width()) { - // Can't move right edge further right, only left (shrinking) - return; - } - if (handleName === 'top' && atTopEdge && handleY >= rect.y()) { - // Can't move top edge further up, only down (shrinking) - return; - } - if (handleName === 'bottom' && atBottomEdge && handleY <= rect.y() + rect.height()) { - // Can't move bottom edge further down, only up (shrinking) - return; - } - } + return { newX, newY, newWidth, newHeight }; + }; - // Apply constraints - if (this.cropConstraints.maxWidth) { - newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); - } - if (this.cropConstraints.maxHeight) { - newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); + private _resizeCropBoxWithAspectRatio = (handle: Konva.Rect) => { + if (!this.konva?.crop || !this.konva?.image || !this.cropConstraints.aspectRatio) { + throw new Error('Crop box, image, or aspect ratio not found'); } + const rect = this.konva.crop.rect; + const handleName = handle.name(); + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + const ratio = this.cropConstraints.aspectRatio; + + const handleX = handle.x() + handle.width() / 2; + const handleY = handle.y() + handle.height() / 2; - // Apply aspect ratio if set - if (this.cropConstraints.aspectRatio) { - const ratio = this.cropConstraints.aspectRatio; - const oldX = rect.x(); - const oldY = rect.y(); - const oldWidth = rect.width(); - const oldHeight = rect.height(); + const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; - // Define anchor points (opposite of the handle being dragged) - let anchorX = oldX; - let anchorY = oldY; + // Early boundary check for aspect ratio mode + const atLeftEdge = rect.x() <= 0; + const atRightEdge = rect.x() + rect.width() >= imgWidth; + const atTopEdge = rect.y() <= 0; + const atBottomEdge = rect.y() + rect.height() >= imgHeight; + + if ( + (handleName === 'left' && atLeftEdge && handleX >= rect.x()) || + (handleName === 'right' && atRightEdge && handleX <= rect.x() + rect.width()) || + (handleName === 'top' && atTopEdge && handleY >= rect.y()) || + (handleName === 'bottom' && atBottomEdge && handleY <= rect.y() + rect.height()) + ) { + return { newX: rect.x(), newY: rect.y(), newWidth: rect.width(), newHeight: rect.height() }; + } + + const { newX: freeX, newY: freeY, newWidth: freeWidth, newHeight: freeHeight } = this._resizeCropBoxFree(handle); + let newX = freeX; + let newY = freeY; + let newWidth = freeWidth; + let newHeight = freeHeight; + + const oldX = rect.x(); + const oldY = rect.y(); + const oldWidth = rect.width(); + const oldHeight = rect.height(); + + // Define anchor points (opposite of the handle being dragged) + let anchorX = oldX; + let anchorY = oldY; - if (handleName.includes('right')) { - anchorX = oldX; // Left edge is anchor - } else if (handleName.includes('left')) { - anchorX = oldX + oldWidth; // Right edge is anchor - } else { - anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom - } + if (handleName.includes('right')) { + anchorX = oldX; // Left edge is anchor + } else if (handleName.includes('left')) { + anchorX = oldX + oldWidth; // Right edge is anchor + } else { + anchorX = oldX + oldWidth / 2; // Center X is anchor for top/bottom + } - if (handleName.includes('bottom')) { - anchorY = oldY; // Top edge is anchor - } else if (handleName.includes('top')) { - anchorY = oldY + oldHeight; // Bottom edge is anchor - } else { - anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right - } + if (handleName.includes('bottom')) { + anchorY = oldY; // Top edge is anchor + } else if (handleName.includes('top')) { + anchorY = oldY + oldHeight; // Bottom edge is anchor + } else { + anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right + } - // Calculate new dimensions maintaining aspect ratio + const isCornerHandle = this.CORNER_HANDLE_NAMES.includes(handleName); + + // Calculate new dimensions maintaining aspect ratio + if (this.EDGE_HANDLE_NAMES.includes(handleName) && !isCornerHandle) { if (handleName === 'left' || handleName === 'right') { - // For left/right handles, adjust height to maintain ratio newHeight = newWidth / ratio; - - // Use center Y as anchor point newY = anchorY - newHeight / 2; - } else if (handleName === 'top' || handleName === 'bottom') { - // For top/bottom handles, adjust width to maintain ratio + } else { + // top or bottom newWidth = newHeight * ratio; - - // Use center X as anchor point newX = anchorX - newWidth / 2; - } else { - // Corner handles - the anchor is the opposite corner - // Use mouse position relative to anchor to determine constraint - const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); - const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); - - // Calculate maximum possible dimensions based on anchor position and image bounds - let maxPossibleWidth; - let maxPossibleHeight; - - if (handleName.includes('left')) { - // Anchor is on the right, max width is anchor X position - maxPossibleWidth = anchorX; - } else { - // Anchor is on the left, max width is image width minus anchor X - maxPossibleWidth = imgWidth - anchorX; - } - - if (handleName.includes('top')) { - // Anchor is on the bottom, max height is anchor Y position - maxPossibleHeight = anchorY; - } else { - // Anchor is on the top, max height is image height minus anchor Y - maxPossibleHeight = imgHeight - anchorY; - } + } + } else if (isCornerHandle) { + const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); + const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); - // Constrain mouse distances to stay within image bounds - const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth); - const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight); + let maxPossibleWidth = handleName.includes('left') ? anchorX : imgWidth - anchorX; + let maxPossibleHeight = handleName.includes('top') ? anchorY : imgHeight - anchorY; - // Determine which dimension should be the primary constraint - // based on which direction the mouse moved further from the anchor (after constraining) - const shouldConstrainByWidth = constrainedMouseDistanceX / ratio > constrainedMouseDistanceY; + const constrainedMouseDistanceX = Math.min(mouseDistanceFromAnchorX, maxPossibleWidth); + const constrainedMouseDistanceY = Math.min(mouseDistanceFromAnchorY, maxPossibleHeight); - if (shouldConstrainByWidth) { - // Width is the primary dimension, calculate height from it - newWidth = constrainedMouseDistanceX; - newHeight = newWidth / ratio; - - // If calculated height exceeds bounds, switch to height constraint - if (newHeight > maxPossibleHeight) { - newHeight = maxPossibleHeight; - newWidth = newHeight * ratio; - } - } else { - // Height is the primary dimension, calculate width from it - newHeight = constrainedMouseDistanceY; + if (constrainedMouseDistanceX / ratio > constrainedMouseDistanceY) { + newWidth = constrainedMouseDistanceX; + newHeight = newWidth / ratio; + if (newHeight > maxPossibleHeight) { + newHeight = maxPossibleHeight; newWidth = newHeight * ratio; - - // If calculated width exceeds bounds, switch to width constraint - if (newWidth > maxPossibleWidth) { - newWidth = maxPossibleWidth; - newHeight = newWidth / ratio; - } - } - - // For corner handles, keep the opposite corner fixed - if (handleName.includes('left')) { - newX = anchorX - newWidth; - } else { - newX = anchorX; - } - - if (handleName.includes('top')) { - newY = anchorY - newHeight; - } else { - newY = anchorY; } - } - - // Boundary checks and adjustments - // Check if we exceed image bounds and need to adjust - if (newX < 0) { - const adjustment = -newX; - newX = 0; - // If we're anchored on the right, we need to adjust width - if (handleName.includes('left')) { - newWidth -= adjustment; + } else { + newHeight = constrainedMouseDistanceY; + newWidth = newHeight * ratio; + if (newWidth > maxPossibleWidth) { + newWidth = maxPossibleWidth; newHeight = newWidth / ratio; - if (handleName !== 'left') { - // For corner handles, also adjust Y to maintain anchor - newY = anchorY - newHeight; - } - } - } - - if (newY < 0) { - const adjustment = -newY; - newY = 0; - // If we're anchored on the bottom, we need to adjust height - if (handleName.includes('top')) { - newHeight -= adjustment; - newWidth = newHeight * ratio; - if (handleName !== 'top') { - // For corner handles, also adjust X to maintain anchor - newX = anchorX - newWidth; - } } } - if (newX + newWidth > imgWidth) { - const adjustment = newX + newWidth - imgWidth; - // If we're anchored on the left, we need to adjust width - if (handleName.includes('right')) { - newWidth -= adjustment; - newHeight = newWidth / ratio; - if (handleName !== 'right') { - // For corner handles, maintain anchor - newY = anchorY - newHeight; - } - } else if (handleName === 'top' || handleName === 'bottom') { - // For vertical handles, recenter - newX = imgWidth - newWidth; - if (newX < 0) { - newWidth = imgWidth; - newHeight = newWidth / ratio; - newX = 0; - } - } - } + newX = handleName.includes('left') ? anchorX - newWidth : anchorX; + newY = handleName.includes('top') ? anchorY - newHeight : anchorY; + } - if (newY + newHeight > imgHeight) { - const adjustment = newY + newHeight - imgHeight; - // If we're anchored on the top, we need to adjust height - if (handleName.includes('bottom')) { - newHeight -= adjustment; - newWidth = newHeight * ratio; - if (handleName !== 'bottom') { - // For corner handles, maintain anchor - newX = anchorX - newWidth; - } - } else if (handleName === 'left' || handleName === 'right') { - // For horizontal handles, recenter - newY = imgHeight - newHeight; - if (newY < 0) { - newHeight = imgHeight; - newWidth = newHeight * ratio; - newY = 0; - } - } - } + // Boundary checks and adjustments + if (newX < 0) { + newX = 0; + newWidth = oldX + oldWidth; + newHeight = newWidth / ratio; + newY = handleName.includes('top') ? oldY + oldHeight - newHeight : oldY; + } + if (newY < 0) { + newY = 0; + newHeight = oldY + oldHeight; + newWidth = newHeight * ratio; + newX = handleName.includes('left') ? oldX + oldWidth - newWidth : oldX; + } + if (newX + newWidth > imgWidth) { + newWidth = imgWidth - newX; + newHeight = newWidth / ratio; + newY = handleName.includes('top') ? oldY + oldHeight - newHeight : oldY; + } + if (newY + newHeight > imgHeight) { + newHeight = imgHeight - newY; + newWidth = newHeight * ratio; + newX = handleName.includes('left') ? oldX + oldWidth - newWidth : oldX; + } - // Final check for minimum sizes - if (newWidth < minWidth) { + // Final check for minimum sizes + if (newWidth < minWidth || newHeight < minHeight) { + if (minWidth / ratio > minHeight) { newWidth = minWidth; newHeight = newWidth / ratio; - // Reposition based on anchor - if (handleName.includes('left')) { - newX = anchorX - newWidth; - } - if (handleName.includes('top')) { - newY = anchorY - newHeight; - } - } - if (newHeight < minHeight) { + } else { newHeight = minHeight; newWidth = newHeight * ratio; - // Reposition based on anchor - if (handleName.includes('left')) { - newX = anchorX - newWidth; - } - if (handleName.includes('top')) { - newY = anchorY - newHeight; - } } + newX = handleName.includes('left') ? anchorX - newWidth : anchorX; + newY = handleName.includes('top') ? anchorY - newHeight : anchorY; } - // Update crop rect - rect.x(newX); - rect.y(newY); - rect.width(newWidth); - rect.height(newHeight); - - // Update overlay, handles, and guides - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - // Reset handle position to follow crop box - this.positionHandle(handle); - - this.callbacks.onCropBoxChange?.({ - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }); + return { newX, newY, newWidth, newHeight }; }; private positionHandle = (handle: Konva.Rect) => { @@ -1063,17 +996,13 @@ export class Editor { if (handleName.includes('right')) { x += rect.width(); - } else if (handleName.includes('left')) { - x += 0; - } else { + } else if (!handleName.includes('left')) { x += rect.width() / 2; } if (handleName.includes('bottom')) { y += rect.height(); - } else if (handleName.includes('top')) { - y += 0; - } else { + } else if (!handleName.includes('top')) { y += rect.height() / 2; } @@ -1165,8 +1094,8 @@ export class Editor { } const scale = this.konva.stage.scaleX(); - const handleSize = 8 / scale; - const strokeWidth = 1 / scale; + const handleSize = this.CROP_HANDLE_SIZE / scale; + const strokeWidth = this.CROP_HANDLE_STROKE_WIDTH / scale; // Update each handle's size and stroke to maintain constant screen size this.konva.crop.handles.children.forEach((handle) => { @@ -1327,7 +1256,7 @@ export class Editor { return; } - scale = Math.max(this.zoomMin, Math.min(this.zoomMax, scale)); + scale = Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, scale)); // If no point provided, use center of viewport if (!point && this.konva.image) { @@ -1369,14 +1298,14 @@ export class Editor { return this.konva?.stage.scaleX() || 1; }; - zoomIn = (factor = 1.2, point?: { x: number; y: number }) => { + zoomIn = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); - this.setZoom(currentZoom * factor, point); + this.setZoom(currentZoom * this.ZOOM_BUTTON_FACTOR, point); }; - zoomOut = (factor = 1.2, point?: { x: number; y: number }) => { + zoomOut = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); - this.setZoom(currentZoom / factor, point); + this.setZoom(currentZoom / this.ZOOM_BUTTON_FACTOR, point); }; resetView = () => { @@ -1415,7 +1344,7 @@ export class Editor { const imageWidth = this.konva.image.node.width(); const imageHeight = this.konva.image.node.height(); - const scale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * 0.9; // 90% to add some padding + const scale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.FIT_TO_CONTAINER_PADDING; this.konva.stage.scale({ x: scale, y: scale }); @@ -1484,8 +1413,8 @@ export class Editor { } // Apply minimum size constraints - const minWidth = this.cropConstraints.minWidth ?? 64; - const minHeight = this.cropConstraints.minHeight ?? 64; + const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; if (newWidth < minWidth) { newWidth = minWidth; From 2ef777c6e591e90c7de2718ea0254dd5dd187702 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:28:51 +1000 Subject: [PATCH 12/34] feat(ui): crop doesn't hide outside cropped region --- .../src/features/editImageModal/lib/editor.ts | 195 +++++++++++++----- 1 file changed, 144 insertions(+), 51 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index fd5755653bc..dac131778c2 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -44,6 +44,10 @@ type KonvaObjects = { handles: Konva.Group; guides: Konva.Group; }; + frozenCrop?: { + layer: Konva.Layer; + overlay: Konva.Group; + }; }; type OutputFormat = 'canvas' | 'blob' | 'dataURL'; @@ -362,33 +366,15 @@ export class Editor { this.konva.image = undefined; } - // Create image layer and node + // Create image layer and node - always show full image const imageLayer = new Konva.Layer(); - let imageNode: Konva.Image; - - if (this.appliedCrop) { - imageNode = new Konva.Image({ - image: this.originalImage, - x: 0, - y: 0, - width: this.appliedCrop.width, - height: this.appliedCrop.height, - crop: { - x: this.appliedCrop.x, - y: this.appliedCrop.y, - width: this.appliedCrop.width, - height: this.appliedCrop.height, - }, - }); - } else { - imageNode = new Konva.Image({ - image: this.originalImage, - x: 0, - y: 0, - width: this.originalImage.width, - height: this.originalImage.height, - }); - } + const imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.originalImage.width, + height: this.originalImage.height, + }); imageLayer.add(imageNode); this.konva.stage.add(imageLayer); @@ -401,6 +387,11 @@ export class Editor { imageLayer.batchDraw(); + // If there's an applied crop, create frozen overlay + if (this.appliedCrop) { + this.createFrozenCropOverlay(); + } + // Center image at 100% zoom this.resetView(); }; @@ -411,6 +402,12 @@ export class Editor { return; } + // Remove frozen crop overlay if it exists + if (this.konva.frozenCrop) { + this.konva.frozenCrop.layer.destroy(); + this.konva.frozenCrop = undefined; + } + this.isCropping = true; // Calculate initial crop dimensions @@ -425,9 +422,9 @@ export class Editor { cropWidth = crop.width; cropHeight = crop.height; } else if (this.appliedCrop) { - // When cropped, start with full visible area - cropX = 0; - cropY = 0; + // Use the applied crop as starting point + cropX = this.appliedCrop.x; + cropY = this.appliedCrop.y; cropWidth = this.appliedCrop.width; cropHeight = this.appliedCrop.height; } else { @@ -1122,6 +1119,109 @@ export class Editor { this.konva.crop.layer.batchDraw(); }; + private freezeCropOverlay = () => { + if (!this.konva?.crop || !this.konva?.image) { + return; + } + + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + const cropX = this.konva.crop.rect.x(); + const cropY = this.konva.crop.rect.y(); + const cropWidth = this.konva.crop.rect.width(); + const cropHeight = this.konva.crop.rect.height(); + + // Create a new frozen overlay layer + const frozenLayer = new Konva.Layer(); + const frozenOverlay = new Konva.Group(); + + // Create full overlay + const fullOverlay = new Konva.Rect({ + x: 0, + y: 0, + width: imgWidth, + height: imgHeight, + fill: 'black', + opacity: 0.7, + }); + + // Create clear rectangle for crop area + const clearRect = new Konva.Rect({ + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight, + fill: 'black', + globalCompositeOperation: 'destination-out', + }); + + frozenOverlay.add(fullOverlay); + frozenOverlay.add(clearRect); + frozenLayer.add(frozenOverlay); + + // Add frozen layer to stage + this.konva.stage.add(frozenLayer); + + // Store reference to frozen overlay + this.konva.frozenCrop = { + layer: frozenLayer, + overlay: frozenOverlay, + }; + + // Remove the interactive crop layer + this.resetEphemeralCropState(); + + frozenLayer.batchDraw(); + }; + + private createFrozenCropOverlay = () => { + if (!this.appliedCrop || !this.konva?.image) { + return; + } + + const imgWidth = this.konva.image.node.width(); + const imgHeight = this.konva.image.node.height(); + + // Create a frozen overlay layer + const frozenLayer = new Konva.Layer(); + const frozenOverlay = new Konva.Group(); + + // Create full overlay + const fullOverlay = new Konva.Rect({ + x: 0, + y: 0, + width: imgWidth, + height: imgHeight, + fill: 'black', + opacity: 0.7, + }); + + // Create clear rectangle for crop area + const clearRect = new Konva.Rect({ + x: this.appliedCrop.x, + y: this.appliedCrop.y, + width: this.appliedCrop.width, + height: this.appliedCrop.height, + fill: 'black', + globalCompositeOperation: 'destination-out', + }); + + frozenOverlay.add(fullOverlay); + frozenOverlay.add(clearRect); + frozenLayer.add(frozenOverlay); + + // Add frozen layer to stage + this.konva.stage.add(frozenLayer); + + // Store reference to frozen overlay + this.konva.frozenCrop = { + layer: frozenLayer, + overlay: frozenOverlay, + }; + + frozenLayer.batchDraw(); + }; + resetEphemeralCropState = () => { this.isCropping = false; if (this.konva?.crop) { @@ -1145,36 +1245,29 @@ export class Editor { const rect = this.konva.crop.rect; - // If there's already an applied crop, combine them - if (this.appliedCrop) { - // The new crop is relative to the already cropped image - this.appliedCrop = { - x: this.appliedCrop.x + rect.x(), - y: this.appliedCrop.y + rect.y(), - width: rect.width(), - height: rect.height(), - }; - } else { - this.appliedCrop = { - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - }; - } + // Store the crop dimensions + this.appliedCrop = { + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + }; - // Redisplay image with crop applied - this.displayImage(); + // Freeze the crop overlay instead of redisplaying image + this.freezeCropOverlay(); - this.resetEphemeralCropState(); + this.isCropping = false; this.callbacks.onCropApply?.(this.appliedCrop); }; resetCrop = () => { this.appliedCrop = null; - // Redisplay image without crop - this.displayImage(); + // Remove frozen crop overlay if it exists + if (this.konva?.frozenCrop) { + this.konva.frozenCrop.layer.destroy(); + this.konva.frozenCrop = undefined; + } this.callbacks.onCropReset?.(); }; From 03a6ddce107be6c686184bc7cd2b2a6415d16695 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:38:48 +1000 Subject: [PATCH 13/34] feat(ui): do not clear crop when canceling --- .../frontend/web/src/features/editImageModal/lib/editor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index dac131778c2..606bc44fc14 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1235,6 +1235,12 @@ export class Editor { return; } this.resetEphemeralCropState(); + + // If there's an applied crop, restore the frozen overlay + if (this.appliedCrop) { + this.createFrozenCropOverlay(); + } + this.callbacks.onCropCancel?.(); }; From e3103467ab813a43c2ffc95798a9d16c85482c32 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:39:01 +1000 Subject: [PATCH 14/34] chore(ui): lint --- .../frontend/web/src/features/editImageModal/lib/editor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 606bc44fc14..2aaa085af08 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1235,12 +1235,12 @@ export class Editor { return; } this.resetEphemeralCropState(); - + // If there's an applied crop, restore the frozen overlay if (this.appliedCrop) { this.createFrozenCropOverlay(); } - + this.callbacks.onCropCancel?.(); }; From 80584315ccc30a5292f54c8543fa0d22840f180f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:44:09 +1000 Subject: [PATCH 15/34] refactor(ui): editor init --- .../components/EditorContainer.tsx | 107 +++++++++--------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index f50b672116d..712da3e4f79 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -5,6 +5,7 @@ import type { CropBox, Editor } from 'features/editImageModal/lib/editor'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; type Props = { editor: Editor; @@ -23,12 +24,64 @@ export const EditorContainer = ({ editor, imageName }: Props) => { const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + const setup = useCallback( + async (imageDTO: ImageDTO, container: HTMLDivElement) => { + editor.init(container); + editor.setCallbacks({ + onZoomChange: (zoom) => { + setZoom(zoom); + }, + onCropStart: () => { + setCropInProgress(true); + setCropBox(null); + }, + onCropBoxChange: (crop) => { + setCropBox(crop); + }, + onCropApply: () => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }, + onCropReset: () => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }, + onCropCancel: () => { + setCropInProgress(false); + setCropBox(null); + }, + onImageLoad: () => { + // setCropInfo(''); + // setIsCropping(false); + // setHasCropBbox(false); + }, + }); + const blob = await convertImageUrlToBlob(imageDTO.image_url); + if (!blob) { + console.error('Failed to convert image to blob'); + return; + } + + await editor.loadImage(imageDTO.image_url); + editor.startCrop({ + x: 0, + y: 0, + width: imageDTO.width, + height: imageDTO.height, + }); + }, + [editor] + ); + useEffect(() => { const container = containerRef.current; - if (!container) { + if (!container || !imageDTO) { return; } editor.init(container); + setup(imageDTO, container); const handleResize = () => { editor.resize(container.clientWidth, container.clientHeight); }; @@ -37,58 +90,8 @@ export const EditorContainer = ({ editor, imageName }: Props) => { resizeObserver.observe(container); return () => { resizeObserver.disconnect(); - editor.destroy(); }; - }, [editor]); - - const loadImage = useCallback(async () => { - if (!imageDTO) { - console.error('Image not found'); - return; - } - const blob = await convertImageUrlToBlob(imageDTO.image_url); - if (!blob) { - console.error('Failed to convert image to blob'); - return; - } - await editor.loadImage(blob); - }, [editor, imageDTO]); - - // Setup callbacks - useEffect(() => { - loadImage(); - editor.setCallbacks({ - onZoomChange: (zoom) => { - setZoom(zoom); - }, - onCropStart: () => { - setCropInProgress(true); - setCropBox(null); - }, - onCropBoxChange: (crop) => { - setCropBox(crop); - }, - onCropApply: () => { - setCropApplied(true); - setCropInProgress(false); - setCropBox(null); - }, - onCropReset: () => { - setCropApplied(true); - setCropInProgress(false); - setCropBox(null); - }, - onCropCancel: () => { - setCropInProgress(false); - setCropBox(null); - }, - onImageLoad: () => { - // setCropInfo(''); - // setIsCropping(false); - // setHasCropBbox(false); - }, - }); - }, [editor, loadImage]); + }, [editor, imageDTO, setup]); const handleStartCrop = useCallback(() => { editor.startCrop(); From 320a60159279e9ad7d148a44e3318df66f5ba80d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 18:44:15 +1000 Subject: [PATCH 16/34] refactor(ui): editor (wip) --- .../src/features/editImageModal/lib/editor.ts | 1164 +++++++---------- 1 file changed, 446 insertions(+), 718 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 2aaa085af08..2bcd94ff3d2 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -1,3 +1,4 @@ +import { $crossOrigin } from 'app/store/nanostores/authToken'; import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; @@ -27,26 +28,43 @@ type EditorCallbacks = { onImageLoad?: () => void; }; +type HandleName = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left' | 'top' | 'right' | 'bottom' | 'left'; +type GuideName = 'left' | 'right' | 'top' | 'bottom'; + +// const HANDLE_INIT_COORDS: Record = { +// 'top-left': { x: 0, y: 0 }, +// 'top-right': { x: 1, y: 0 }, +// 'bottom-right': { x: 1, y: 1 }, +// 'bottom-left': { x: 0, y: 1 }, +// top: { x: 0.5, y: 0 }, +// right: { x: 1, y: 0.5 }, +// bottom: { x: 0.5, y: 1 }, +// left: { x: 0, y: 0.5 }, +// }; + type KonvaObjects = { stage: Konva.Stage; bg: { layer: Konva.Layer; - patternRect: Konva.Rect; - }; - image?: { - layer: Konva.Layer; - node: Konva.Image; + rect: Konva.Rect; }; - crop?: { + image: { layer: Konva.Layer; - rect: Konva.Rect; - overlay: Konva.Group; - handles: Konva.Group; - guides: Konva.Group; + image?: Konva.Image; }; - frozenCrop?: { + crop: { layer: Konva.Layer; - overlay: Konva.Group; + overlay: { + group: Konva.Group; + full: Konva.Rect; + clear: Konva.Rect; + }; + interaction: { + group: Konva.Group; + rect: Konva.Rect; + handles: Record; + guides: Record; + }; }; }; @@ -65,7 +83,6 @@ export class Editor { private originalImage: HTMLImageElement | null = null; private isCropping = false; private appliedCrop: CropBox | null = null; - private currentImageBlobUrl: string | null = null; // Constants private readonly MIN_CROP_DIMENSION = 64; @@ -73,6 +90,10 @@ export class Editor { private readonly ZOOM_BUTTON_FACTOR = 1.2; private readonly CROP_HANDLE_SIZE = 8; private readonly CROP_HANDLE_STROKE_WIDTH = 1; + private readonly CROP_GUIDE_STROKE = 'rgba(255, 255, 255, 0.5)'; + private readonly CROP_GUIDE_STROKE_WIDTH = 1; + private readonly CROP_HANDLE_FILL = 'white'; + private readonly CROP_HANDLE_STROKE = 'black'; private readonly FIT_TO_CONTAINER_PADDING = 0.9; private readonly DEFAULT_CROP_BOX_SCALE = 0.8; private readonly CORNER_HANDLE_NAMES = ['top-left', 'top-right', 'bottom-right', 'bottom-left']; @@ -87,6 +108,7 @@ export class Editor { minHeight: this.MIN_CROP_DIMENSION, }; private callbacks: EditorCallbacks = {}; + private cropBox: CropBox | null = null; // State private isPanning = false; @@ -102,29 +124,288 @@ export class Editor { height: container.clientHeight, }); - const bgLayer = new Konva.Layer(); - const bgPatternRect = new Konva.Rect(); - bgLayer.add(bgPatternRect); - const bgImage = new Image(); - bgImage.onload = () => { - bgPatternRect.fillPatternImage(bgImage); - this.renderBg(); - }; - bgImage.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; + const bg = this.createKonvaBgObjects(); + const image = this.createKonvaImageObjects(); + const crop = this.createKonvaCropObjects(); - stage.add(bgLayer); + stage.add(bg.layer); + stage.add(image.layer); + stage.add(crop.layer); this.konva = { stage, - bg: { - layer: bgLayer, - patternRect: bgPatternRect, - }, + bg, + image, + crop, }; - // Setup mouse event handlers + this.setupStageEvents(); }; + createKonvaBgObjects = (): KonvaObjects['bg'] => { + const layer = new Konva.Layer(); + const rect = new Konva.Rect(); + const image = new Image(); + image.onload = () => { + rect.fillPatternImage(image); + this.renderBg(); + }; + image.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; + layer.add(rect); + + return { + layer, + rect, + }; + }; + + createKonvaImageObjects = (): KonvaObjects['image'] => { + const layer = new Konva.Layer(); + return { + layer, + }; + }; + + createKonvaCropObjects = (): KonvaObjects['crop'] => { + const layer = new Konva.Layer(); + const overlay = this.createKonvaCropOverlayObjects(); + const interaction = this.createKonvaCropInteractionObjects(); + layer.add(overlay.group); + layer.add(interaction.group); + return { + layer, + overlay, + interaction, + }; + }; + createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => { + const group = new Konva.Group(); + const full = new Konva.Rect({ + fill: 'black', + opacity: 0.7, + }); + const clear = new Konva.Rect({ + fill: 'black', + globalCompositeOperation: 'destination-out', + }); + group.add(full); + group.add(clear); + return { + group, + full, + clear, + }; + }; + + createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { + const group = new Konva.Group(); + + const rect = this.createCropInteractionRect(); + const handles = { + 'top-left': this.createHandle('top-left'), + 'top-right': this.createHandle('top-right'), + 'bottom-right': this.createHandle('bottom-right'), + 'bottom-left': this.createHandle('bottom-left'), + top: this.createHandle('top'), + right: this.createHandle('right'), + bottom: this.createHandle('bottom'), + left: this.createHandle('left'), + }; + const guides = { + left: this.createGuide('left'), + right: this.createGuide('right'), + top: this.createGuide('top'), + bottom: this.createGuide('bottom'), + }; + + group.add(rect); + + for (const handle of Object.values(handles)) { + group.add(handle); + } + for (const guide of Object.values(guides)) { + group.add(guide); + } + + return { + group, + rect, + handles, + guides, + }; + }; + + createCropInteractionRect = (): Konva.Rect => { + const rect = new Konva.Rect({ + stroke: 'white', + strokeWidth: 1, + strokeScaleEnabled: false, + draggable: true, + }); + + // Prevent crop box dragging when panning + rect.on('dragstart', (e) => { + if (this.isSpacePressed || this.isPanning) { + e.target.stopDrag(); + return false; + } + }); + + // Crop box dragging + rect.on('dragmove', () => { + if (!this.konva?.image.image || !this.cropBox) { + return; + } + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); + + // Constrain to image bounds + const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); + const y = Math.max(0, Math.min(rect.y(), imgHeight - rect.height())); + + rect.x(x); + rect.y(y); + + this.updateCropBox({ + ...this.cropBox, + x, + y, + }); + }); + + // Cursor styles + rect.on('mouseenter', () => { + const stage = this.konva?.stage; + if (!stage) { + return; + } + if (!this.isSpacePressed) { + stage.container().style.cursor = 'move'; + } + }); + + rect.on('mouseleave', () => { + const stage = this.konva?.stage; + if (!stage) { + return; + } + if (!this.isSpacePressed) { + stage.container().style.cursor = 'default'; + } + }); + + return rect; + }; + + updateCropInteractionRect = () => { + if (!this.konva || !this.cropBox) { + return; + } + this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox }); + }; + + createGuide = (name: GuideName): Konva.Line => { + const line = new Konva.Line({ + name, + stroke: this.CROP_GUIDE_STROKE, + strokeWidth: this.CROP_GUIDE_STROKE_WIDTH, + strokeScaleEnabled: false, + listening: false, + }); + + return line; + }; + + updateCropGuides = () => { + if (!this.konva || !this.cropBox) { + return; + } + + const { x, y, width, height } = this.cropBox; + + const verticalThird = width / 3; + this.konva.crop.interaction.guides.left.points([x + verticalThird, y, x + verticalThird, y + height]); + this.konva.crop.interaction.guides.right.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]); + + const horizontalThird = height / 3; + this.konva.crop.interaction.guides.top.points([x, y + horizontalThird, x + width, y + horizontalThird]); + this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); + }; + + createHandle = (name: HandleName): Konva.Rect => { + const rect = new Konva.Rect({ + name, + x: 0, + y: 0, + width: this.CROP_HANDLE_SIZE, + height: this.CROP_HANDLE_SIZE, + fill: this.CROP_HANDLE_FILL, + stroke: this.CROP_HANDLE_STROKE, + strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, + strokeScaleEnabled: true, + draggable: true, + }); + + // Prevent handle dragging when panning + rect.on('dragstart', () => { + const stage = this.konva?.stage; + if (!stage) { + return; + } + if (stage.isDragging()) { + rect.stopDrag(); + return false; + } + }); + + // Set cursor based on handle type + rect.on('mouseenter', () => { + const stage = this.konva?.stage; + if (!stage) { + return; + } + if (!stage.isDragging()) { + let cursor = 'pointer'; + if (name === 'top-left' || name === 'bottom-right') { + cursor = 'nwse-resize'; + } else if (name === 'top-right' || name === 'bottom-left') { + cursor = 'nesw-resize'; + } else if (name === 'top' || name === 'bottom') { + cursor = 'ns-resize'; + } else if (name === 'left' || name === 'right') { + cursor = 'ew-resize'; + } + stage.container().style.cursor = cursor; + } + }); + + rect.on('mouseleave', () => { + const stage = this.konva?.stage; + if (!stage) { + return; + } + if (!stage.isDragging()) { + stage.container().style.cursor = 'default'; + } + }); + + // Handle dragging + rect.on('dragmove', () => { + this.resizeCropBox(rect); + }); + + return rect; + }; + + updateCropBox = (cropBox: CropBox) => { + this.cropBox = cropBox; + this.updateCropInteractionRect(); + this.updateCropOverlay(); + this.updateCropGuides(); + this.updateHandlePositions(); + this.callbacks.onCropBoxChange?.(cropBox); + }; + renderBg = () => { if (!this.konva) { return; @@ -134,7 +415,7 @@ export class Editor { const { x, y } = this.konva.stage.getPosition(); const { width, height } = this.konva.stage.size(); - this.konva.bg.patternRect.setAttrs({ + this.konva.bg.rect.setAttrs({ visible: true, x: Math.floor(-x / scale), y: Math.floor(-y / scale), @@ -263,14 +544,14 @@ export class Editor { // Stop any active drags on crop elements if (this.konva.crop) { - if (this.konva.crop.rect.isDragging()) { - this.konva.crop.rect.stopDrag(); + if (this.konva.crop.interaction.rect.isDragging()) { + this.konva.crop.interaction.rect.stopDrag(); } - this.konva.crop.handles.children.forEach((handle) => { + for (const handle of Object.values(this.konva.crop.interaction.handles)) { if (handle.isDragging()) { handle.stopDrag(); } - }); + } } } }; @@ -313,20 +594,11 @@ export class Editor { onContextMenu = (e: MouseEvent) => e.preventDefault(); // Image Management - loadImage = (src: string | File | Blob): Promise => { + loadImage = (src: string): Promise => { return new Promise((resolve, reject) => { - // Clean up previous blob URL if it exists - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = null; - } - const img = new Image(); - // Set crossOrigin to avoid CORS issues when exporting - if (typeof src === 'string') { - img.crossOrigin = 'anonymous'; - } + img.crossOrigin = $crossOrigin.get(); img.onload = () => { this.originalImage = img; @@ -336,21 +608,10 @@ export class Editor { }; img.onerror = () => { - // Clean up blob URL on error - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = null; - } reject(new Error('Failed to load image')); }; - if (typeof src === 'string') { - img.src = src; - } else if (src instanceof File || src instanceof Blob) { - const url = URL.createObjectURL(src); - this.currentImageBlobUrl = url; - img.src = url; - } + img.src = src; }); }; @@ -360,14 +621,11 @@ export class Editor { } // Clear existing image - if (this.konva.image) { - this.konva.image.node.destroy(); - this.konva.image.layer.destroy(); - this.konva.image = undefined; + if (this.konva.image.image) { + this.konva.image.image.destroy(); + this.konva.image.image = undefined; } - // Create image layer and node - always show full image - const imageLayer = new Konva.Layer(); const imageNode = new Konva.Image({ image: this.originalImage, x: 0, @@ -376,21 +634,8 @@ export class Editor { height: this.originalImage.height, }); - imageLayer.add(imageNode); - this.konva.stage.add(imageLayer); - - // Store references - this.konva.image = { - layer: imageLayer, - node: imageNode, - }; - - imageLayer.batchDraw(); - - // If there's an applied crop, create frozen overlay - if (this.appliedCrop) { - this.createFrozenCropOverlay(); - } + this.konva.image.image = imageNode; + this.konva.image.layer.add(imageNode); // Center image at 100% zoom this.resetView(); @@ -398,16 +643,11 @@ export class Editor { // Crop Mode startCrop = (crop?: CropBox) => { - if (!this.konva?.image || this.isCropping) { + if (!this.konva?.image.image || this.isCropping) { return; } - // Remove frozen crop overlay if it exists - if (this.konva.frozenCrop) { - this.konva.frozenCrop.layer.destroy(); - this.konva.frozenCrop = undefined; - } - + this.unfreezeCropOverlay(); this.isCropping = true; // Calculate initial crop dimensions @@ -429,337 +669,29 @@ export class Editor { cropHeight = this.appliedCrop.height; } else { // Create default crop box (centered, 80% of image) - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); cropWidth = imgWidth * this.DEFAULT_CROP_BOX_SCALE; cropHeight = imgHeight * this.DEFAULT_CROP_BOX_SCALE; cropX = (imgWidth - cropWidth) / 2; cropY = (imgHeight - cropHeight) / 2; } - this.createCropBox(cropX, cropY, cropWidth, cropHeight); - - this.callbacks.onCropStart?.(); - this.callbacks.onCropBoxChange?.({ + this.updateCropBox({ x: cropX, y: cropY, width: cropWidth, height: cropHeight, }); - }; - - private createCropBox = (x: number, y: number, width: number, height: number) => { - if (!this.konva?.image) { - return; - } - - // Clear existing crop if any - if (this.konva.crop) { - this.konva.crop.layer.destroy(); - this.konva.crop = undefined; - } - - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - // Create crop layer - const cropLayer = new Konva.Layer(); - - // Create overlay group with composite operation - const overlay = new Konva.Group(); - - // Create full overlay - const fullOverlay = new Konva.Rect({ - x: 0, - y: 0, - width: imgWidth, - height: imgHeight, - fill: 'black', - opacity: 0.7, - }); - - // Create clear rectangle for crop area using composite operation - const clearRect = new Konva.Rect({ - x: x, - y: y, - width: width, - height: height, - fill: 'black', - globalCompositeOperation: 'destination-out', - }); - - overlay.add(fullOverlay); - overlay.add(clearRect); - - // Create crop rectangle - const rect = new Konva.Rect({ - x: x, - y: y, - width: width, - height: height, - stroke: 'white', - strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, - strokeScaleEnabled: false, - draggable: true, - }); - - // Create handles group - const handles = new Konva.Group(); - - // Create guides group - const guides = new Konva.Group(); - - // Store all crop objects together - this.konva.crop = { - layer: cropLayer, - rect: rect, - overlay: overlay, - handles: handles, - guides: guides, - }; - - // Create handles and guides - this.createCropHandles(); - this.createCropGuides(); - - // Setup crop box events - this.setupCropBoxEvents(); - - // Add to layer - cropLayer.add(overlay); - cropLayer.add(rect); - cropLayer.add(guides); - cropLayer.add(handles); - // Add layer to stage - this.konva.stage.add(cropLayer); - - // Apply current scale to handles - this.updateHandleScale(); - - cropLayer.batchDraw(); - }; - - private createCropGuides = () => { - if (!this.konva?.crop) { - return; - } - - const rect = this.konva.crop.rect; - const guides = this.konva.crop.guides; - - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const guideConfig = { - stroke: 'rgba(255, 255, 255, 0.5)', - strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, - strokeScaleEnabled: false, - listening: false, - }; - - // Vertical lines (thirds) - const verticalThird = width / 3; - guides.add( - new Konva.Line({ - points: [x + verticalThird, y, x + verticalThird, y + height], - ...guideConfig, - }) - ); - guides.add( - new Konva.Line({ - points: [x + verticalThird * 2, y, x + verticalThird * 2, y + height], - ...guideConfig, - }) - ); - - // Horizontal lines (thirds) - const horizontalThird = height / 3; - guides.add( - new Konva.Line({ - points: [x, y + horizontalThird, x + width, y + horizontalThird], - ...guideConfig, - }) - ); - guides.add( - new Konva.Line({ - points: [x, y + horizontalThird * 2, x + width, y + horizontalThird * 2], - ...guideConfig, - }) - ); - }; - - private createCropHandles = () => { - if (!this.konva?.crop) { - return; - } - - const rect = this.konva.crop.rect; - const handles = this.konva.crop.handles; - const scale = this.konva.stage.scaleX(); - const handleSize = this.CROP_HANDLE_SIZE / scale; - const handleConfig = { - width: handleSize, - height: handleSize, - fill: 'white', - stroke: 'black', - strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, - strokeScaleEnabled: true, - }; - - // Corner handles - const corners = [ - { name: 'top-left', x: 0, y: 0 }, - { name: 'top-right', x: 1, y: 0 }, - { name: 'bottom-right', x: 1, y: 1 }, - { name: 'bottom-left', x: 0, y: 1 }, - ]; - - corners.forEach((corner) => { - const handle = new Konva.Rect({ - ...handleConfig, - name: corner.name, - x: rect.x() + corner.x * rect.width() - handleSize / 2, - y: rect.y() + corner.y * rect.height() - handleSize / 2, - draggable: true, - }); - - this.setupHandleEvents(handle); - handles.add(handle); - }); - - // Edge handles - const edges = [ - { name: 'top', x: 0.5, y: 0 }, - { name: 'right', x: 1, y: 0.5 }, - { name: 'bottom', x: 0.5, y: 1 }, - { name: 'left', x: 0, y: 0.5 }, - ]; - - edges.forEach((edge) => { - const handle = new Konva.Rect({ - ...handleConfig, - name: edge.name, - x: rect.x() + edge.x * rect.width() - handleSize / 2, - y: rect.y() + edge.y * rect.height() - handleSize / 2, - draggable: true, - }); - - this.setupHandleEvents(handle); - handles.add(handle); - }); - }; - - private setupCropBoxEvents = () => { - if (!this.konva?.crop) { - return; - } - const stage = this.konva.stage; - const rect = this.konva.crop.rect; - const image = this.konva.image; - if (!image) { - return; - } - - // Prevent crop box dragging when panning - rect.on('dragstart', (e) => { - if (this.isSpacePressed || this.isPanning) { - e.target.stopDrag(); - return false; - } - }); - - // Crop box dragging - rect.on('dragmove', () => { - const imgWidth = image.node.width(); - const imgHeight = image.node.height(); - - // Constrain to image bounds - const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); - const y = Math.max(0, Math.min(rect.y(), imgHeight - rect.height())); - - rect.x(x); - rect.y(y); - - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - this.callbacks.onCropBoxChange?.({ - x, - y, - width: rect.width(), - height: rect.height(), - }); - }); - - // Cursor styles - rect.on('mouseenter', () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = 'move'; - } - }); - - rect.on('mouseleave', () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = 'default'; - } - }); - }; - - private setupHandleEvents = (handle: Konva.Rect) => { - if (!this.konva) { - return; - } - const stage = this.konva.stage; - const handleName = handle.name(); - - // Prevent handle dragging when panning - handle.on('dragstart', (e) => { - if (this.isSpacePressed || this.isPanning) { - e.target.stopDrag(); - return false; - } - }); - - // Set cursor based on handle type - handle.on('mouseenter', () => { - if (!this.isSpacePressed) { - let cursor = 'pointer'; - if (handleName.includes('top-left') || handleName.includes('bottom-right')) { - cursor = 'nwse-resize'; - } else if (handleName.includes('top-right') || handleName.includes('bottom-left')) { - cursor = 'nesw-resize'; - } else if (handleName.includes('top') || handleName.includes('bottom')) { - cursor = 'ns-resize'; - } else if (handleName.includes('left') || handleName.includes('right')) { - cursor = 'ew-resize'; - } - stage.container().style.cursor = cursor; - } - }); - - handle.on('mouseleave', () => { - if (!this.isSpacePressed) { - stage.container().style.cursor = 'default'; - } - }); - - // Handle dragging - handle.on('dragmove', () => { - this.resizeCropBox(handle); - }); + this.callbacks.onCropStart?.(); }; private resizeCropBox = (handle: Konva.Rect) => { - if (!this.konva?.crop || !this.konva?.image) { + if (!this.konva) { return; } - const rect = this.konva.crop.rect; - let { newX, newY, newWidth, newHeight } = this.cropConstraints.aspectRatio ? this._resizeCropBoxWithAspectRatio(handle) : this._resizeCropBoxFree(handle); @@ -772,21 +704,7 @@ export class Editor { newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); } - // Update crop rect - rect.x(newX); - rect.y(newY); - rect.width(newWidth); - rect.height(newHeight); - - // Update overlay, handles, and guides - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - // Reset handle position to follow crop box - this.positionHandle(handle); - - this.callbacks.onCropBoxChange?.({ + this.updateCropBox({ x: newX, y: newY, width: newWidth, @@ -795,13 +713,13 @@ export class Editor { }; private _resizeCropBoxFree = (handle: Konva.Rect) => { - if (!this.konva?.crop || !this.konva?.image) { + if (!this.konva?.image.image) { throw new Error('Crop box or image not found'); } - const rect = this.konva.crop.rect; + const rect = this.konva.crop.overlay.clear; const handleName = handle.name(); - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); let newX = rect.x(); let newY = rect.y(); @@ -836,13 +754,13 @@ export class Editor { }; private _resizeCropBoxWithAspectRatio = (handle: Konva.Rect) => { - if (!this.konva?.crop || !this.konva?.image || !this.cropConstraints.aspectRatio) { + if (!this.konva?.image.image || !this.cropConstraints.aspectRatio) { throw new Error('Crop box, image, or aspect ratio not found'); } - const rect = this.konva.crop.rect; + const rect = this.konva.crop.overlay.clear; const handleName = handle.name(); - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); const ratio = this.cropConstraints.aspectRatio; const handleX = handle.x() + handle.width() / 2; @@ -980,113 +898,58 @@ export class Editor { }; private positionHandle = (handle: Konva.Rect) => { - if (!this.konva?.crop) { + if (!this.konva || !this.cropBox) { return; } - const rect = this.konva.crop.rect; + const { x, y, width, height } = this.cropBox; const handleName = handle.name(); const handleSize = handle.width(); - let x = rect.x(); - let y = rect.y(); + let handleX = x; + let handleY = y; if (handleName.includes('right')) { - x += rect.width(); + handleX += width; } else if (!handleName.includes('left')) { - x += rect.width() / 2; + handleX += width / 2; } if (handleName.includes('bottom')) { - y += rect.height(); + handleY += height; } else if (!handleName.includes('top')) { - y += rect.height() / 2; + handleY += height / 2; } - handle.x(x - handleSize / 2); - handle.y(y - handleSize / 2); + handle.x(handleX - handleSize / 2); + handle.y(handleY - handleSize / 2); }; private updateHandlePositions = () => { - if (!this.konva?.crop) { - return; - } - - this.konva.crop.handles.children.forEach((handle) => { - if (handle instanceof Konva.Rect) { - this.positionHandle(handle); - } - }); - }; - - private updateCropGuides = () => { - if (!this.konva?.crop) { - return; - } - - const rect = this.konva.crop.rect; - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const lines = this.konva.crop.guides.children; - if (lines.length < 4) { + if (!this.konva) { return; } - // Update vertical lines - const verticalThird = width / 3; - const line0 = lines[0]; - const line1 = lines[1]; - if (line0 instanceof Konva.Line) { - line0.points([x + verticalThird, y, x + verticalThird, y + height]); - } - if (line1 instanceof Konva.Line) { - line1.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]); - } - - // Update horizontal lines - const horizontalThird = height / 3; - const line2 = lines[2]; - const line3 = lines[3]; - if (line2 instanceof Konva.Line) { - line2.points([x, y + horizontalThird, x + width, y + horizontalThird]); - } - if (line3 instanceof Konva.Line) { - line3.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); + for (const handle of Object.values(this.konva.crop.interaction.handles)) { + this.positionHandle(handle); } }; private updateCropOverlay = () => { - if (!this.konva?.crop) { + if (!this.konva?.image.image || !this.cropBox) { return; } - const rect = this.konva.crop.rect; - const x = rect.x(); - const y = rect.y(); - const width = rect.width(); - const height = rect.height(); - - const nodes = this.konva.crop.overlay.children; - - // Update clear rectangle position (the cutout) - if (nodes.length > 1) { - const clearRect = nodes[1]; - if (clearRect instanceof Konva.Rect) { - clearRect.x(x); - clearRect.y(y); - clearRect.width(width); - clearRect.height(height); - } - } + this.konva.crop.overlay.full.setAttrs({ + ...this.konva.image.image.getPosition(), + ...this.konva.image.image.getSize(), + }); - this.konva.crop.layer.batchDraw(); + this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); }; private updateHandleScale = () => { - if (!this.konva?.crop) { + if (!this.konva) { return; } @@ -1094,140 +957,44 @@ export class Editor { const handleSize = this.CROP_HANDLE_SIZE / scale; const strokeWidth = this.CROP_HANDLE_STROKE_WIDTH / scale; - // Update each handle's size and stroke to maintain constant screen size - this.konva.crop.handles.children.forEach((handle) => { - if (handle instanceof Konva.Rect) { - const currentX = handle.x(); - const currentY = handle.y(); - const oldSize = handle.width(); - - // Calculate center position - const centerX = currentX + oldSize / 2; - const centerY = currentY + oldSize / 2; - - // Update size and stroke - handle.width(handleSize); - handle.height(handleSize); - handle.strokeWidth(strokeWidth); - - // Reposition to maintain center - handle.x(centerX - handleSize / 2); - handle.y(centerY - handleSize / 2); - } - }); + for (const handle of Object.values(this.konva.crop.interaction.handles)) { + const currentX = handle.x(); + const currentY = handle.y(); + const oldSize = handle.width(); + + // Calculate center position + const centerX = currentX + oldSize / 2; + const centerY = currentY + oldSize / 2; + + // Update size and stroke + handle.width(handleSize); + handle.height(handleSize); + handle.strokeWidth(strokeWidth); - this.konva.crop.layer.batchDraw(); + // Reposition to maintain center + handle.x(centerX - handleSize / 2); + handle.y(centerY - handleSize / 2); + } }; private freezeCropOverlay = () => { - if (!this.konva?.crop || !this.konva?.image) { + if (!this.konva) { return; } - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - const cropX = this.konva.crop.rect.x(); - const cropY = this.konva.crop.rect.y(); - const cropWidth = this.konva.crop.rect.width(); - const cropHeight = this.konva.crop.rect.height(); - - // Create a new frozen overlay layer - const frozenLayer = new Konva.Layer(); - const frozenOverlay = new Konva.Group(); - - // Create full overlay - const fullOverlay = new Konva.Rect({ - x: 0, - y: 0, - width: imgWidth, - height: imgHeight, - fill: 'black', - opacity: 0.7, - }); - - // Create clear rectangle for crop area - const clearRect = new Konva.Rect({ - x: cropX, - y: cropY, - width: cropWidth, - height: cropHeight, - fill: 'black', - globalCompositeOperation: 'destination-out', - }); - - frozenOverlay.add(fullOverlay); - frozenOverlay.add(clearRect); - frozenLayer.add(frozenOverlay); - - // Add frozen layer to stage - this.konva.stage.add(frozenLayer); - - // Store reference to frozen overlay - this.konva.frozenCrop = { - layer: frozenLayer, - overlay: frozenOverlay, - }; - - // Remove the interactive crop layer - this.resetEphemeralCropState(); - - frozenLayer.batchDraw(); + this.konva.crop.interaction.group.visible(false); }; - private createFrozenCropOverlay = () => { - if (!this.appliedCrop || !this.konva?.image) { + private unfreezeCropOverlay = () => { + if (!this.konva) { return; } - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - // Create a frozen overlay layer - const frozenLayer = new Konva.Layer(); - const frozenOverlay = new Konva.Group(); - - // Create full overlay - const fullOverlay = new Konva.Rect({ - x: 0, - y: 0, - width: imgWidth, - height: imgHeight, - fill: 'black', - opacity: 0.7, - }); - - // Create clear rectangle for crop area - const clearRect = new Konva.Rect({ - x: this.appliedCrop.x, - y: this.appliedCrop.y, - width: this.appliedCrop.width, - height: this.appliedCrop.height, - fill: 'black', - globalCompositeOperation: 'destination-out', - }); - - frozenOverlay.add(fullOverlay); - frozenOverlay.add(clearRect); - frozenLayer.add(frozenOverlay); - - // Add frozen layer to stage - this.konva.stage.add(frozenLayer); - - // Store reference to frozen overlay - this.konva.frozenCrop = { - layer: frozenLayer, - overlay: frozenOverlay, - }; - - frozenLayer.batchDraw(); + this.konva.crop.interaction.group.visible(true); }; resetEphemeralCropState = () => { this.isCropping = false; - if (this.konva?.crop) { - this.konva.crop.layer.destroy(); - this.konva.crop = undefined; - } }; cancelCrop = () => { @@ -1236,28 +1003,16 @@ export class Editor { } this.resetEphemeralCropState(); - // If there's an applied crop, restore the frozen overlay - if (this.appliedCrop) { - this.createFrozenCropOverlay(); - } - this.callbacks.onCropCancel?.(); }; applyCrop = () => { - if (!this.isCropping || !this.konva?.crop) { + if (!this.isCropping || !this.cropBox) { return; } - const rect = this.konva.crop.rect; - // Store the crop dimensions - this.appliedCrop = { - x: rect.x(), - y: rect.y(), - width: rect.width(), - height: rect.height(), - }; + this.appliedCrop = { ...this.cropBox }; // Freeze the crop overlay instead of redisplaying image this.freezeCropOverlay(); @@ -1269,12 +1024,6 @@ export class Editor { resetCrop = () => { this.appliedCrop = null; - // Remove frozen crop overlay if it exists - if (this.konva?.frozenCrop) { - this.konva.frozenCrop.layer.destroy(); - this.konva.frozenCrop = undefined; - } - this.callbacks.onCropReset?.(); }; @@ -1408,7 +1157,7 @@ export class Editor { }; resetView = () => { - if (!this.konva?.image) { + if (!this.konva?.image.image) { return; } @@ -1417,8 +1166,8 @@ export class Editor { // Center the image const containerWidth = this.konva.stage.width(); const containerHeight = this.konva.stage.height(); - const imageWidth = this.konva.image.node.width(); - const imageHeight = this.konva.image.node.height(); + const imageWidth = this.konva.image.image.width(); + const imageHeight = this.konva.image.image.height(); this.konva.stage.position({ x: (containerWidth - imageWidth) / 2, @@ -1434,14 +1183,14 @@ export class Editor { }; fitToContainer = () => { - if (!this.konva?.image) { + if (!this.konva?.image?.image) { return; } const containerWidth = this.konva.stage.width(); const containerHeight = this.konva.stage.height(); - const imageWidth = this.konva.image.node.width(); - const imageHeight = this.konva.image.node.height(); + const imageWidth = this.konva.image.image.width(); + const imageHeight = this.konva.image.image.height(); const scale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.FIT_TO_CONTAINER_PADDING; @@ -1477,86 +1226,71 @@ export class Editor { // Update the constraint this.cropConstraints.aspectRatio = ratio; - // If we're currently cropping, adjust the crop box to match the new ratio - if (this.isCropping && this.konva?.crop && this.konva?.image) { - const rect = this.konva.crop.rect; - const currentWidth = rect.width(); - const currentHeight = rect.height(); - const currentArea = currentWidth * currentHeight; + if (!this.konva?.image.image || !this.cropBox) { + return; + } - if (ratio === undefined) { - // Just removed the aspect ratio constraint, no need to adjust - return; - } + const currentWidth = this.cropBox.width; + const currentHeight = this.cropBox.height; + const currentArea = currentWidth * currentHeight; - // Calculate new dimensions maintaining the same area - // area = width * height - // ratio = width / height - // So: area = width * (width / ratio) - // Therefore: width = sqrt(area * ratio) - let newWidth = Math.sqrt(currentArea * ratio); - let newHeight = newWidth / ratio; - - // Get image bounds - const imgWidth = this.konva.image.node.width(); - const imgHeight = this.konva.image.node.height(); - - // Check if the new dimensions would exceed image bounds - if (newWidth > imgWidth || newHeight > imgHeight) { - // Scale down to fit within image bounds while maintaining ratio - const scaleX = imgWidth / newWidth; - const scaleY = imgHeight / newHeight; - const scale = Math.min(scaleX, scaleY); - newWidth *= scale; - newHeight *= scale; - } + if (ratio === undefined) { + // Just removed the aspect ratio constraint, no need to adjust + return; + } - // Apply minimum size constraints - const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; + // Calculate new dimensions maintaining the same area + // area = width * height + // ratio = width / height + // So: area = width * (width / ratio) + // Therefore: width = sqrt(area * ratio) + let newWidth = Math.sqrt(currentArea * ratio); + let newHeight = newWidth / ratio; - if (newWidth < minWidth) { - newWidth = minWidth; - newHeight = newWidth / ratio; - } - if (newHeight < minHeight) { - newHeight = minHeight; - newWidth = newHeight * ratio; - } + // Get image bounds + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); - // Center the new crop box at the same position as the old one - const currentCenterX = rect.x() + currentWidth / 2; - const currentCenterY = rect.y() + currentHeight / 2; - - let newX = currentCenterX - newWidth / 2; - let newY = currentCenterY - newHeight / 2; - - // Ensure the crop box stays within image bounds - newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); - newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); - - // Update the crop box - rect.x(newX); - rect.y(newY); - rect.width(newWidth); - rect.height(newHeight); - - // Update all visual elements - this.updateCropOverlay(); - this.updateHandlePositions(); - this.updateCropGuides(); - - // Notify callback - this.callbacks.onCropBoxChange?.({ - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }); + // Check if the new dimensions would exceed image bounds + if (newWidth > imgWidth || newHeight > imgHeight) { + // Scale down to fit within image bounds while maintaining ratio + const scaleX = imgWidth / newWidth; + const scaleY = imgHeight / newHeight; + const scale = Math.min(scaleX, scaleY); + newWidth *= scale; + newHeight *= scale; + } - // Force a redraw - this.konva.crop.layer.batchDraw(); + // Apply minimum size constraints + const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; + + if (newWidth < minWidth) { + newWidth = minWidth; + newHeight = newWidth / ratio; } + if (newHeight < minHeight) { + newHeight = minHeight; + newWidth = newHeight * ratio; + } + + // Center the new crop box at the same position as the old one + const currentCenterX = this.cropBox.x + currentWidth / 2; + const currentCenterY = this.cropBox.y + currentHeight / 2; + + let newX = currentCenterX - newWidth / 2; + let newY = currentCenterY - newHeight / 2; + + // Ensure the crop box stays within image bounds + newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); + newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); + + this.updateCropBox({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); }; getCropAspectRatio = (): number | undefined => { @@ -1580,12 +1314,6 @@ export class Editor { unsubscribe(); } - // Clean up blob URL if it exists - if (this.currentImageBlobUrl) { - URL.revokeObjectURL(this.currentImageBlobUrl); - this.currentImageBlobUrl = null; - } - // Cancel any ongoing crop operation if (this.isCropping) { this.cancelCrop(); From d702958af1674942d2cba40973afe24925416cb8 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:01:12 +1000 Subject: [PATCH 17/34] refactor(ui): editor (wip) --- .../src/features/editImageModal/lib/editor.ts | 99 +++++++++---------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 2bcd94ff3d2..a83689ef2ba 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -2,6 +2,7 @@ import { $crossOrigin } from 'app/store/nanostores/authToken'; import { TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL } from 'features/controlLayers/konva/patterns/transparency-checkerboard-pattern'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; +import { objectEntries } from 'tsafe'; type CropConstraints = { minWidth?: number; @@ -29,18 +30,8 @@ type EditorCallbacks = { }; type HandleName = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left' | 'top' | 'right' | 'bottom' | 'left'; -type GuideName = 'left' | 'right' | 'top' | 'bottom'; -// const HANDLE_INIT_COORDS: Record = { -// 'top-left': { x: 0, y: 0 }, -// 'top-right': { x: 1, y: 0 }, -// 'bottom-right': { x: 1, y: 1 }, -// 'bottom-left': { x: 0, y: 1 }, -// top: { x: 0.5, y: 0 }, -// right: { x: 1, y: 0.5 }, -// bottom: { x: 0.5, y: 1 }, -// left: { x: 0, y: 0.5 }, -// }; +type GuideName = 'left' | 'right' | 'top' | 'bottom'; type KonvaObjects = { stage: Konva.Stage; @@ -96,8 +87,6 @@ export class Editor { private readonly CROP_HANDLE_STROKE = 'black'; private readonly FIT_TO_CONTAINER_PADDING = 0.9; private readonly DEFAULT_CROP_BOX_SCALE = 0.8; - private readonly CORNER_HANDLE_NAMES = ['top-left', 'top-right', 'bottom-right', 'bottom-left']; - private readonly EDGE_HANDLE_NAMES = ['top', 'right', 'bottom', 'left']; // Configuration private readonly ZOOM_MIN = 0.1; @@ -391,7 +380,7 @@ export class Editor { // Handle dragging rect.on('dragmove', () => { - this.resizeCropBox(rect); + this.resizeCropBox(name, rect); }); return rect; @@ -687,14 +676,14 @@ export class Editor { this.callbacks.onCropStart?.(); }; - private resizeCropBox = (handle: Konva.Rect) => { + private resizeCropBox = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva) { return; } let { newX, newY, newWidth, newHeight } = this.cropConstraints.aspectRatio - ? this._resizeCropBoxWithAspectRatio(handle) - : this._resizeCropBoxFree(handle); + ? this._resizeCropBoxWithAspectRatio(handleName, handleRect) + : this._resizeCropBoxFree(handleName, handleRect); // Apply general constraints if (this.cropConstraints.maxWidth) { @@ -712,12 +701,11 @@ export class Editor { }); }; - private _resizeCropBoxFree = (handle: Konva.Rect) => { + private _resizeCropBoxFree = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva?.image.image) { throw new Error('Crop box or image not found'); } const rect = this.konva.crop.overlay.clear; - const handleName = handle.name(); const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); @@ -726,8 +714,8 @@ export class Editor { let newWidth = rect.width(); let newHeight = rect.height(); - const handleX = handle.x() + handle.width() / 2; - const handleY = handle.y() + handle.height() / 2; + const handleX = handleRect.x() + handleRect.width() / 2; + const handleY = handleRect.y() + handleRect.height() / 2; const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; @@ -753,47 +741,56 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; - private _resizeCropBoxWithAspectRatio = (handle: Konva.Rect) => { - if (!this.konva?.image.image || !this.cropConstraints.aspectRatio) { + private _resizeCropBoxWithAspectRatio = (handleName: HandleName, handleRect: Konva.Rect) => { + if (!this.konva?.image.image || !this.cropConstraints.aspectRatio || !this.cropBox) { throw new Error('Crop box, image, or aspect ratio not found'); } - const rect = this.konva.crop.overlay.clear; - const handleName = handle.name(); const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); const ratio = this.cropConstraints.aspectRatio; - const handleX = handle.x() + handle.width() / 2; - const handleY = handle.y() + handle.height() / 2; + const handleX = handleRect.x() + handleRect.width() / 2; + const handleY = handleRect.y() + handleRect.height() / 2; const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; // Early boundary check for aspect ratio mode - const atLeftEdge = rect.x() <= 0; - const atRightEdge = rect.x() + rect.width() >= imgWidth; - const atTopEdge = rect.y() <= 0; - const atBottomEdge = rect.y() + rect.height() >= imgHeight; + const atLeftEdge = this.cropBox.x <= 0; + const atRightEdge = this.cropBox.x + this.cropBox.width >= imgWidth; + const atTopEdge = this.cropBox.y <= 0; + const atBottomEdge = this.cropBox.y + this.cropBox.height >= imgHeight; if ( - (handleName === 'left' && atLeftEdge && handleX >= rect.x()) || - (handleName === 'right' && atRightEdge && handleX <= rect.x() + rect.width()) || - (handleName === 'top' && atTopEdge && handleY >= rect.y()) || - (handleName === 'bottom' && atBottomEdge && handleY <= rect.y() + rect.height()) + (handleName === 'left' && atLeftEdge && handleX < this.cropBox.x) || + (handleName === 'right' && atRightEdge && handleX > this.cropBox.x + this.cropBox.width) || + (handleName === 'top' && atTopEdge && handleY < this.cropBox.y) || + (handleName === 'bottom' && atBottomEdge && handleY > this.cropBox.y + this.cropBox.height) ) { - return { newX: rect.x(), newY: rect.y(), newWidth: rect.width(), newHeight: rect.height() }; + return { + newX: this.cropBox.x, + newY: this.cropBox.y, + newWidth: this.cropBox.width, + newHeight: this.cropBox.height, + }; } - const { newX: freeX, newY: freeY, newWidth: freeWidth, newHeight: freeHeight } = this._resizeCropBoxFree(handle); + const { + newX: freeX, + newY: freeY, + newWidth: freeWidth, + newHeight: freeHeight, + } = this._resizeCropBoxFree(handleName, handleRect); + let newX = freeX; let newY = freeY; let newWidth = freeWidth; let newHeight = freeHeight; - const oldX = rect.x(); - const oldY = rect.y(); - const oldWidth = rect.width(); - const oldHeight = rect.height(); + const oldX = this.cropBox.x; + const oldY = this.cropBox.y; + const oldWidth = this.cropBox.width; + const oldHeight = this.cropBox.height; // Define anchor points (opposite of the handle being dragged) let anchorX = oldX; @@ -815,10 +812,8 @@ export class Editor { anchorY = oldY + oldHeight / 2; // Center Y is anchor for left/right } - const isCornerHandle = this.CORNER_HANDLE_NAMES.includes(handleName); - // Calculate new dimensions maintaining aspect ratio - if (this.EDGE_HANDLE_NAMES.includes(handleName) && !isCornerHandle) { + if (handleName === 'left' || handleName === 'right' || handleName === 'top' || handleName === 'bottom') { if (handleName === 'left' || handleName === 'right') { newHeight = newWidth / ratio; newY = anchorY - newHeight / 2; @@ -827,7 +822,8 @@ export class Editor { newWidth = newHeight * ratio; newX = anchorX - newWidth / 2; } - } else if (isCornerHandle) { + } else { + // Corner handles const mouseDistanceFromAnchorX = Math.abs(handleX - anchorX); const mouseDistanceFromAnchorY = Math.abs(handleY - anchorY); @@ -897,14 +893,13 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; - private positionHandle = (handle: Konva.Rect) => { + private positionHandle = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva || !this.cropBox) { return; } const { x, y, width, height } = this.cropBox; - const handleName = handle.name(); - const handleSize = handle.width(); + const handleSize = handleRect.width(); let handleX = x; let handleY = y; @@ -921,8 +916,8 @@ export class Editor { handleY += height / 2; } - handle.x(handleX - handleSize / 2); - handle.y(handleY - handleSize / 2); + handleRect.x(handleX - handleSize / 2); + handleRect.y(handleY - handleSize / 2); }; private updateHandlePositions = () => { @@ -930,8 +925,8 @@ export class Editor { return; } - for (const handle of Object.values(this.konva.crop.interaction.handles)) { - this.positionHandle(handle); + for (const [handleName, handleRect] of objectEntries(this.konva.crop.interaction.handles)) { + this.positionHandle(handleName, handleRect); } }; From 583140b6499fe2c0e4f0b16cd726a558bc8e0126 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 19:18:51 +1000 Subject: [PATCH 18/34] refactor(ui): editor (wip) --- .../src/features/editImageModal/lib/editor.ts | 142 +++++++----------- 1 file changed, 58 insertions(+), 84 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index a83689ef2ba..a34a1010b26 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -73,7 +73,6 @@ export class Editor { private konva: KonvaObjects | null = null; private originalImage: HTMLImageElement | null = null; private isCropping = false; - private appliedCrop: CropBox | null = null; // Constants private readonly MIN_CROP_DIMENSION = 64; @@ -630,52 +629,6 @@ export class Editor { this.resetView(); }; - // Crop Mode - startCrop = (crop?: CropBox) => { - if (!this.konva?.image.image || this.isCropping) { - return; - } - - this.unfreezeCropOverlay(); - this.isCropping = true; - - // Calculate initial crop dimensions - let cropX: number; - let cropY: number; - let cropWidth: number; - let cropHeight: number; - - if (crop) { - cropX = crop.x; - cropY = crop.y; - cropWidth = crop.width; - cropHeight = crop.height; - } else if (this.appliedCrop) { - // Use the applied crop as starting point - cropX = this.appliedCrop.x; - cropY = this.appliedCrop.y; - cropWidth = this.appliedCrop.width; - cropHeight = this.appliedCrop.height; - } else { - // Create default crop box (centered, 80% of image) - const imgWidth = this.konva.image.image.width(); - const imgHeight = this.konva.image.image.height(); - cropWidth = imgWidth * this.DEFAULT_CROP_BOX_SCALE; - cropHeight = imgHeight * this.DEFAULT_CROP_BOX_SCALE; - cropX = (imgWidth - cropWidth) / 2; - cropY = (imgHeight - cropHeight) / 2; - } - - this.updateCropBox({ - x: cropX, - y: cropY, - width: cropWidth, - height: cropHeight, - }); - - this.callbacks.onCropStart?.(); - }; - private resizeCropBox = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva) { return; @@ -972,60 +925,81 @@ export class Editor { } }; - private freezeCropOverlay = () => { - if (!this.konva) { + // Crop Mode + startCrop = (crop?: CropBox) => { + if (!this.konva?.image.image || this.isCropping) { return; } - this.konva.crop.interaction.group.visible(false); - }; + // Calculate initial crop dimensions + let cropX: number; + let cropY: number; + let cropWidth: number; + let cropHeight: number; - private unfreezeCropOverlay = () => { - if (!this.konva) { - return; + if (crop) { + cropX = crop.x; + cropY = crop.y; + cropWidth = crop.width; + cropHeight = crop.height; + } else if (this.cropBox) { + // Use the current crop as starting point + cropX = this.cropBox.x; + cropY = this.cropBox.y; + cropWidth = this.cropBox.width; + cropHeight = this.cropBox.height; + } else { + // Create default crop box (centered, 80% of image) + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); + cropWidth = imgWidth * this.DEFAULT_CROP_BOX_SCALE; + cropHeight = imgHeight * this.DEFAULT_CROP_BOX_SCALE; + cropX = (imgWidth - cropWidth) / 2; + cropY = (imgHeight - cropHeight) / 2; } + this.updateCropBox({ + x: cropX, + y: cropY, + width: cropWidth, + height: cropHeight, + }); + this.isCropping = true; this.konva.crop.interaction.group.visible(true); - }; - resetEphemeralCropState = () => { - this.isCropping = false; + this.callbacks.onCropStart?.(); }; cancelCrop = () => { - if (!this.isCropping || !this.konva?.crop) { + if (!this.isCropping || !this.konva) { return; } - this.resetEphemeralCropState(); - + this.isCropping = false; + this.konva.crop.interaction.group.visible(false); this.callbacks.onCropCancel?.(); }; applyCrop = () => { - if (!this.isCropping || !this.cropBox) { + if (!this.isCropping || !this.cropBox || !this.konva) { return; } - // Store the crop dimensions - this.appliedCrop = { ...this.cropBox }; - - // Freeze the crop overlay instead of redisplaying image - this.freezeCropOverlay(); - this.isCropping = false; - this.callbacks.onCropApply?.(this.appliedCrop); + this.konva.crop.interaction.group.visible(false); + this.callbacks.onCropApply?.(this.cropBox); }; resetCrop = () => { - this.appliedCrop = null; - + if (this.konva?.image.image) { + this.updateCropBox({ + x: 0, + y: 0, + ...this.konva.image.image.size(), + }); + } this.callbacks.onCropReset?.(); }; - hasCrop = (): boolean => { - return !!this.appliedCrop; - }; - // Export exportImage = ( format: T = 'blob' as T @@ -1045,20 +1019,20 @@ export class Editor { } try { - if (this.appliedCrop) { - canvas.width = this.appliedCrop.width; - canvas.height = this.appliedCrop.height; + if (this.cropBox) { + canvas.width = this.cropBox.width; + canvas.height = this.cropBox.height; ctx.drawImage( this.originalImage, - this.appliedCrop.x, - this.appliedCrop.y, - this.appliedCrop.width, - this.appliedCrop.height, + this.cropBox.x, + this.cropBox.y, + this.cropBox.width, + this.cropBox.height, 0, 0, - this.appliedCrop.width, - this.appliedCrop.height + this.cropBox.width, + this.cropBox.height ); } else { canvas.width = this.originalImage.width; @@ -1321,7 +1295,7 @@ export class Editor { // Clear all references this.konva = null; this.originalImage = null; - this.appliedCrop = null; + this.cropBox = null; this.callbacks = {}; }; } From fb78067182f2a3f33ee40034ec4a3f47c2cfeba1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:41:13 +1000 Subject: [PATCH 19/34] feat(ui): clean up editor --- .../components/EditorContainer.tsx | 9 +- .../src/features/editImageModal/lib/editor.ts | 311 +++++++++--------- 2 files changed, 156 insertions(+), 164 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 712da3e4f79..138fcf28543 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -71,6 +71,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { width: imageDTO.width, height: imageDTO.height, }); + editor.fitToContainer(); }, [editor] ); @@ -133,22 +134,14 @@ export const EditorContainer = ({ editor, imageName }: Props) => { const handleApplyCrop = useCallback(() => { editor.applyCrop(); - // setIsCropping(false); - // setHasCropBbox(true); - // setCropInfo(''); - setAspectRatio('free'); }, [editor]); const handleCancelCrop = useCallback(() => { editor.cancelCrop(); - // setIsCropping(false); - // setCropInfo(''); - setAspectRatio('free'); }, [editor]); const handleResetCrop = useCallback(() => { editor.resetCrop(); - // setHasCropBbox(false); }, [editor]); const handleExport = useCallback(async () => { diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index a34a1010b26..3224f1f1f60 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -103,7 +103,7 @@ export class Editor { private lastPointerPosition: { x: number; y: number } | null = null; private isSpacePressed = false; - subscriptions: Set<() => void> = new Set(); + private subscriptions: Set<() => void> = new Set(); init = (container: HTMLDivElement) => { const stage = new Konva.Stage({ @@ -130,13 +130,13 @@ export class Editor { this.setupStageEvents(); }; - createKonvaBgObjects = (): KonvaObjects['bg'] => { + private createKonvaBgObjects = (): KonvaObjects['bg'] => { const layer = new Konva.Layer(); const rect = new Konva.Rect(); const image = new Image(); image.onload = () => { rect.fillPatternImage(image); - this.renderBg(); + this.updateBg(); }; image.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; layer.add(rect); @@ -147,14 +147,14 @@ export class Editor { }; }; - createKonvaImageObjects = (): KonvaObjects['image'] => { + private createKonvaImageObjects = (): KonvaObjects['image'] => { const layer = new Konva.Layer(); return { layer, }; }; - createKonvaCropObjects = (): KonvaObjects['crop'] => { + private createKonvaCropObjects = (): KonvaObjects['crop'] => { const layer = new Konva.Layer(); const overlay = this.createKonvaCropOverlayObjects(); const interaction = this.createKonvaCropInteractionObjects(); @@ -166,7 +166,8 @@ export class Editor { interaction, }; }; - createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => { + + private createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => { const group = new Konva.Group(); const full = new Konva.Rect({ fill: 'black', @@ -185,25 +186,25 @@ export class Editor { }; }; - createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { + private createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { const group = new Konva.Group(); - const rect = this.createCropInteractionRect(); + const rect = this.createKonvaCropInteractionRect(); const handles = { - 'top-left': this.createHandle('top-left'), - 'top-right': this.createHandle('top-right'), - 'bottom-right': this.createHandle('bottom-right'), - 'bottom-left': this.createHandle('bottom-left'), - top: this.createHandle('top'), - right: this.createHandle('right'), - bottom: this.createHandle('bottom'), - left: this.createHandle('left'), + 'top-left': this.createKonvaCropHandle('top-left'), + 'top-right': this.createKonvaCropHandle('top-right'), + 'bottom-right': this.createKonvaCropHandle('bottom-right'), + 'bottom-left': this.createKonvaCropHandle('bottom-left'), + top: this.createKonvaCropHandle('top'), + right: this.createKonvaCropHandle('right'), + bottom: this.createKonvaCropHandle('bottom'), + left: this.createKonvaCropHandle('left'), }; const guides = { - left: this.createGuide('left'), - right: this.createGuide('right'), - top: this.createGuide('top'), - bottom: this.createGuide('bottom'), + left: this.createKonvaCropGuide('left'), + right: this.createKonvaCropGuide('right'), + top: this.createKonvaCropGuide('top'), + bottom: this.createKonvaCropGuide('bottom'), }; group.add(rect); @@ -223,7 +224,7 @@ export class Editor { }; }; - createCropInteractionRect = (): Konva.Rect => { + private createKonvaCropInteractionRect = (): Konva.Rect => { const rect = new Konva.Rect({ stroke: 'white', strokeWidth: 1, @@ -285,14 +286,7 @@ export class Editor { return rect; }; - updateCropInteractionRect = () => { - if (!this.konva || !this.cropBox) { - return; - } - this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox }); - }; - - createGuide = (name: GuideName): Konva.Line => { + private createKonvaCropGuide = (name: GuideName): Konva.Line => { const line = new Konva.Line({ name, stroke: this.CROP_GUIDE_STROKE, @@ -304,23 +298,7 @@ export class Editor { return line; }; - updateCropGuides = () => { - if (!this.konva || !this.cropBox) { - return; - } - - const { x, y, width, height } = this.cropBox; - - const verticalThird = width / 3; - this.konva.crop.interaction.guides.left.points([x + verticalThird, y, x + verticalThird, y + height]); - this.konva.crop.interaction.guides.right.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]); - - const horizontalThird = height / 3; - this.konva.crop.interaction.guides.top.points([x, y + horizontalThird, x + width, y + horizontalThird]); - this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); - }; - - createHandle = (name: HandleName): Konva.Rect => { + private createKonvaCropHandle = (name: HandleName): Konva.Rect => { const rect = new Konva.Rect({ name, x: 0, @@ -332,6 +310,7 @@ export class Editor { strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, strokeScaleEnabled: true, draggable: true, + hitStrokeWidth: 16, }); // Prevent handle dragging when panning @@ -385,7 +364,30 @@ export class Editor { return rect; }; - updateCropBox = (cropBox: CropBox) => { + private updateCropInteractionRect = () => { + if (!this.konva || !this.cropBox) { + return; + } + this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox }); + }; + + private updateCropGuides = () => { + if (!this.konva || !this.cropBox) { + return; + } + + const { x, y, width, height } = this.cropBox; + + const verticalThird = width / 3; + this.konva.crop.interaction.guides.left.points([x + verticalThird, y, x + verticalThird, y + height]); + this.konva.crop.interaction.guides.right.points([x + verticalThird * 2, y, x + verticalThird * 2, y + height]); + + const horizontalThird = height / 3; + this.konva.crop.interaction.guides.top.points([x, y + horizontalThird, x + width, y + horizontalThird]); + this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); + }; + + private updateCropBox = (cropBox: CropBox) => { this.cropBox = cropBox; this.updateCropInteractionRect(); this.updateCropOverlay(); @@ -394,7 +396,7 @@ export class Editor { this.callbacks.onCropBoxChange?.(cropBox); }; - renderBg = () => { + private updateBg = () => { if (!this.konva) { return; } @@ -414,6 +416,80 @@ export class Editor { }); }; + private updateHandlePositions = () => { + if (!this.konva || !this.cropBox) { + return; + } + + for (const [handleName, handleRect] of objectEntries(this.konva.crop.interaction.handles)) { + const { x, y, width, height } = this.cropBox; + const handleSize = handleRect.width(); + + let handleX = x; + let handleY = y; + + if (handleName.includes('right')) { + handleX += width; + } else if (!handleName.includes('left')) { + handleX += width / 2; + } + + if (handleName.includes('bottom')) { + handleY += height; + } else if (!handleName.includes('top')) { + handleY += height / 2; + } + + handleRect.x(handleX - handleSize / 2); + handleRect.y(handleY - handleSize / 2); + } + }; + + private updateCropOverlay = () => { + if (!this.konva?.image.image || !this.cropBox) { + return; + } + + // Make the overlay cover the entire image + this.konva.crop.overlay.full.setAttrs({ + ...this.konva.image.image.getPosition(), + ...this.konva.image.image.getSize(), + }); + + // Clear the crop area from the overlay + this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); + }; + + private updateHandleScale = () => { + if (!this.konva) { + return; + } + + const scale = this.konva.stage.scaleX(); + const handleSize = this.CROP_HANDLE_SIZE / scale; + const strokeWidth = this.CROP_HANDLE_STROKE_WIDTH / scale; + + for (const handle of Object.values(this.konva.crop.interaction.handles)) { + const currentX = handle.x(); + const currentY = handle.y(); + const oldSize = handle.width(); + + // Calculate center position + const centerX = currentX + oldSize / 2; + const centerY = currentY + oldSize / 2; + + // Update size and stroke + handle.width(handleSize); + handle.height(handleSize); + handle.strokeWidth(strokeWidth); + + // Reposition to maintain center + handle.x(centerX - handleSize / 2); + handle.y(centerY - handleSize / 2); + } + }; + + //#region Event Handling private setupStageEvents = () => { if (!this.konva) { return; @@ -454,7 +530,7 @@ export class Editor { }; // Track Space key press - onKeyDown = (e: KeyboardEvent) => { + private onKeyDown = (e: KeyboardEvent) => { if (!this.konva?.stage) { return; } @@ -466,7 +542,7 @@ export class Editor { }; // Zoom with mouse wheel - onWheel = (e: WheelEvent) => { + private onWheel = (e: WheelEvent) => { if (!this.konva?.stage) { return; } @@ -501,11 +577,11 @@ export class Editor { // Update handle scaling to maintain constant screen size this.updateHandleScale(); - this.renderBg(); + this.updateBg(); this.callbacks.onZoomChange?.(newScale); }; - onKeyUp = (e: KeyboardEvent) => { + private onKeyUp = (e: KeyboardEvent) => { if (!this.konva?.stage) { return; } @@ -519,7 +595,7 @@ export class Editor { }; // Pan with Space + drag or middle mouse button - onPointerDown = (e: KonvaEventObject) => { + private onPointerDown = (e: KonvaEventObject) => { if (!this.konva?.stage) { return; } @@ -544,7 +620,7 @@ export class Editor { } }; - onPointerMove = (_: KonvaEventObject) => { + private onPointerMove = (_: KonvaEventObject) => { if (!this.konva?.stage) { return; } @@ -563,12 +639,12 @@ export class Editor { this.konva.stage.x(this.konva.stage.x() + dx); this.konva.stage.y(this.konva.stage.y() + dy); - this.renderBg(); + this.updateBg(); this.lastPointerPosition = pointer; }; - onPointerUp = (_: KonvaEventObject) => { + private onPointerUp = (_: KonvaEventObject) => { if (!this.konva?.stage) { return; } @@ -578,32 +654,13 @@ export class Editor { } }; - // Prevent context menu on right click - onContextMenu = (e: MouseEvent) => e.preventDefault(); - - // Image Management - loadImage = (src: string): Promise => { - return new Promise((resolve, reject) => { - const img = new Image(); - - img.crossOrigin = $crossOrigin.get(); - - img.onload = () => { - this.originalImage = img; - this.displayImage(); - this.callbacks.onImageLoad?.(); - resolve(); - }; - - img.onerror = () => { - reject(new Error('Failed to load image')); - }; - - img.src = src; - }); + private onContextMenu = (e: MouseEvent) => { + // Prevent context menu on right-click + e.preventDefault(); }; + //#endregion - private displayImage = () => { + private updateImage = () => { if (!this.originalImage || !this.konva) { return; } @@ -846,83 +903,25 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; - private positionHandle = (handleName: HandleName, handleRect: Konva.Rect) => { - if (!this.konva || !this.cropBox) { - return; - } - - const { x, y, width, height } = this.cropBox; - const handleSize = handleRect.width(); - - let handleX = x; - let handleY = y; - - if (handleName.includes('right')) { - handleX += width; - } else if (!handleName.includes('left')) { - handleX += width / 2; - } - - if (handleName.includes('bottom')) { - handleY += height; - } else if (!handleName.includes('top')) { - handleY += height / 2; - } - - handleRect.x(handleX - handleSize / 2); - handleRect.y(handleY - handleSize / 2); - }; + loadImage = (src: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); - private updateHandlePositions = () => { - if (!this.konva) { - return; - } + img.crossOrigin = $crossOrigin.get(); - for (const [handleName, handleRect] of objectEntries(this.konva.crop.interaction.handles)) { - this.positionHandle(handleName, handleRect); - } - }; + img.onload = () => { + this.originalImage = img; + this.updateImage(); + this.callbacks.onImageLoad?.(); + resolve(); + }; - private updateCropOverlay = () => { - if (!this.konva?.image.image || !this.cropBox) { - return; - } + img.onerror = () => { + reject(new Error('Failed to load image')); + }; - this.konva.crop.overlay.full.setAttrs({ - ...this.konva.image.image.getPosition(), - ...this.konva.image.image.getSize(), + img.src = src; }); - - this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); - }; - - private updateHandleScale = () => { - if (!this.konva) { - return; - } - - const scale = this.konva.stage.scaleX(); - const handleSize = this.CROP_HANDLE_SIZE / scale; - const strokeWidth = this.CROP_HANDLE_STROKE_WIDTH / scale; - - for (const handle of Object.values(this.konva.crop.interaction.handles)) { - const currentX = handle.x(); - const currentY = handle.y(); - const oldSize = handle.width(); - - // Calculate center position - const centerX = currentX + oldSize / 2; - const centerY = currentY + oldSize / 2; - - // Update size and stroke - handle.width(handleSize); - handle.height(handleSize); - handle.strokeWidth(strokeWidth); - - // Reposition to maintain center - handle.x(centerX - handleSize / 2); - handle.y(centerY - handleSize / 2); - } }; // Crop Mode @@ -1106,7 +1105,7 @@ export class Editor { // Update handle scaling this.updateHandleScale(); - this.renderBg(); + this.updateBg(); this.callbacks.onZoomChange?.(scale); }; @@ -1146,7 +1145,7 @@ export class Editor { // Update handle scaling this.updateHandleScale(); - this.renderBg(); + this.updateBg(); this.callbacks.onZoomChange?.(1); }; @@ -1177,7 +1176,7 @@ export class Editor { // Update handle scaling this.updateHandleScale(); - this.renderBg(); + this.updateBg(); this.callbacks.onZoomChange?.(scale); }; @@ -1275,7 +1274,7 @@ export class Editor { this.konva.stage.width(width); this.konva.stage.height(height); - this.renderBg(); + this.updateBg(); }; destroy = () => { From 4105612f93201dcf232e3e68754ff75c7cb104fd Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:48:49 +1000 Subject: [PATCH 20/34] feat(ui): extract config to own obj --- .../src/features/editImageModal/lib/editor.ts | 126 +++++++++++------- 1 file changed, 77 insertions(+), 49 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 3224f1f1f60..1547eced6ed 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -69,31 +69,59 @@ type OutputFormatToOutputMap = T extends 'canvas' ? string : never; +type EditorConfig = { + MIN_CROP_DIMENSION: number; + + ZOOM_WHEEL_FACTOR: number; + ZOOM_BUTTON_FACTOR: number; + + CROP_HANDLE_SIZE: number; + CROP_HANDLE_STROKE_WIDTH: number; + CROP_HANDLE_FILL: string; + CROP_HANDLE_STROKE: string; + + CROP_GUIDE_STROKE: string; + CROP_GUIDE_STROKE_WIDTH: number; + + FIT_TO_CONTAINER_PADDING: number; + + DEFAULT_CROP_BOX_SCALE: number; + + ZOOM_MIN: number; + ZOOM_MAX: number; +}; + +const DEFAULT_CONFIG: EditorConfig = { + MIN_CROP_DIMENSION: 64, + + ZOOM_WHEEL_FACTOR: 1.1, + ZOOM_BUTTON_FACTOR: 1.2, + + CROP_HANDLE_SIZE: 8, + CROP_HANDLE_STROKE_WIDTH: 1, + CROP_HANDLE_FILL: 'white', + CROP_HANDLE_STROKE: 'black', + + CROP_GUIDE_STROKE: 'rgba(255, 255, 255, 0.5)', + CROP_GUIDE_STROKE_WIDTH: 1, + + FIT_TO_CONTAINER_PADDING: 0.9, + + DEFAULT_CROP_BOX_SCALE: 0.8, + + ZOOM_MIN: 0.1, + ZOOM_MAX: 10, +}; + export class Editor { private konva: KonvaObjects | null = null; private originalImage: HTMLImageElement | null = null; private isCropping = false; - - // Constants - private readonly MIN_CROP_DIMENSION = 64; - private readonly ZOOM_WHEEL_FACTOR = 1.1; - private readonly ZOOM_BUTTON_FACTOR = 1.2; - private readonly CROP_HANDLE_SIZE = 8; - private readonly CROP_HANDLE_STROKE_WIDTH = 1; - private readonly CROP_GUIDE_STROKE = 'rgba(255, 255, 255, 0.5)'; - private readonly CROP_GUIDE_STROKE_WIDTH = 1; - private readonly CROP_HANDLE_FILL = 'white'; - private readonly CROP_HANDLE_STROKE = 'black'; - private readonly FIT_TO_CONTAINER_PADDING = 0.9; - private readonly DEFAULT_CROP_BOX_SCALE = 0.8; - - // Configuration - private readonly ZOOM_MIN = 0.1; - private readonly ZOOM_MAX = 10; + private config: EditorConfig = DEFAULT_CONFIG; private cropConstraints: CropConstraints = { - minWidth: this.MIN_CROP_DIMENSION, - minHeight: this.MIN_CROP_DIMENSION, + minWidth: this.config.MIN_CROP_DIMENSION, + minHeight: this.config.MIN_CROP_DIMENSION, }; private callbacks: EditorCallbacks = {}; private cropBox: CropBox | null = null; @@ -105,7 +133,9 @@ export class Editor { private subscriptions: Set<() => void> = new Set(); - init = (container: HTMLDivElement) => { + init = (container: HTMLDivElement, config?: Partial) => { + this.config = { ...this.config, ...config }; + const stage = new Konva.Stage({ container: container, width: container.clientWidth, @@ -245,24 +275,21 @@ export class Editor { if (!this.konva?.image.image || !this.cropBox) { return; } + const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); // Constrain to image bounds const x = Math.max(0, Math.min(rect.x(), imgWidth - rect.width())); const y = Math.max(0, Math.min(rect.y(), imgHeight - rect.height())); + const { width, height } = this.cropBox; rect.x(x); rect.y(y); - this.updateCropBox({ - ...this.cropBox, - x, - y, - }); + this.updateCropBox({ x, y, width, height }); }); - // Cursor styles rect.on('mouseenter', () => { const stage = this.konva?.stage; if (!stage) { @@ -289,8 +316,8 @@ export class Editor { private createKonvaCropGuide = (name: GuideName): Konva.Line => { const line = new Konva.Line({ name, - stroke: this.CROP_GUIDE_STROKE, - strokeWidth: this.CROP_GUIDE_STROKE_WIDTH, + stroke: this.config.CROP_GUIDE_STROKE, + strokeWidth: this.config.CROP_GUIDE_STROKE_WIDTH, strokeScaleEnabled: false, listening: false, }); @@ -303,11 +330,11 @@ export class Editor { name, x: 0, y: 0, - width: this.CROP_HANDLE_SIZE, - height: this.CROP_HANDLE_SIZE, - fill: this.CROP_HANDLE_FILL, - stroke: this.CROP_HANDLE_STROKE, - strokeWidth: this.CROP_HANDLE_STROKE_WIDTH, + width: this.config.CROP_HANDLE_SIZE, + height: this.config.CROP_HANDLE_SIZE, + fill: this.config.CROP_HANDLE_FILL, + stroke: this.config.CROP_HANDLE_STROKE, + strokeWidth: this.config.CROP_HANDLE_STROKE_WIDTH, strokeScaleEnabled: true, draggable: true, hitStrokeWidth: 16, @@ -466,8 +493,8 @@ export class Editor { } const scale = this.konva.stage.scaleX(); - const handleSize = this.CROP_HANDLE_SIZE / scale; - const strokeWidth = this.CROP_HANDLE_STROKE_WIDTH / scale; + const handleSize = this.config.CROP_HANDLE_SIZE / scale; + const strokeWidth = this.config.CROP_HANDLE_STROKE_WIDTH / scale; for (const handle of Object.values(this.konva.crop.interaction.handles)) { const currentX = handle.x(); @@ -561,11 +588,11 @@ export class Editor { }; const direction = e.deltaY > 0 ? -1 : 1; - const scaleBy = this.ZOOM_WHEEL_FACTOR; + const scaleBy = this.config.ZOOM_WHEEL_FACTOR; let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; // Apply zoom limits - newScale = Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, newScale)); + newScale = Math.max(this.config.ZOOM_MIN, Math.min(this.config.ZOOM_MAX, newScale)); this.konva.stage.scale({ x: newScale, y: newScale }); @@ -727,8 +754,8 @@ export class Editor { const handleX = handleRect.x() + handleRect.width() / 2; const handleY = handleRect.y() + handleRect.height() / 2; - const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; + const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; // Update dimensions based on handle type if (handleName.includes('left')) { @@ -762,8 +789,8 @@ export class Editor { const handleX = handleRect.x() + handleRect.width() / 2; const handleY = handleRect.y() + handleRect.height() / 2; - const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; + const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; // Early boundary check for aspect ratio mode const atLeftEdge = this.cropBox.x <= 0; @@ -951,8 +978,8 @@ export class Editor { // Create default crop box (centered, 80% of image) const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); - cropWidth = imgWidth * this.DEFAULT_CROP_BOX_SCALE; - cropHeight = imgHeight * this.DEFAULT_CROP_BOX_SCALE; + cropWidth = imgWidth * this.config.DEFAULT_CROP_BOX_SCALE; + cropHeight = imgHeight * this.config.DEFAULT_CROP_BOX_SCALE; cropX = (imgWidth - cropWidth) / 2; cropY = (imgHeight - cropHeight) / 2; } @@ -1072,7 +1099,7 @@ export class Editor { return; } - scale = Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, scale)); + scale = Math.max(this.config.ZOOM_MIN, Math.min(this.config.ZOOM_MAX, scale)); // If no point provided, use center of viewport if (!point && this.konva.image) { @@ -1116,12 +1143,12 @@ export class Editor { zoomIn = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); - this.setZoom(currentZoom * this.ZOOM_BUTTON_FACTOR, point); + this.setZoom(currentZoom * this.config.ZOOM_BUTTON_FACTOR, point); }; zoomOut = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); - this.setZoom(currentZoom / this.ZOOM_BUTTON_FACTOR, point); + this.setZoom(currentZoom / this.config.ZOOM_BUTTON_FACTOR, point); }; resetView = () => { @@ -1160,7 +1187,8 @@ export class Editor { const imageWidth = this.konva.image.image.width(); const imageHeight = this.konva.image.image.height(); - const scale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.FIT_TO_CONTAINER_PADDING; + const scale = + Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.config.FIT_TO_CONTAINER_PADDING; this.konva.stage.scale({ x: scale, y: scale }); @@ -1230,8 +1258,8 @@ export class Editor { } // Apply minimum size constraints - const minWidth = this.cropConstraints.minWidth ?? this.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.MIN_CROP_DIMENSION; + const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; + const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; if (newWidth < minWidth) { newWidth = minWidth; From 436de45456ee1fa71298121cf03652d4586d9ce5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:52:28 +1000 Subject: [PATCH 21/34] refactor(ui): simplify crop constraints --- .../src/features/editImageModal/lib/editor.ts | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 1547eced6ed..af81fe54653 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -4,14 +4,6 @@ import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { objectEntries } from 'tsafe'; -type CropConstraints = { - minWidth?: number; - minHeight?: number; - maxWidth?: number; - maxHeight?: number; - aspectRatio?: number; -}; - export type CropBox = { x: number; y: number; @@ -119,10 +111,8 @@ export class Editor { private isCropping = false; private config: EditorConfig = DEFAULT_CONFIG; - private cropConstraints: CropConstraints = { - minWidth: this.config.MIN_CROP_DIMENSION, - minHeight: this.config.MIN_CROP_DIMENSION, - }; + private aspectRatio: number | null = null; + private callbacks: EditorCallbacks = {}; private cropBox: CropBox | null = null; @@ -718,18 +708,10 @@ export class Editor { return; } - let { newX, newY, newWidth, newHeight } = this.cropConstraints.aspectRatio + let { newX, newY, newWidth, newHeight } = this.aspectRatio ? this._resizeCropBoxWithAspectRatio(handleName, handleRect) : this._resizeCropBoxFree(handleName, handleRect); - // Apply general constraints - if (this.cropConstraints.maxWidth) { - newWidth = Math.min(newWidth, this.cropConstraints.maxWidth); - } - if (this.cropConstraints.maxHeight) { - newHeight = Math.min(newHeight, this.cropConstraints.maxHeight); - } - this.updateCropBox({ x: newX, y: newY, @@ -754,8 +736,8 @@ export class Editor { const handleX = handleRect.x() + handleRect.width() / 2; const handleY = handleRect.y() + handleRect.height() / 2; - const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; + const minWidth = this.config.MIN_CROP_DIMENSION; + const minHeight = this.config.MIN_CROP_DIMENSION; // Update dimensions based on handle type if (handleName.includes('left')) { @@ -779,18 +761,18 @@ export class Editor { }; private _resizeCropBoxWithAspectRatio = (handleName: HandleName, handleRect: Konva.Rect) => { - if (!this.konva?.image.image || !this.cropConstraints.aspectRatio || !this.cropBox) { + if (!this.konva?.image.image || !this.aspectRatio || !this.cropBox) { throw new Error('Crop box, image, or aspect ratio not found'); } const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); - const ratio = this.cropConstraints.aspectRatio; + const ratio = this.aspectRatio; const handleX = handleRect.x() + handleRect.width() / 2; const handleY = handleRect.y() + handleRect.height() / 2; - const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; + const minWidth = this.config.MIN_CROP_DIMENSION; + const minHeight = this.config.MIN_CROP_DIMENSION; // Early boundary check for aspect ratio mode const atLeftEdge = this.cropBox.x <= 0; @@ -1218,9 +1200,9 @@ export class Editor { } }; - setCropAspectRatio = (ratio: number | undefined) => { + setCropAspectRatio = (ratio: number | null) => { // Update the constraint - this.cropConstraints.aspectRatio = ratio; + this.aspectRatio = ratio; if (!this.konva?.image.image || !this.cropBox) { return; @@ -1230,7 +1212,7 @@ export class Editor { const currentHeight = this.cropBox.height; const currentArea = currentWidth * currentHeight; - if (ratio === undefined) { + if (ratio === null) { // Just removed the aspect ratio constraint, no need to adjust return; } @@ -1258,8 +1240,8 @@ export class Editor { } // Apply minimum size constraints - const minWidth = this.cropConstraints.minWidth ?? this.config.MIN_CROP_DIMENSION; - const minHeight = this.cropConstraints.minHeight ?? this.config.MIN_CROP_DIMENSION; + const minWidth = this.config.MIN_CROP_DIMENSION; + const minHeight = this.config.MIN_CROP_DIMENSION; if (newWidth < minWidth) { newWidth = minWidth; @@ -1289,8 +1271,8 @@ export class Editor { }); }; - getCropAspectRatio = (): number | undefined => { - return this.cropConstraints.aspectRatio; + getCropAspectRatio = (): number | null => { + return this.aspectRatio; }; // Utility From 2d56bb6e798398905181b612d6c35f5c09ec0c72 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:58:06 +1000 Subject: [PATCH 22/34] tidy(ui): editor listeners --- .../src/features/editImageModal/lib/editor.ts | 54 ++++++++----------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index af81fe54653..896a2a2d57c 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -121,7 +121,7 @@ export class Editor { private lastPointerPosition: { x: number; y: number } | null = null; private isSpacePressed = false; - private subscriptions: Set<() => void> = new Set(); + private cleanupFunctions: Set<() => void> = new Set(); init = (container: HTMLDivElement, config?: Partial) => { this.config = { ...this.config, ...config }; @@ -147,7 +147,7 @@ export class Editor { crop, }; - this.setupStageEvents(); + this.setupListeners(); }; private createKonvaBgObjects = (): KonvaObjects['bg'] => { @@ -507,41 +507,31 @@ export class Editor { }; //#region Event Handling - private setupStageEvents = () => { + private setupListeners = () => { if (!this.konva) { return; } const stage = this.konva.stage; - stage.container().addEventListener('wheel', this.onWheel, { passive: false }); - this.subscriptions.add(() => { - stage.container().removeEventListener('wheel', this.onWheel); - }); - stage.container().addEventListener('contextmenu', this.onContextMenu); - this.subscriptions.add(() => { - stage.container().removeEventListener('contextmenu', this.onContextMenu); - }); - + stage.on('wheel', this.onWheel); + stage.on('contextmenu', this.onContextMenu); stage.on('pointerdown', this.onPointerDown); - this.subscriptions.add(() => { - stage.off('pointerdown', this.onPointerDown); - }); stage.on('pointerup', this.onPointerUp); - this.subscriptions.add(() => { - stage.off('pointerup', this.onPointerUp); - }); stage.on('pointermove', this.onPointerMove); - this.subscriptions.add(() => { + + this.cleanupFunctions.add(() => { + stage.off('wheel', this.onWheel); + stage.off('contextmenu', this.onContextMenu); + stage.off('pointerdown', this.onPointerDown); + stage.off('pointerup', this.onPointerUp); stage.off('pointermove', this.onPointerMove); }); window.addEventListener('keydown', this.onKeyDown); - this.subscriptions.add(() => { - window.removeEventListener('keydown', this.onKeyDown); - }); - window.addEventListener('keyup', this.onKeyUp); - this.subscriptions.add(() => { + + this.cleanupFunctions.add(() => { + window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('keyup', this.onKeyUp); }); }; @@ -559,11 +549,11 @@ export class Editor { }; // Zoom with mouse wheel - private onWheel = (e: WheelEvent) => { + private onWheel = (e: KonvaEventObject) => { if (!this.konva?.stage) { return; } - e.preventDefault(); + e.evt.preventDefault(); const oldScale = this.konva.stage.scaleX(); const pointer = this.konva.stage.getPointerPosition(); @@ -577,7 +567,7 @@ export class Editor { y: (pointer.y - this.konva.stage.y()) / oldScale, }; - const direction = e.deltaY > 0 ? -1 : 1; + const direction = e.evt.deltaY > 0 ? -1 : 1; const scaleBy = this.config.ZOOM_WHEEL_FACTOR; let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; @@ -671,9 +661,9 @@ export class Editor { } }; - private onContextMenu = (e: MouseEvent) => { + private onContextMenu = (e: KonvaEventObject) => { // Prevent context menu on right-click - e.preventDefault(); + e.evt.preventDefault(); }; //#endregion @@ -1288,8 +1278,8 @@ export class Editor { }; destroy = () => { - for (const unsubscribe of this.subscriptions) { - unsubscribe(); + for (const cleanup of this.cleanupFunctions) { + cleanup(); } // Cancel any ongoing crop operation @@ -1297,8 +1287,6 @@ export class Editor { this.cancelCrop(); } - // Remove all Konva event listeners by destroying the stage - // This automatically removes all Konva event handlers this.konva?.stage.destroy(); // Clear all references From 68924e6076be402a2ba1a19f19edd99a65fb8d06 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:12:24 +1000 Subject: [PATCH 23/34] tidy(ui): editor misc --- .../src/features/editImageModal/lib/editor.ts | 320 +++++++++++------- 1 file changed, 193 insertions(+), 127 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 896a2a2d57c..cf951ff64ad 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -4,6 +4,9 @@ import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { objectEntries } from 'tsafe'; +/** + * The position and size of a crop box. + */ export type CropBox = { x: number; y: number; @@ -11,6 +14,9 @@ export type CropBox = { height: number; }; +/** + * The callbacks supported by the editor. + */ type EditorCallbacks = { onCropStart?: () => void; onCropBoxChange?: (crop: CropBox) => void; @@ -21,10 +27,19 @@ type EditorCallbacks = { onImageLoad?: () => void; }; +/** + * Crop box resize handle names. + */ type HandleName = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left' | 'top' | 'right' | 'bottom' | 'left'; +/** + * Crop box guide line names. + */ type GuideName = 'left' | 'right' | 'top' | 'bottom'; +/** + * All the Konva objects used by the editor, organized by function and approximating the Konva node structures. + */ type KonvaObjects = { stage: Konva.Stage; bg: { @@ -51,8 +66,14 @@ type KonvaObjects = { }; }; +/** + * Valid editor output formats. + */ type OutputFormat = 'canvas' | 'blob' | 'dataURL'; +/** + * Type helper mapping output format name to the actual data type. + */ type OutputFormatToOutputMap = T extends 'canvas' ? HTMLCanvasElement : T extends 'blob' @@ -61,54 +82,96 @@ type OutputFormatToOutputMap = T extends 'canvas' ? string : never; +/** + * The editor's configurable parameters. + */ type EditorConfig = { + /** + * The minimum size for the crop box. Applied to both width and height. + */ MIN_CROP_DIMENSION: number; + /** + * The zoom factor applied when zooming with the mouse wheel. A value of 1.1 means each wheel step zooms in/out by 10%. + */ ZOOM_WHEEL_FACTOR: number; + + /** + * The zoom factor applied when zooming with buttons (e.g. the editor's zoomIn/zoomOut methods). A value of 1.2 means + * each button press zooms in/out by 20%. + */ ZOOM_BUTTON_FACTOR: number; + /** + * The size of the crop box resize handles. The handles do not scale with zoom; this is the size they will appear on screen. + */ CROP_HANDLE_SIZE: number; + + /** + * The stroke width of the crop box resize handles. The stroke does not scale with zoom; this is the width it will appear on screen. + */ CROP_HANDLE_STROKE_WIDTH: number; + + /** + * The fill color for the crop box resize handles. + */ CROP_HANDLE_FILL: string; + + /** + * The stroke color for the crop box resize handles. + */ CROP_HANDLE_STROKE: string; + /** + * The stroke color for the group box guides. + */ CROP_GUIDE_STROKE: string; + + /** + * The stroke width for the crop box guides. The stroke does not scale with zoom; this is the width it will appear on screen. + */ CROP_GUIDE_STROKE_WIDTH: number; - FIT_TO_CONTAINER_PADDING: number; + /** + * When fitting the image to the container, this padding factor is applied to ensure some space around the image. + */ + FIT_TO_CONTAINER_PADDING_PCT: number; + /** + * When starting a new crop, the initial crop box will be this fraction of the image size. + */ DEFAULT_CROP_BOX_SCALE: number; - ZOOM_MIN: number; - ZOOM_MAX: number; + /** + * The minimum zoom (scale) for the stage. + */ + ZOOM_MIN_PCT: number; + + /** + * The maximum zoom (scale) for the stage. + */ + ZOOM_MAX_PCT: number; }; const DEFAULT_CONFIG: EditorConfig = { MIN_CROP_DIMENSION: 64, - ZOOM_WHEEL_FACTOR: 1.1, ZOOM_BUTTON_FACTOR: 1.2, - CROP_HANDLE_SIZE: 8, CROP_HANDLE_STROKE_WIDTH: 1, CROP_HANDLE_FILL: 'white', CROP_HANDLE_STROKE: 'black', - CROP_GUIDE_STROKE: 'rgba(255, 255, 255, 0.5)', CROP_GUIDE_STROKE_WIDTH: 1, - - FIT_TO_CONTAINER_PADDING: 0.9, - + FIT_TO_CONTAINER_PADDING_PCT: 0.9, DEFAULT_CROP_BOX_SCALE: 0.8, - - ZOOM_MIN: 0.1, - ZOOM_MAX: 10, + ZOOM_MIN_PCT: 0.1, + ZOOM_MAX_PCT: 10, }; export class Editor { private konva: KonvaObjects | null = null; private originalImage: HTMLImageElement | null = null; - private isCropping = false; private config: EditorConfig = DEFAULT_CONFIG; private aspectRatio: number | null = null; @@ -117,6 +180,7 @@ export class Editor { private cropBox: CropBox | null = null; // State + private isCropping = false; private isPanning = false; private lastPointerPosition: { x: number; y: number } | null = null; private isSpacePressed = false; @@ -156,7 +220,7 @@ export class Editor { const image = new Image(); image.onload = () => { rect.fillPatternImage(image); - this.updateBg(); + this.updateKonvaBg(); }; image.src = TRANSPARENCY_CHECKERBOARD_PATTERN_DARK_DATAURL; layer.add(rect); @@ -211,20 +275,20 @@ export class Editor { const rect = this.createKonvaCropInteractionRect(); const handles = { - 'top-left': this.createKonvaCropHandle('top-left'), - 'top-right': this.createKonvaCropHandle('top-right'), - 'bottom-right': this.createKonvaCropHandle('bottom-right'), - 'bottom-left': this.createKonvaCropHandle('bottom-left'), - top: this.createKonvaCropHandle('top'), - right: this.createKonvaCropHandle('right'), - bottom: this.createKonvaCropHandle('bottom'), - left: this.createKonvaCropHandle('left'), + 'top-left': this.createKonvaCropInteractionHandle('top-left'), + 'top-right': this.createKonvaCropInteractionHandle('top-right'), + 'bottom-right': this.createKonvaCropInteractionHandle('bottom-right'), + 'bottom-left': this.createKonvaCropInteractionHandle('bottom-left'), + top: this.createKonvaCropInteractionHandle('top'), + right: this.createKonvaCropInteractionHandle('right'), + bottom: this.createKonvaCropInteractionHandle('bottom'), + left: this.createKonvaCropInteractionHandle('left'), }; const guides = { - left: this.createKonvaCropGuide('left'), - right: this.createKonvaCropGuide('right'), - top: this.createKonvaCropGuide('top'), - bottom: this.createKonvaCropGuide('bottom'), + left: this.createKonvaCropInteractionGuide('left'), + right: this.createKonvaCropInteractionGuide('right'), + top: this.createKonvaCropInteractionGuide('top'), + bottom: this.createKonvaCropInteractionGuide('bottom'), }; group.add(rect); @@ -277,7 +341,7 @@ export class Editor { rect.x(x); rect.y(y); - this.updateCropBox({ x, y, width, height }); + this.updateKonvaCropBox({ x, y, width, height }); }); rect.on('mouseenter', () => { @@ -303,7 +367,7 @@ export class Editor { return rect; }; - private createKonvaCropGuide = (name: GuideName): Konva.Line => { + private createKonvaCropInteractionGuide = (name: GuideName): Konva.Line => { const line = new Konva.Line({ name, stroke: this.config.CROP_GUIDE_STROKE, @@ -315,7 +379,7 @@ export class Editor { return line; }; - private createKonvaCropHandle = (name: HandleName): Konva.Rect => { + private createKonvaCropInteractionHandle = (name: HandleName): Konva.Rect => { const rect = new Konva.Rect({ name, x: 0, @@ -381,14 +445,14 @@ export class Editor { return rect; }; - private updateCropInteractionRect = () => { + private updateKonvaCropInteractionRect = () => { if (!this.konva || !this.cropBox) { return; } this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox }); }; - private updateCropGuides = () => { + private updateKonvaCropInteractionGuides = () => { if (!this.konva || !this.cropBox) { return; } @@ -404,36 +468,7 @@ export class Editor { this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); }; - private updateCropBox = (cropBox: CropBox) => { - this.cropBox = cropBox; - this.updateCropInteractionRect(); - this.updateCropOverlay(); - this.updateCropGuides(); - this.updateHandlePositions(); - this.callbacks.onCropBoxChange?.(cropBox); - }; - - private updateBg = () => { - if (!this.konva) { - return; - } - const scale = this.konva.stage.scaleX(); - const patternScale = 1 / scale; - const { x, y } = this.konva.stage.getPosition(); - const { width, height } = this.konva.stage.size(); - - this.konva.bg.rect.setAttrs({ - visible: true, - x: Math.floor(-x / scale), - y: Math.floor(-y / scale), - width: Math.ceil(width / scale), - height: Math.ceil(height / scale), - fillPatternScaleX: patternScale, - fillPatternScaleY: patternScale, - }); - }; - - private updateHandlePositions = () => { + private updateKonvaCropInteractionHandlePositions = () => { if (!this.konva || !this.cropBox) { return; } @@ -462,22 +497,7 @@ export class Editor { } }; - private updateCropOverlay = () => { - if (!this.konva?.image.image || !this.cropBox) { - return; - } - - // Make the overlay cover the entire image - this.konva.crop.overlay.full.setAttrs({ - ...this.konva.image.image.getPosition(), - ...this.konva.image.image.getSize(), - }); - - // Clear the crop area from the overlay - this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); - }; - - private updateHandleScale = () => { + private updateKonvaCropInteractionHandleScales = () => { if (!this.konva) { return; } @@ -506,6 +526,76 @@ export class Editor { } }; + private updateKonvaCropBox = (cropBox: CropBox) => { + this.cropBox = cropBox; + this.updateKonvaCropOverlay(); + this.updateKonvaCropInteractionRect(); + this.updateKonvaCropInteractionGuides(); + this.updateKonvaCropInteractionHandlePositions(); + this.callbacks.onCropBoxChange?.(cropBox); + }; + + private updateKonvaBg = () => { + if (!this.konva) { + return; + } + const scale = this.konva.stage.scaleX(); + const patternScale = 1 / scale; + const { x, y } = this.konva.stage.getPosition(); + const { width, height } = this.konva.stage.size(); + + this.konva.bg.rect.setAttrs({ + visible: true, + x: Math.floor(-x / scale), + y: Math.floor(-y / scale), + width: Math.ceil(width / scale), + height: Math.ceil(height / scale), + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale, + }); + }; + + private updateKonvaCropOverlay = () => { + if (!this.konva?.image.image || !this.cropBox) { + return; + } + + // Make the overlay cover the entire image + this.konva.crop.overlay.full.setAttrs({ + ...this.konva.image.image.getPosition(), + ...this.konva.image.image.getSize(), + }); + + // Clear the crop area from the overlay + this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); + }; + + private updateImage = () => { + if (!this.originalImage || !this.konva) { + return; + } + + // Clear existing image + if (this.konva.image.image) { + this.konva.image.image.destroy(); + this.konva.image.image = undefined; + } + + const imageNode = new Konva.Image({ + image: this.originalImage, + x: 0, + y: 0, + width: this.originalImage.width, + height: this.originalImage.height, + }); + + this.konva.image.image = imageNode; + this.konva.image.layer.add(imageNode); + + // Center image at 100% zoom + this.resetView(); + }; + //#region Event Handling private setupListeners = () => { if (!this.konva) { @@ -572,7 +662,7 @@ export class Editor { let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; // Apply zoom limits - newScale = Math.max(this.config.ZOOM_MIN, Math.min(this.config.ZOOM_MAX, newScale)); + newScale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, newScale)); this.konva.stage.scale({ x: newScale, y: newScale }); @@ -583,8 +673,8 @@ export class Editor { this.konva.stage.position(newPos); // Update handle scaling to maintain constant screen size - this.updateHandleScale(); - this.updateBg(); + this.updateKonvaCropInteractionHandleScales(); + this.updateKonvaBg(); this.callbacks.onZoomChange?.(newScale); }; @@ -646,7 +736,7 @@ export class Editor { this.konva.stage.x(this.konva.stage.x() + dx); this.konva.stage.y(this.konva.stage.y() + dy); - this.updateBg(); + this.updateKonvaBg(); this.lastPointerPosition = pointer; }; @@ -665,33 +755,7 @@ export class Editor { // Prevent context menu on right-click e.evt.preventDefault(); }; - //#endregion - - private updateImage = () => { - if (!this.originalImage || !this.konva) { - return; - } - - // Clear existing image - if (this.konva.image.image) { - this.konva.image.image.destroy(); - this.konva.image.image = undefined; - } - - const imageNode = new Konva.Image({ - image: this.originalImage, - x: 0, - y: 0, - width: this.originalImage.width, - height: this.originalImage.height, - }); - - this.konva.image.image = imageNode; - this.konva.image.layer.add(imageNode); - - // Center image at 100% zoom - this.resetView(); - }; + //#region Event Handling private resizeCropBox = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva) { @@ -702,7 +766,7 @@ export class Editor { ? this._resizeCropBoxWithAspectRatio(handleName, handleRect) : this._resizeCropBoxFree(handleName, handleRect); - this.updateCropBox({ + this.updateKonvaCropBox({ x: newX, y: newY, width: newWidth, @@ -711,17 +775,17 @@ export class Editor { }; private _resizeCropBoxFree = (handleName: HandleName, handleRect: Konva.Rect) => { - if (!this.konva?.image.image) { + if (!this.konva?.image.image || !this.cropBox) { throw new Error('Crop box or image not found'); } - const rect = this.konva.crop.overlay.clear; + const imgWidth = this.konva.image.image.width(); const imgHeight = this.konva.image.image.height(); - let newX = rect.x(); - let newY = rect.y(); - let newWidth = rect.width(); - let newHeight = rect.height(); + let newX = this.cropBox.x; + let newY = this.cropBox.y; + let newWidth = this.cropBox.width; + let newHeight = this.cropBox.height; const handleX = handleRect.x() + handleRect.width() / 2; const handleY = handleRect.y() + handleRect.height() / 2; @@ -902,6 +966,7 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; + //#region Public API loadImage = (src: string): Promise => { return new Promise((resolve, reject) => { const img = new Image(); @@ -956,7 +1021,7 @@ export class Editor { cropY = (imgHeight - cropHeight) / 2; } - this.updateCropBox({ + this.updateKonvaCropBox({ x: cropX, y: cropY, width: cropWidth, @@ -989,7 +1054,7 @@ export class Editor { resetCrop = () => { if (this.konva?.image.image) { - this.updateCropBox({ + this.updateKonvaCropBox({ x: 0, y: 0, ...this.konva.image.image.size(), @@ -1071,7 +1136,7 @@ export class Editor { return; } - scale = Math.max(this.config.ZOOM_MIN, Math.min(this.config.ZOOM_MAX, scale)); + scale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, scale)); // If no point provided, use center of viewport if (!point && this.konva.image) { @@ -1102,9 +1167,9 @@ export class Editor { } // Update handle scaling - this.updateHandleScale(); + this.updateKonvaCropInteractionHandleScales(); - this.updateBg(); + this.updateKonvaBg(); this.callbacks.onZoomChange?.(scale); }; @@ -1142,9 +1207,9 @@ export class Editor { }); // Update handle scaling - this.updateHandleScale(); + this.updateKonvaCropInteractionHandleScales(); - this.updateBg(); + this.updateKonvaBg(); this.callbacks.onZoomChange?.(1); }; @@ -1160,7 +1225,7 @@ export class Editor { const imageHeight = this.konva.image.image.height(); const scale = - Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.config.FIT_TO_CONTAINER_PADDING; + Math.min(containerWidth / imageWidth, containerHeight / imageHeight) * this.config.FIT_TO_CONTAINER_PADDING_PCT; this.konva.stage.scale({ x: scale, y: scale }); @@ -1174,9 +1239,9 @@ export class Editor { }); // Update handle scaling - this.updateHandleScale(); + this.updateKonvaCropInteractionHandleScales(); - this.updateBg(); + this.updateKonvaBg(); this.callbacks.onZoomChange?.(scale); }; @@ -1253,7 +1318,7 @@ export class Editor { newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); - this.updateCropBox({ + this.updateKonvaCropBox({ x: newX, y: newY, width: newWidth, @@ -1274,7 +1339,7 @@ export class Editor { this.konva.stage.width(width); this.konva.stage.height(height); - this.updateBg(); + this.updateKonvaBg(); }; destroy = () => { @@ -1295,4 +1360,5 @@ export class Editor { this.cropBox = null; this.callbacks = {}; }; + //#endregion Public API } From 35032313f4b4d119df28c6e74f5fed693b2ca91b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 12 Sep 2025 22:31:12 +1000 Subject: [PATCH 24/34] docs(ui): add comments to editor --- .../src/features/editImageModal/lib/editor.ts | 573 +++++++++++------- 1 file changed, 369 insertions(+), 204 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index cf951ff64ad..1f16c7f6d01 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -187,6 +187,11 @@ export class Editor { private cleanupFunctions: Set<() => void> = new Set(); + /** + * Initialize the editor inside the given container element. + * @param container The HTML element to contain the editor. It will be used as the Konva stage container. + * @param config Optional configuration overrides. + */ init = (container: HTMLDivElement, config?: Partial) => { this.config = { ...this.config, ...config }; @@ -214,6 +219,9 @@ export class Editor { this.setupListeners(); }; + /** + * Create the Konva objects used for the background layer (checkerboard pattern). + */ private createKonvaBgObjects = (): KonvaObjects['bg'] => { const layer = new Konva.Layer(); const rect = new Konva.Rect(); @@ -231,6 +239,9 @@ export class Editor { }; }; + /** + * Create the Konva objects used for the image layer. Note that the Konva image node is created when an image is loaded. + */ private createKonvaImageObjects = (): KonvaObjects['image'] => { const layer = new Konva.Layer(); return { @@ -238,6 +249,9 @@ export class Editor { }; }; + /** + * Create the Konva objects used for cropping (overlay and interaction). + */ private createKonvaCropObjects = (): KonvaObjects['crop'] => { const layer = new Konva.Layer(); const overlay = this.createKonvaCropOverlayObjects(); @@ -251,6 +265,12 @@ export class Editor { }; }; + /** + * Create the Konva objects used for the crop overlay (the darkened area outside the crop box). + * + * This includes a full rectangle covering the entire image and a rectangle matching the crop box which is used to + * "cut out" the crop area from the overlay using the 'destination-out' composite operation. + */ private createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => { const group = new Konva.Group(); const full = new Konva.Rect({ @@ -270,6 +290,9 @@ export class Editor { }; }; + /** + * Create the Konva objects used for crop interaction (the crop box, resize handles, and guides). + */ private createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { const group = new Konva.Group(); @@ -308,6 +331,9 @@ export class Editor { }; }; + /** + * Create the Konva rectangle used for crop box interaction (dragging the crop box). + */ private createKonvaCropInteractionRect = (): Konva.Rect => { const rect = new Konva.Rect({ stroke: 'white', @@ -341,7 +367,7 @@ export class Editor { rect.x(x); rect.y(y); - this.updateKonvaCropBox({ x, y, width, height }); + this.updateCropBox({ x, y, width, height }); }); rect.on('mouseenter', () => { @@ -367,6 +393,9 @@ export class Editor { return rect; }; + /** + * Create a Konva line used as a crop box guide (one of the "rule of thirds" lines). + */ private createKonvaCropInteractionGuide = (name: GuideName): Konva.Line => { const line = new Konva.Line({ name, @@ -379,6 +408,9 @@ export class Editor { return line; }; + /** + * Create a Konva rectangle used as a crop box resize handle. + */ private createKonvaCropInteractionHandle = (name: HandleName): Konva.Rect => { const rect = new Konva.Rect({ name, @@ -439,12 +471,28 @@ export class Editor { // Handle dragging rect.on('dragmove', () => { - this.resizeCropBox(name, rect); + if (!this.konva) { + return; + } + + const { newX, newY, newWidth, newHeight } = this.aspectRatio + ? this.getNextCropBoxByHandleWithAspectRatio(name, rect) + : this.getNextCropBoxByHandleFree(name, rect); + + this.updateCropBox({ + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }); }); return rect; }; + /** + * Update (render) the Konva rectangle used for crop box interaction (dragging the crop box). + */ private updateKonvaCropInteractionRect = () => { if (!this.konva || !this.cropBox) { return; @@ -452,6 +500,9 @@ export class Editor { this.konva.crop.interaction.rect.setAttrs({ ...this.cropBox }); }; + /** + * Update (render) the Konva lines used as crop box guides (the "rule of thirds" lines). + */ private updateKonvaCropInteractionGuides = () => { if (!this.konva || !this.cropBox) { return; @@ -468,6 +519,10 @@ export class Editor { this.konva.crop.interaction.guides.bottom.points([x, y + horizontalThird * 2, x + width, y + horizontalThird * 2]); }; + /** + * Update (render) the Konva rectangles used as crop box resize handles. Only the positions are updated in this + * method. + */ private updateKonvaCropInteractionHandlePositions = () => { if (!this.konva || !this.cropBox) { return; @@ -497,6 +552,10 @@ export class Editor { } }; + /** + * Update (render) the Konva rectangles used as crop box resize handles. Only the sizes and stroke widths are updated + * in this method to maintain a constant screen size regardless of zoom level. + */ private updateKonvaCropInteractionHandleScales = () => { if (!this.konva) { return; @@ -526,7 +585,10 @@ export class Editor { } }; - private updateKonvaCropBox = (cropBox: CropBox) => { + /** + * Update the crop box state and re-render all related Konva objects. + */ + private updateCropBox = (cropBox: CropBox) => { this.cropBox = cropBox; this.updateKonvaCropOverlay(); this.updateKonvaCropInteractionRect(); @@ -535,6 +597,9 @@ export class Editor { this.callbacks.onCropBoxChange?.(cropBox); }; + /** + * Update (render) the Konva background objects (the checkerboard pattern). + */ private updateKonvaBg = () => { if (!this.konva) { return; @@ -555,6 +620,9 @@ export class Editor { }); }; + /** + * Update (render) the Konva crop overlay objects (the darkened area outside the crop box). + */ private updateKonvaCropOverlay = () => { if (!this.konva?.image.image || !this.cropBox) { return; @@ -570,6 +638,11 @@ export class Editor { this.konva.crop.overlay.clear.setAttrs({ ...this.cropBox }); }; + /** + * Update (render) the Konva image object when a new image is loaded. + * + * This shouldn't be called during normal renders. + */ private updateImage = () => { if (!this.originalImage || !this.konva) { return; @@ -596,185 +669,14 @@ export class Editor { this.resetView(); }; - //#region Event Handling - private setupListeners = () => { - if (!this.konva) { - return; - } - const stage = this.konva.stage; - - stage.on('wheel', this.onWheel); - stage.on('contextmenu', this.onContextMenu); - stage.on('pointerdown', this.onPointerDown); - stage.on('pointerup', this.onPointerUp); - stage.on('pointermove', this.onPointerMove); - - this.cleanupFunctions.add(() => { - stage.off('wheel', this.onWheel); - stage.off('contextmenu', this.onContextMenu); - stage.off('pointerdown', this.onPointerDown); - stage.off('pointerup', this.onPointerUp); - stage.off('pointermove', this.onPointerMove); - }); - - window.addEventListener('keydown', this.onKeyDown); - window.addEventListener('keyup', this.onKeyUp); - - this.cleanupFunctions.add(() => { - window.removeEventListener('keydown', this.onKeyDown); - window.removeEventListener('keyup', this.onKeyUp); - }); - }; - - // Track Space key press - private onKeyDown = (e: KeyboardEvent) => { - if (!this.konva?.stage) { - return; - } - if (e.code === 'Space' && !this.isSpacePressed) { - e.preventDefault(); - this.isSpacePressed = true; - this.konva.stage.container().style.cursor = 'grab'; - } - }; - - // Zoom with mouse wheel - private onWheel = (e: KonvaEventObject) => { - if (!this.konva?.stage) { - return; - } - e.evt.preventDefault(); - - const oldScale = this.konva.stage.scaleX(); - const pointer = this.konva.stage.getPointerPosition(); - - if (!pointer) { - return; - } - - const mousePointTo = { - x: (pointer.x - this.konva.stage.x()) / oldScale, - y: (pointer.y - this.konva.stage.y()) / oldScale, - }; - - const direction = e.evt.deltaY > 0 ? -1 : 1; - const scaleBy = this.config.ZOOM_WHEEL_FACTOR; - let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; - - // Apply zoom limits - newScale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, newScale)); - - this.konva.stage.scale({ x: newScale, y: newScale }); - - const newPos = { - x: pointer.x - mousePointTo.x * newScale, - y: pointer.y - mousePointTo.y * newScale, - }; - this.konva.stage.position(newPos); - - // Update handle scaling to maintain constant screen size - this.updateKonvaCropInteractionHandleScales(); - this.updateKonvaBg(); - this.callbacks.onZoomChange?.(newScale); - }; - - private onKeyUp = (e: KeyboardEvent) => { - if (!this.konva?.stage) { - return; - } - if (e.code === 'Space') { - e.preventDefault(); - this.isSpacePressed = false; - this.isPanning = false; - // Revert cursor to default; mouseenter events will set it correctly if over an interactive element. - this.konva.stage.container().style.cursor = 'default'; - } - }; - - // Pan with Space + drag or middle mouse button - private onPointerDown = (e: KonvaEventObject) => { - if (!this.konva?.stage) { - return; - } - if (this.isSpacePressed || e.evt.button === 1) { - e.evt.preventDefault(); - e.evt.stopPropagation(); - this.isPanning = true; - this.lastPointerPosition = this.konva.stage.getPointerPosition(); - this.konva.stage.container().style.cursor = 'grabbing'; - - // Stop any active drags on crop elements - if (this.konva.crop) { - if (this.konva.crop.interaction.rect.isDragging()) { - this.konva.crop.interaction.rect.stopDrag(); - } - for (const handle of Object.values(this.konva.crop.interaction.handles)) { - if (handle.isDragging()) { - handle.stopDrag(); - } - } - } - } - }; - - private onPointerMove = (_: KonvaEventObject) => { - if (!this.konva?.stage) { - return; - } - if (!this.isPanning || !this.lastPointerPosition) { - return; - } - - const pointer = this.konva.stage.getPointerPosition(); - if (!pointer) { - return; - } - - const dx = pointer.x - this.lastPointerPosition.x; - const dy = pointer.y - this.lastPointerPosition.y; - - this.konva.stage.x(this.konva.stage.x() + dx); - this.konva.stage.y(this.konva.stage.y() + dy); - - this.updateKonvaBg(); - - this.lastPointerPosition = pointer; - }; - - private onPointerUp = (_: KonvaEventObject) => { - if (!this.konva?.stage) { - return; - } - if (this.isPanning) { - this.isPanning = false; - this.konva.stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default'; - } - }; - - private onContextMenu = (e: KonvaEventObject) => { - // Prevent context menu on right-click - e.evt.preventDefault(); - }; - //#region Event Handling - - private resizeCropBox = (handleName: HandleName, handleRect: Konva.Rect) => { - if (!this.konva) { - return; - } - - let { newX, newY, newWidth, newHeight } = this.aspectRatio - ? this._resizeCropBoxWithAspectRatio(handleName, handleRect) - : this._resizeCropBoxFree(handleName, handleRect); - - this.updateKonvaCropBox({ - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }); - }; - - private _resizeCropBoxFree = (handleName: HandleName, handleRect: Konva.Rect) => { + /** + * Calculate the next crop box dimensions when dragging a handle in freeform mode (no aspect ratio). + * + * The handle that was dragged determines which edges of the crop box are adjusted. + * + * TODO(psyche): Konva's Transformer class can handle this logic. Explore refactoring to use it. + */ + private getNextCropBoxByHandleFree = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva?.image.image || !this.cropBox) { throw new Error('Crop box or image not found'); } @@ -814,7 +716,14 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; - private _resizeCropBoxWithAspectRatio = (handleName: HandleName, handleRect: Konva.Rect) => { + /** + * Calculate the next crop box dimensions when dragging a handle in fixed aspect ratio mode. + * + * The handle that was dragged determines which edges of the crop box are adjusted. + * + * TODO(psyche): Konva's Transformer class can handle this logic. Explore refactoring to use it. + */ + private getNextCropBoxByHandleWithAspectRatio = (handleName: HandleName, handleRect: Konva.Rect) => { if (!this.konva?.image.image || !this.aspectRatio || !this.cropBox) { throw new Error('Crop box, image, or aspect ratio not found'); } @@ -853,7 +762,7 @@ export class Editor { newY: freeY, newWidth: freeWidth, newHeight: freeHeight, - } = this._resizeCropBoxFree(handleName, handleRect); + } = this.getNextCropBoxByHandleFree(handleName, handleRect); let newX = freeX; let newY = freeY; @@ -966,7 +875,202 @@ export class Editor { return { newX, newY, newWidth, newHeight }; }; + //#region Event Handling + + /** + * Set up event listeners for Konva stage (pointer) and window events (keyboard). + */ + private setupListeners = () => { + if (!this.konva) { + return; + } + + const stage = this.konva.stage; + + stage.on('wheel', this.onWheel); + stage.on('contextmenu', this.onContextMenu); + stage.on('pointerdown', this.onPointerDown); + stage.on('pointerup', this.onPointerUp); + stage.on('pointermove', this.onPointerMove); + + this.cleanupFunctions.add(() => { + stage.off('wheel', this.onWheel); + stage.off('contextmenu', this.onContextMenu); + stage.off('pointerdown', this.onPointerDown); + stage.off('pointerup', this.onPointerUp); + stage.off('pointermove', this.onPointerMove); + }); + + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + + this.cleanupFunctions.add(() => { + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + }); + }; + + /** + * Handle keydown events. + * - Space: Enable panning mode. + */ + private onKeyDown = (e: KeyboardEvent) => { + if (!this.konva?.stage) { + return; + } + if (e.code === 'Space' && !this.isSpacePressed) { + e.preventDefault(); + this.isSpacePressed = true; + this.konva.stage.container().style.cursor = 'grab'; + } + }; + + /** + * Handle keyup events. + * - Space: Disable panning mode. + */ + private onKeyUp = (e: KeyboardEvent) => { + if (!this.konva?.stage) { + return; + } + if (e.code === 'Space') { + e.preventDefault(); + this.isSpacePressed = false; + this.isPanning = false; + // Revert cursor to default; mouseenter events will set it correctly if over an interactive element. + this.konva.stage.container().style.cursor = 'default'; + } + }; + + /** + * Handle mouse wheel events for zooming in/out. + * - Zoom is centered on the mouse pointer position and constrained to min/max levels. + * - The crop box handles are rescaled to maintain a constant screen size. + * - The background pattern is rescalted to maintain a constant screen size. + */ + private onWheel = (e: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + e.evt.preventDefault(); + + const oldScale = this.konva.stage.scaleX(); + const pointer = this.konva.stage.getPointerPosition(); + + if (!pointer) { + return; + } + + const mousePointTo = { + x: (pointer.x - this.konva.stage.x()) / oldScale, + y: (pointer.y - this.konva.stage.y()) / oldScale, + }; + + const direction = e.evt.deltaY > 0 ? -1 : 1; + const scaleBy = this.config.ZOOM_WHEEL_FACTOR; + let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; + + // Apply zoom limits + newScale = Math.max(this.config.ZOOM_MIN_PCT, Math.min(this.config.ZOOM_MAX_PCT, newScale)); + + this.konva.stage.scale({ x: newScale, y: newScale }); + + const newPos = { + x: pointer.x - mousePointTo.x * newScale, + y: pointer.y - mousePointTo.y * newScale, + }; + this.konva.stage.position(newPos); + + // Update handle scaling to maintain constant screen size + this.updateKonvaCropInteractionHandleScales(); + this.updateKonvaBg(); + this.callbacks.onZoomChange?.(newScale); + }; + + /** + * Handle pointer down events to initiate panning mode if spacebar is pressed or middle mouse button is used. + * - Stops any active drags on crop elements to prevent conflicts. + */ + private onPointerDown = (e: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (this.isSpacePressed || e.evt.button === 1) { + e.evt.preventDefault(); + e.evt.stopPropagation(); + this.isPanning = true; + this.lastPointerPosition = this.konva.stage.getPointerPosition(); + this.konva.stage.container().style.cursor = 'grabbing'; + + // Stop any active drags on crop elements + if (this.konva.crop) { + if (this.konva.crop.interaction.rect.isDragging()) { + this.konva.crop.interaction.rect.stopDrag(); + } + for (const handle of Object.values(this.konva.crop.interaction.handles)) { + if (handle.isDragging()) { + handle.stopDrag(); + } + } + } + } + }; + + /** + * Handle pointer move events to pan the image when in panning mode. + */ + private onPointerMove = (_: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (!this.isPanning || !this.lastPointerPosition) { + return; + } + + const pointer = this.konva.stage.getPointerPosition(); + if (!pointer) { + return; + } + + const dx = pointer.x - this.lastPointerPosition.x; + const dy = pointer.y - this.lastPointerPosition.y; + + this.konva.stage.x(this.konva.stage.x() + dx); + this.konva.stage.y(this.konva.stage.y() + dy); + + this.updateKonvaBg(); + + this.lastPointerPosition = pointer; + }; + + /** + * Handle pointer up events to exit panning mode. + */ + private onPointerUp = (_: KonvaEventObject) => { + if (!this.konva?.stage) { + return; + } + if (this.isPanning) { + this.isPanning = false; + this.konva.stage.container().style.cursor = this.isSpacePressed ? 'grab' : 'default'; + } + }; + + /** + * Handle context menu events to prevent the default browser context menu from appearing on right-click. + */ + private onContextMenu = (e: KonvaEventObject) => { + e.evt.preventDefault(); + }; + //#region Event Handling + //#region Public API + + /** + * Load an image from a URL or data URL. + * @param src The image source URL or data URL. + * @returns A promise that resolves when the image is loaded or rejects on error. + */ loadImage = (src: string): Promise => { return new Promise((resolve, reject) => { const img = new Image(); @@ -988,8 +1092,11 @@ export class Editor { }); }; - // Crop Mode - startCrop = (crop?: CropBox) => { + /** + * Start a cropping session with an optional initial crop box. + * @param initialCrop Optional initial crop box to use. If not provided, uses the current crop box or a default centered box. + */ + startCrop = (initialCrop?: CropBox) => { if (!this.konva?.image.image || this.isCropping) { return; } @@ -1000,11 +1107,12 @@ export class Editor { let cropWidth: number; let cropHeight: number; - if (crop) { - cropX = crop.x; - cropY = crop.y; - cropWidth = crop.width; - cropHeight = crop.height; + if (initialCrop) { + // User provided initial crop + cropX = initialCrop.x; + cropY = initialCrop.y; + cropWidth = initialCrop.width; + cropHeight = initialCrop.height; } else if (this.cropBox) { // Use the current crop as starting point cropX = this.cropBox.x; @@ -1021,7 +1129,7 @@ export class Editor { cropY = (imgHeight - cropHeight) / 2; } - this.updateKonvaCropBox({ + this.updateCropBox({ x: cropX, y: cropY, width: cropWidth, @@ -1033,6 +1141,9 @@ export class Editor { this.callbacks.onCropStart?.(); }; + /** + * Cancel the current cropping session and hide crop UI. + */ cancelCrop = () => { if (!this.isCropping || !this.konva) { return; @@ -1042,6 +1153,9 @@ export class Editor { this.callbacks.onCropCancel?.(); }; + /** + * Apply the current crop box and exit cropping mode. + */ applyCrop = () => { if (!this.isCropping || !this.cropBox || !this.konva) { return; @@ -1052,9 +1166,12 @@ export class Editor { this.callbacks.onCropApply?.(this.cropBox); }; + /** + * Reset the crop box to encompass the entire image. + */ resetCrop = () => { if (this.konva?.image.image) { - this.updateKonvaCropBox({ + this.updateCropBox({ x: 0, y: 0, ...this.konva.image.image.size(), @@ -1063,12 +1180,12 @@ export class Editor { this.callbacks.onCropReset?.(); }; - // Export - exportImage = ( - format: T = 'blob' as T - ): Promise< - T extends 'canvas' ? HTMLCanvasElement : T extends 'blob' ? Blob : T extends 'dataURL' ? string : never - > => { + /** + * Export the current image, optionally cropped, in the specified format. + * @param format The output format: 'canvas', 'blob', or 'dataURL'. Defaults to 'blob'. + * @returns A promise that resolves with the exported image in the requested format. + */ + exportImage = (format: T = 'blob' as T): Promise> => { return new Promise((resolve, reject) => { if (!this.originalImage) { throw new Error('No image loaded'); @@ -1130,7 +1247,11 @@ export class Editor { }); }; - // View Control + /** + * Set the zoom level, optionally centered on a specific point. + * @param scale The target zoom scale (1 = 100%). + * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport. + */ setZoom = (scale: number, point?: { x: number; y: number }) => { if (!this.konva) { return; @@ -1174,20 +1295,34 @@ export class Editor { this.callbacks.onZoomChange?.(scale); }; + /** + * Get the current zoom level (1 = 100%). + */ getZoom = (): number => { return this.konva?.stage.scaleX() || 1; }; + /** + * Zoom in/out by a fixed factor, optionally centered on a specific point. + * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport. + */ zoomIn = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); this.setZoom(currentZoom * this.config.ZOOM_BUTTON_FACTOR, point); }; + /** + * Zoom out by a fixed factor, optionally centered on a specific point. + * @param point Optional point to center the zoom on, in stage coordinates. Defaults to center of viewport. + */ zoomOut = (point?: { x: number; y: number }) => { const currentZoom = this.getZoom(); this.setZoom(currentZoom / this.config.ZOOM_BUTTON_FACTOR, point); }; + /** + * Reset the view to 100% zoom and center the image in the container. + */ resetView = () => { if (!this.konva?.image.image) { return; @@ -1214,6 +1349,10 @@ export class Editor { this.callbacks.onZoomChange?.(1); }; + /** + * Scale the image to fit within the container while maintaining aspect ratio. + * Adds padding to ensure the image isn't flush against container edges. + */ fitToContainer = () => { if (!this.konva?.image?.image) { return; @@ -1246,7 +1385,11 @@ export class Editor { this.callbacks.onZoomChange?.(scale); }; - // Configuration + /** + * Set or update event callbacks. + * @param callbacks The callbacks to set or update. + * @param replace If true, replaces all existing callbacks. If false, merges with existing callbacks. Default is false. + */ setCallbacks = (callbacks: EditorCallbacks, replace = false) => { if (replace) { this.callbacks = callbacks; @@ -1255,8 +1398,14 @@ export class Editor { } }; + /** + * Set or update the crop aspect ratio constraint. + * @param ratio The desired aspect ratio (width / height) or null to remove the constraint. + * + * If setting a new aspect ratio, the crop box is adjusted to maintain its area while fitting within image bounds. + * Minimum size constraints are applied as needed. + */ setCropAspectRatio = (ratio: number | null) => { - // Update the constraint this.aspectRatio = ratio; if (!this.konva?.image.image || !this.cropBox) { @@ -1318,7 +1467,7 @@ export class Editor { newX = Math.max(0, Math.min(newX, imgWidth - newWidth)); newY = Math.max(0, Math.min(newY, imgHeight - newHeight)); - this.updateKonvaCropBox({ + this.updateCropBox({ x: newX, y: newY, width: newWidth, @@ -1326,11 +1475,22 @@ export class Editor { }); }; + /** + * Get the current crop aspect ratio constraint. + * @returns The current aspect ratio (width / height) or null if no constraint is set. + */ getCropAspectRatio = (): number | null => { return this.aspectRatio; }; - // Utility + /** + * Resize the editor container and adjust the Konva stage accordingly. + * + * Use this method when the container size changes (e.g., window resize) to ensure the canvas fits properly. + * + * @param width The new container width in pixels. + * @param height The new container height in pixels. + */ resize = (width: number, height: number) => { if (!this.konva) { return; @@ -1342,6 +1502,11 @@ export class Editor { this.updateKonvaBg(); }; + /** + * Destroy the editor instance, cleaning up all resources and event listeners. + * + * After calling this method, the instance should not be used again. + */ destroy = () => { for (const cleanup of this.cleanupFunctions) { cleanup(); From 00f34a00efc7052f979d1bf513b226c54871ab66 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:02:53 +1000 Subject: [PATCH 25/34] feat(ui): add concept for editable image state --- .../src/features/controlLayers/store/types.ts | 18 ++ .../src/features/controlLayers/store/util.ts | 16 ++ .../web/src/features/dnd/DndImage.tsx | 24 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 4 +- .../components/EditorContainer.tsx | 114 +++++----- .../src/features/editImageModal/lib/editor.ts | 209 ++++++++++++++---- .../features/editImageModal/store/index.ts | 6 +- .../features/parameters/store/videoSlice.ts | 8 +- .../StartingFrameImage.tsx | 71 ++++-- 9 files changed, 345 insertions(+), 125 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 1969fb77b64..5608335e110 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -37,6 +37,24 @@ export const zImageWithDims = z.object({ }); export type ImageWithDims = z.infer; +const zCropBox = z.object({ + x: z.number().int().min(0), + y: z.number().int().min(0), + width: z.number().int().positive(), + height: z.number().int().positive(), +}); +export const zCroppableImage = z.object({ + original: zImageWithDims, + crop: z + .object({ + box: zCropBox, + ratio: z.number().gt(0).nullable(), + image: zImageWithDims, + }) + .optional(), +}); +export type CroppableImageWithDims = z.infer; + const zImageWithDimsDataURL = z.object({ dataURL: z.string(), width: z.number().int().positive(), diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts index cb6e816e320..620ae6d11d1 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -10,6 +10,7 @@ import type { ChatGPT4oReferenceImageConfig, ControlLoRAConfig, ControlNetConfig, + CroppableImageWithDims, FluxKontextReferenceImageConfig, FLUXReduxConfig, Gemini2_5ReferenceImageConfig, @@ -45,6 +46,21 @@ export const imageDTOToImageWithDims = ({ image_name, width, height }: ImageDTO) height, }); +export const imageDTOToCroppableImage = ( + originalImageDTO: ImageDTO, + crop?: CroppableImageWithDims['crop'] +): CroppableImageWithDims => { + const { image_name, width, height } = originalImageDTO; + const val: CroppableImageWithDims = { + original: { image_name, width, height }, + }; + if (crop) { + val.crop = deepClone(crop); + } + + return val; +}; + export const imageDTOToImageField = ({ image_name }: ImageDTO): ImageField => ({ image_name }); const DEFAULT_RG_MASK_FILL_COLORS: RgbColor[] = [ diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 71488500b88..b3617583097 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -9,8 +9,10 @@ import { singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; +import { Editor } from 'features/editImageModal/lib/editor'; +import { openEditImageModal } from 'features/editImageModal/store'; import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; -import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; const sx = { @@ -26,12 +28,14 @@ const sx = { type Props = { imageDTO: ImageDTO; asThumbnail?: boolean; + editable?: boolean; } & ImageProps; export const DndImage = memo( - forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => { + forwardRef(({ imageDTO, asThumbnail, editable, ...rest }: Props, forwardedRef) => { const store = useAppStore(); const crossOrigin = useStore($crossOrigin); + const [previewDataURL, setPreviewDataURl] = useState(null); const [isDragging, setIsDragging] = useState(false); const ref = useRef(null); @@ -69,18 +73,32 @@ export const DndImage = memo( useImageContextMenu(imageDTO, ref); + const edit = useCallback(() => { + if (!editable) { + return; + } + + const editor = new Editor(); + editor.onCropApply(async () => { + const previewDataURL = await editor.exportImage('dataURL', { withCropOverlay: true }); + setPreviewDataURl(previewDataURL); + }); + openEditImageModal(imageDTO.image_name, editor); + }, [editable, imageDTO.image_name]); + return ( <> {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index e092e4fb80d..6e5f60017d2 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -4,7 +4,7 @@ import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerH import { getPrefixedId } from 'features/controlLayers/konva/util'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import type { BoardId } from 'features/gallery/store/types'; import { @@ -641,7 +641,7 @@ export const videoFrameFromImageDndTarget: DndTarget { const { imageDTO } = sourceData.payload; - dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO))); }, }; //#endregion diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 138fcf28543..7f707184eb6 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -12,6 +12,29 @@ type Props = { imageName: string; }; +const CROP_ASPECT_RATIO_MAP: Record = { + '16:9': 16 / 9, + '3:2': 3 / 2, + '4:3': 4 / 3, + '1:1': 1, + '3:4': 3 / 4, + '2:3': 2 / 3, + '9:16': 9 / 16, +}; + +export const getAspectRatioString = (ratio: number | null) => { + if (!ratio) { + return 'free'; + } + const entries = Object.entries(CROP_ASPECT_RATIO_MAP); + for (const [key, value] of entries) { + if (value === ratio) { + return key; + } + } + return 'free'; +}; + export const EditorContainer = ({ editor, imageName }: Props) => { const containerRef = useRef(null); const [zoom, setZoom] = useState(100); @@ -26,51 +49,44 @@ export const EditorContainer = ({ editor, imageName }: Props) => { const setup = useCallback( async (imageDTO: ImageDTO, container: HTMLDivElement) => { + console.log('Setting up editor'); editor.init(container); - editor.setCallbacks({ - onZoomChange: (zoom) => { - setZoom(zoom); - }, - onCropStart: () => { - setCropInProgress(true); - setCropBox(null); - }, - onCropBoxChange: (crop) => { - setCropBox(crop); - }, - onCropApply: () => { - setCropApplied(true); - setCropInProgress(false); - setCropBox(null); - }, - onCropReset: () => { - setCropApplied(true); - setCropInProgress(false); - setCropBox(null); - }, - onCropCancel: () => { - setCropInProgress(false); - setCropBox(null); - }, - onImageLoad: () => { - // setCropInfo(''); - // setIsCropping(false); - // setHasCropBbox(false); - }, + editor.onZoomChange((zoom) => { + setZoom(zoom); + }); + editor.onCropStart(() => { + setCropInProgress(true); + setCropBox(null); + }); + editor.onCropBoxChange((crop) => { + setCropBox(crop); + }); + editor.onCropApply(() => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }); + editor.onCropReset(() => { + setCropApplied(true); + setCropInProgress(false); + setCropBox(null); + }); + editor.onCropCancel(() => { + setCropInProgress(false); + setCropBox(null); + }); + editor.onImageLoad(() => { + // setCropInfo(''); + // setIsCropping(false); + // setHasCropBbox(false); }); const blob = await convertImageUrlToBlob(imageDTO.image_url); if (!blob) { console.error('Failed to convert image to blob'); return; } - + setAspectRatio(getAspectRatioString(editor.getCropAspectRatio())); await editor.loadImage(imageDTO.image_url); - editor.startCrop({ - x: 0, - y: 0, - width: imageDTO.width, - height: imageDTO.height, - }); editor.fitToContainer(); }, [editor] @@ -98,15 +114,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { editor.startCrop(); // Apply current aspect ratio if not free if (aspectRatio !== 'free') { - const ratios: Record = { - '1:1': 1, - '4:3': 4 / 3, - '16:9': 16 / 9, - '3:2': 3 / 2, - '2:3': 2 / 3, - '9:16': 9 / 16, - }; - editor.setCropAspectRatio(ratios[aspectRatio]); + editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[aspectRatio] ?? null); } }, [aspectRatio, editor]); @@ -116,17 +124,9 @@ export const EditorContainer = ({ editor, imageName }: Props) => { setAspectRatio(newRatio); if (newRatio === 'free') { - editor.setCropAspectRatio(undefined); + editor.setCropAspectRatio(null); } else { - const ratios: Record = { - '1:1': 1, - '4:3': 4 / 3, - '16:9': 16 / 9, - '3:2': 3 / 2, - '2:3': 2 / 3, - '9:16': 9 / 16, - }; - editor.setCropAspectRatio(ratios[newRatio]); + editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[newRatio] ?? null); } }, [editor] @@ -146,7 +146,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { const handleExport = useCallback(async () => { try { - const blob = await editor.exportImage('blob'); + const blob = await editor.exportImage('blob', { withCropOverlay: true }); const file = new File([blob], 'image.png', { type: 'image/png' }); await uploadImage({ diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 1f16c7f6d01..3cf6d28a8b5 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -18,15 +18,17 @@ export type CropBox = { * The callbacks supported by the editor. */ type EditorCallbacks = { - onCropStart?: () => void; - onCropBoxChange?: (crop: CropBox) => void; - onCropApply?: (crop: CropBox) => void; - onCropReset?: () => void; - onCropCancel?: () => void; - onZoomChange?: (zoom: number) => void; - onImageLoad?: () => void; + onCropStart: Set<() => void>; + onCropBoxChange: Set<(crop: CropBox) => void>; + onCropApply: Set<(crop: CropBox) => void>; + onCropReset: Set<() => void>; + onCropCancel: Set<() => void>; + onZoomChange: Set<(zoom: number) => void>; + onImageLoad: Set<() => void>; }; +type SetElement = T extends Set ? U : never; + /** * Crop box resize handle names. */ @@ -132,6 +134,11 @@ type EditorConfig = { */ CROP_GUIDE_STROKE_WIDTH: number; + /** + * The fill color for the crop overlay (the darkened area outside the crop box). + */ + CROP_OVERLAY_FILL_COLOR: string; + /** * When fitting the image to the container, this padding factor is applied to ensure some space around the image. */ @@ -163,6 +170,7 @@ const DEFAULT_CONFIG: EditorConfig = { CROP_HANDLE_STROKE: 'black', CROP_GUIDE_STROKE: 'rgba(255, 255, 255, 0.5)', CROP_GUIDE_STROKE_WIDTH: 1, + CROP_OVERLAY_FILL_COLOR: 'rgba(0, 0, 0, 0.8)', FIT_TO_CONTAINER_PADDING_PCT: 0.9, DEFAULT_CROP_BOX_SCALE: 0.8, ZOOM_MIN_PCT: 0.1, @@ -176,7 +184,16 @@ export class Editor { private aspectRatio: number | null = null; - private callbacks: EditorCallbacks = {}; + private callbacks: EditorCallbacks = { + onCropApply: new Set(), + onCropBoxChange: new Set(), + onCropCancel: new Set(), + onCropReset: new Set(), + onCropStart: new Set(), + onZoomChange: new Set(), + onImageLoad: new Set(), + }; + private cropBox: CropBox | null = null; // State @@ -217,6 +234,10 @@ export class Editor { }; this.setupListeners(); + + if (this.originalImage) { + this.updateImage(); + } }; /** @@ -274,8 +295,7 @@ export class Editor { private createKonvaCropOverlayObjects = (): KonvaObjects['crop']['overlay'] => { const group = new Konva.Group(); const full = new Konva.Rect({ - fill: 'black', - opacity: 0.7, + fill: this.config.CROP_OVERLAY_FILL_COLOR, }); const clear = new Konva.Rect({ fill: 'black', @@ -294,7 +314,7 @@ export class Editor { * Create the Konva objects used for crop interaction (the crop box, resize handles, and guides). */ private createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { - const group = new Konva.Group(); + const group = new Konva.Group({ visible: false }); const rect = this.createKonvaCropInteractionRect(); const handles = { @@ -589,12 +609,18 @@ export class Editor { * Update the crop box state and re-render all related Konva objects. */ private updateCropBox = (cropBox: CropBox) => { - this.cropBox = cropBox; + const { x, y, width, height } = cropBox; + this.cropBox = { + x: Math.floor(x), + y: Math.floor(y), + width: Math.floor(width), + height: Math.floor(height), + }; this.updateKonvaCropOverlay(); this.updateKonvaCropInteractionRect(); this.updateKonvaCropInteractionGuides(); this.updateKonvaCropInteractionHandlePositions(); - this.callbacks.onCropBoxChange?.(cropBox); + this._invokeCallbacks('onCropBoxChange', cropBox); }; /** @@ -667,6 +693,10 @@ export class Editor { // Center image at 100% zoom this.resetView(); + + if (this.cropBox) { + this.updateKonvaCropOverlay(); + } }; /** @@ -984,7 +1014,7 @@ export class Editor { // Update handle scaling to maintain constant screen size this.updateKonvaCropInteractionHandleScales(); this.updateKonvaBg(); - this.callbacks.onZoomChange?.(newScale); + this._invokeCallbacks('onZoomChange', newScale); }; /** @@ -1080,7 +1110,7 @@ export class Editor { img.onload = () => { this.originalImage = img; this.updateImage(); - this.callbacks.onImageLoad?.(); + this._invokeCallbacks('onImageLoad'); resolve(); }; @@ -1138,7 +1168,7 @@ export class Editor { this.isCropping = true; this.konva.crop.interaction.group.visible(true); - this.callbacks.onCropStart?.(); + this._invokeCallbacks('onCropStart'); }; /** @@ -1150,7 +1180,7 @@ export class Editor { } this.isCropping = false; this.konva.crop.interaction.group.visible(false); - this.callbacks.onCropCancel?.(); + this._invokeCallbacks('onCropCancel'); }; /** @@ -1163,7 +1193,7 @@ export class Editor { this.isCropping = false; this.konva.crop.interaction.group.visible(false); - this.callbacks.onCropApply?.(this.cropBox); + this._invokeCallbacks('onCropApply', this.cropBox); }; /** @@ -1177,15 +1207,22 @@ export class Editor { ...this.konva.image.image.size(), }); } - this.callbacks.onCropReset?.(); + this._invokeCallbacks('onCropReset'); }; /** - * Export the current image, optionally cropped, in the specified format. + * Export the current image with the current crop applied, in the specified format. + * + * If there is no crop box, the full image is exported. + * * @param format The output format: 'canvas', 'blob', or 'dataURL'. Defaults to 'blob'. * @returns A promise that resolves with the exported image in the requested format. */ - exportImage = (format: T = 'blob' as T): Promise> => { + exportImage = ( + format: T = 'blob' as T, + options?: { withCropOverlay?: boolean } + ): Promise> => { + const { withCropOverlay } = { withCropOverlay: false, ...options }; return new Promise((resolve, reject) => { if (!this.originalImage) { throw new Error('No image loaded'); @@ -1200,20 +1237,48 @@ export class Editor { try { if (this.cropBox) { - canvas.width = this.cropBox.width; - canvas.height = this.cropBox.height; - - ctx.drawImage( - this.originalImage, - this.cropBox.x, - this.cropBox.y, - this.cropBox.width, - this.cropBox.height, - 0, - 0, - this.cropBox.width, - this.cropBox.height - ); + if (!withCropOverlay) { + // Draw the cropped image + canvas.width = this.cropBox.width; + canvas.height = this.cropBox.height; + + ctx.drawImage( + this.originalImage, + this.cropBox.x, + this.cropBox.y, + this.cropBox.width, + this.cropBox.height, + 0, + 0, + this.cropBox.width, + this.cropBox.height + ); + } else { + // Draw the full image with dark overlay and clear crop area + canvas.width = this.originalImage.width; + canvas.height = this.originalImage.height; + + ctx.drawImage(this.originalImage, 0, 0); + + // We need a new canvas for the overlay to avoid messing up the original image when clearing the crop area + const overlayCanvas = document.createElement('canvas'); + overlayCanvas.width = this.originalImage.width; + overlayCanvas.height = this.originalImage.height; + + const overlayCtx = overlayCanvas.getContext('2d'); + if (!overlayCtx) { + throw new Error('Failed to get canvas context'); + } + + overlayCtx.fillStyle = this.config.CROP_OVERLAY_FILL_COLOR; + overlayCtx.fillRect(0, 0, overlayCanvas.width, overlayCanvas.height); + overlayCtx.clearRect(this.cropBox.x, this.cropBox.y, this.cropBox.width, this.cropBox.height); + + ctx.globalCompositeOperation = 'multiply'; + ctx.drawImage(overlayCanvas, 0, 0); + + overlayCanvas.remove(); + } } else { canvas.width = this.originalImage.width; canvas.height = this.originalImage.height; @@ -1292,7 +1357,7 @@ export class Editor { this.updateKonvaBg(); - this.callbacks.onZoomChange?.(scale); + this._invokeCallbacks('onZoomChange', scale); }; /** @@ -1346,7 +1411,7 @@ export class Editor { this.updateKonvaBg(); - this.callbacks.onZoomChange?.(1); + this._invokeCallbacks('onZoomChange', 1); }; /** @@ -1382,7 +1447,7 @@ export class Editor { this.updateKonvaBg(); - this.callbacks.onZoomChange?.(scale); + this._invokeCallbacks('onZoomChange', scale); }; /** @@ -1475,6 +1540,10 @@ export class Editor { }); }; + setCropBox = (box: CropBox) => { + this.updateCropBox(box); + }; + /** * Get the current crop aspect ratio constraint. * @returns The current aspect ratio (width / height) or null if no constraint is set. @@ -1483,6 +1552,66 @@ export class Editor { return this.aspectRatio; }; + /** + * Helper to build a callback registrar function for a specific event name. + * @param name The callback event name. + */ + _buildCallbackRegistrar = (name: T) => { + return (cb: SetElement): (() => void) => { + (this.callbacks[name] as Set).add(cb); + return () => { + (this.callbacks[name] as Set).delete(cb); + }; + }; + }; + + /** + * Invoke all callbacks registered for a specific event. + * @param name The callback event name. + * @param args The arguments to pass to each callback. + */ + private _invokeCallbacks = ( + name: T, + ...args: EditorCallbacks[T] extends Set<(...args: infer P) => void> ? P : never + ): void => { + const callbacks = this.callbacks[name]; + if (callbacks && callbacks.size > 0) { + callbacks.forEach((cb) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (cb as (...args: any[]) => void)(...args); + }); + } + }; + + /** + * Register a callback for when the crop is applied. + */ + onCropApply = this._buildCallbackRegistrar('onCropApply'); + /** + * Register a callback for when the crop is canceled. + */ + onCropCancel = this._buildCallbackRegistrar('onCropCancel'); + /** + * Register a callback for when the crop is reset. + */ + onCropReset = this._buildCallbackRegistrar('onCropReset'); + /** + * Register a callback for when cropping starts. + */ + onCropStart = this._buildCallbackRegistrar('onCropStart'); + /** + * Register a callback for when the crop box changes (moved or resized). + */ + onCropBoxChange = this._buildCallbackRegistrar('onCropBoxChange'); + /** + * Register a callback for when a new image is loaded. + */ + onImageLoad = this._buildCallbackRegistrar('onImageLoad'); + /** + * Register a callback for when the zoom level changes. + */ + onZoomChange = this._buildCallbackRegistrar('onZoomChange'); + /** * Resize the editor container and adjust the Konva stage accordingly. * @@ -1523,7 +1652,9 @@ export class Editor { this.konva = null; this.originalImage = null; this.cropBox = null; - this.callbacks = {}; + for (const set of Object.values(this.callbacks)) { + set.clear(); + } }; //#endregion Public API } diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts index 00c937a1a46..efff096c9e7 100644 --- a/invokeai/frontend/web/src/features/editImageModal/store/index.ts +++ b/invokeai/frontend/web/src/features/editImageModal/store/index.ts @@ -1,4 +1,4 @@ -import { Editor } from 'features/editImageModal/lib/editor'; +import type { Editor } from 'features/editImageModal/lib/editor'; import { atom } from 'nanostores'; type EditImageModalState = @@ -19,11 +19,11 @@ export const $editImageModalState = atom({ editor: null, }); -export const openEditImageModal = (imageName: string) => { +export const openEditImageModal = (imageName: string, editor: Editor) => { $editImageModalState.set({ isOpen: true, imageName, - editor: new Editor(), + editor, }); }; diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 1ad49d792bf..742cb32d5dc 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; import type { - ImageWithDims, + CroppableImageWithDims, VideoAspectRatio, VideoDuration, VideoResolution, @@ -16,7 +16,7 @@ import { isVeo3AspectRatioID, isVeo3DurationID, isVeo3Resolution, - zImageWithDims, + zCroppableImage, zVideoAspectRatio, zVideoDuration, zVideoResolution, @@ -31,7 +31,7 @@ import z from 'zod'; const zVideoState = z.object({ _version: z.literal(1), - startingFrameImage: zImageWithDims.nullable(), + startingFrameImage: zCroppableImage.nullable(), videoModel: zModelIdentifierField.nullable(), videoResolution: zVideoResolution, videoDuration: zVideoDuration, @@ -55,7 +55,7 @@ const slice = createSlice({ name: 'video', initialState: getInitialState(), reducers: { - startingFrameImageChanged: (state, action: PayloadAction) => { + startingFrameImageChanged: (state, action: PayloadAction) => { state.startingFrameImage = action.payload; }, diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index edd24341961..c65e37742f3 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -1,12 +1,14 @@ import { Box, Flex, FormLabel, Icon, IconButton, Text, Tooltip } from '@invoke-ai/ui-library'; +import { objectEquals } from '@observ33r/object-equals'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { ASPECT_RATIO_MAP } from 'features/controlLayers/store/types'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon, imageButtonSx } from 'features/dnd/DndImageIcon'; +import { Editor } from 'features/editImageModal/lib/editor'; import { openEditImageModal } from 'features/editImageModal/store'; import { selectStartingFrameImage, @@ -17,7 +19,7 @@ import { import { t } from 'i18next'; import { useCallback, useMemo } from 'react'; import { PiArrowCounterClockwiseBold, PiCropBold, PiWarningBold } from 'react-icons/pi'; -import { useImageDTO } from 'services/api/endpoints/images'; +import { useImageDTO, useUploadImageMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; const dndTargetData = videoFrameFromImageDndTarget.getData({ frame: 'start' }); @@ -26,8 +28,10 @@ export const StartingFrameImage = () => { const dispatch = useAppDispatch(); const requiresStartingFrame = useAppSelector(selectVideoModelRequiresStartingFrame); const startingFrameImage = useAppSelector(selectStartingFrameImage); - const imageDTO = useImageDTO(startingFrameImage?.image_name); + const originalImageDTO = useImageDTO(startingFrameImage?.original.image_name); + const croppedImageDTO = useImageDTO(startingFrameImage?.crop?.image.image_name); const videoAspectRatio = useAppSelector(selectVideoAspectRatio); + const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); const onReset = useCallback(() => { dispatch(startingFrameImageChanged(null)); @@ -35,25 +39,53 @@ export const StartingFrameImage = () => { const onUpload = useCallback( (imageDTO: ImageDTO) => { - dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO))); }, [dispatch] ); - const onOpenEditImageModal = useCallback(() => { - if (!imageDTO) { + const edit = useCallback(() => { + if (!originalImageDTO) { return; } - openEditImageModal(imageDTO.image_name); - }, [imageDTO]); + const editor = new Editor(); + if (startingFrameImage?.crop) { + editor.setCropBox(startingFrameImage.crop.box); + editor.setCropAspectRatio(startingFrameImage.crop.ratio); + } + editor.onCropApply(async (box) => { + if (objectEquals(box, startingFrameImage?.crop?.box)) { + return; + } + const blob = await editor.exportImage('blob'); + const file = new File([blob], 'image.png', { type: 'image/png' }); + + const newCroppedImageDTO = await uploadImage({ + file, + is_intermediate: true, + image_category: 'user', + }).unwrap(); + + dispatch( + startingFrameImageChanged( + imageDTOToCroppableImage(originalImageDTO, { + image: imageDTOToImageWithDims(newCroppedImageDTO), + box, + ratio: editor.getCropAspectRatio(), + }) + ) + ); + }); + openEditImageModal(originalImageDTO.image_name, editor); + }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); const fitsCurrentAspectRatio = useMemo(() => { - if (!imageDTO) { + if (!originalImageDTO) { return true; } - return imageDTO.width / imageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio; - }, [imageDTO, videoAspectRatio]); + return originalImageDTO.width / originalImageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio; + }, [originalImageDTO, videoAspectRatio]); return ( @@ -75,18 +107,23 @@ export const StartingFrameImage = () => { borderStyle="solid" borderColor={fitsCurrentAspectRatio ? 'base.500' : 'warning.500'} > - {!imageDTO && ( + {!originalImageDTO && ( )} - {imageDTO && ( + {originalImageDTO && ( <> - + { variant="link" sx={imageButtonSx} aria-label={t('common.crop')} - onClick={onOpenEditImageModal} + onClick={edit} icon={} tooltip={t('common.crop')} /> @@ -120,7 +157,7 @@ export const StartingFrameImage = () => { borderTopEndRadius="base" borderBottomStartRadius="base" pointerEvents="none" - >{`${imageDTO.width}x${imageDTO.height}`} + >{`${croppedImageDTO?.width ?? originalImageDTO.width}x${croppedImageDTO?.height ?? originalImageDTO.height}`} )} From e8eee5551ce363d37992244e5c7edeca94a0a7bc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:09:12 +1000 Subject: [PATCH 26/34] feat(ui): make the editor components not care about the image --- .../components/EditorContainer.tsx | 26 +++++-------------- .../features/editImageModal/store/index.ts | 7 +---- .../StartingFrameImage.tsx | 5 ++-- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 7f707184eb6..740022ecc17 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -1,15 +1,12 @@ import { Button, Divider, Flex, Select, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import type { CropBox, Editor } from 'features/editImageModal/lib/editor'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; +import { useUploadImageMutation } from 'services/api/endpoints/images'; type Props = { editor: Editor; - imageName: string; }; const CROP_ASPECT_RATIO_MAP: Record = { @@ -35,21 +32,19 @@ export const getAspectRatioString = (ratio: number | null) => { return 'free'; }; -export const EditorContainer = ({ editor, imageName }: Props) => { +export const EditorContainer = ({ editor }: Props) => { const containerRef = useRef(null); const [zoom, setZoom] = useState(100); const [cropInProgress, setCropInProgress] = useState(false); const [cropBox, setCropBox] = useState(null); const [cropApplied, setCropApplied] = useState(false); const [aspectRatio, setAspectRatio] = useState('free'); - const { data: imageDTO } = useGetImageDTOQuery(imageName); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); const setup = useCallback( - async (imageDTO: ImageDTO, container: HTMLDivElement) => { - console.log('Setting up editor'); + (container: HTMLDivElement) => { editor.init(container); editor.onZoomChange((zoom) => { setZoom(zoom); @@ -80,13 +75,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { // setIsCropping(false); // setHasCropBbox(false); }); - const blob = await convertImageUrlToBlob(imageDTO.image_url); - if (!blob) { - console.error('Failed to convert image to blob'); - return; - } setAspectRatio(getAspectRatioString(editor.getCropAspectRatio())); - await editor.loadImage(imageDTO.image_url); editor.fitToContainer(); }, [editor] @@ -94,11 +83,10 @@ export const EditorContainer = ({ editor, imageName }: Props) => { useEffect(() => { const container = containerRef.current; - if (!container || !imageDTO) { + if (!container) { return; } - editor.init(container); - setup(imageDTO, container); + setup(container); const handleResize = () => { editor.resize(container.clientWidth, container.clientHeight); }; @@ -108,7 +96,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { return () => { resizeObserver.disconnect(); }; - }, [editor, imageDTO, setup]); + }, [editor, setup]); const handleStartCrop = useCallback(() => { editor.startCrop(); @@ -146,7 +134,7 @@ export const EditorContainer = ({ editor, imageName }: Props) => { const handleExport = useCallback(async () => { try { - const blob = await editor.exportImage('blob', { withCropOverlay: true }); + const blob = await editor.exportImage('blob'); const file = new File([blob], 'image.png', { type: 'image/png' }); await uploadImage({ diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts index efff096c9e7..aa6b5e2a6ff 100644 --- a/invokeai/frontend/web/src/features/editImageModal/store/index.ts +++ b/invokeai/frontend/web/src/features/editImageModal/store/index.ts @@ -4,25 +4,21 @@ import { atom } from 'nanostores'; type EditImageModalState = | { isOpen: false; - imageName: null; editor: null; } | { isOpen: true; - imageName: string; editor: Editor; }; export const $editImageModalState = atom({ isOpen: false, - imageName: null, editor: null, }); -export const openEditImageModal = (imageName: string, editor: Editor) => { +export const openEditImageModal = (editor: Editor) => { $editImageModalState.set({ isOpen: true, - imageName, editor, }); }; @@ -32,7 +28,6 @@ export const closeEditImageModal = () => { editor?.destroy(); $editImageModalState.set({ isOpen: false, - imageName: null, editor: null, }); }; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index c65e37742f3..68e4d9c7c9a 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -44,7 +44,7 @@ export const StartingFrameImage = () => { [dispatch] ); - const edit = useCallback(() => { + const edit = useCallback(async () => { if (!originalImageDTO) { return; } @@ -76,7 +76,8 @@ export const StartingFrameImage = () => { ) ); }); - openEditImageModal(originalImageDTO.image_name, editor); + await editor.loadImage(originalImageDTO.image_url); + openEditImageModal(editor); }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); const fitsCurrentAspectRatio = useMemo(() => { From 410e68a7d352a4af731243838660a2be9bfd742e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:15:14 +1000 Subject: [PATCH 27/34] docs(ui): add comments to startingframeimage --- .../VideoSettingsAccordion/StartingFrameImage.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 68e4d9c7c9a..e22827c84ea 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -48,13 +48,23 @@ export const StartingFrameImage = () => { if (!originalImageDTO) { return; } + + // We will create a new editor instance each time the user wants to edit const editor = new Editor(); + + // Initialize w/ the existing crop & ratio if there is one if (startingFrameImage?.crop) { editor.setCropBox(startingFrameImage.crop.box); + // Due to floating point precision, we need to record the ratio separately - cannot infer from w/hof box + // TODO(psyche): figure out how to not need to save ratio separately, maybe use some "close enough" logic? editor.setCropAspectRatio(startingFrameImage.crop.ratio); } + + // When the user applies the crop, we will upload the cropped image and store the applied crop box so if the user + // re-opens the editor they see the same crop editor.onCropApply(async (box) => { if (objectEquals(box, startingFrameImage?.crop?.box)) { + // If the box hasn't changed, don't do anything return; } const blob = await editor.exportImage('blob'); @@ -76,6 +86,8 @@ export const StartingFrameImage = () => { ) ); }); + + // Load the image into the editor and open the modal once it's ready await editor.loadImage(originalImageDTO.image_url); openEditImageModal(editor); }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); From 0d21715bb8c4ea846a2673e47d55e000335f8381 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:16:29 +1000 Subject: [PATCH 28/34] fix(ui): video graphs --- .../nodes/util/graph/generation/buildRunwayVideoGraph.ts | 2 +- .../features/nodes/util/graph/generation/buildVeo3VideoGraph.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts index 77606360e48..29af7064005 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -38,7 +38,7 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn const startingFrameImage = selectStartingFrameImage(state); assert(startingFrameImage, 'Video starting frame is required for runway video generation'); - const firstFrameImageField = zImageField.parse(startingFrameImage); + const firstFrameImageField = zImageField.parse(startingFrameImage.crop?.image ?? startingFrameImage.original); const { seed, shouldRandomizeSeed } = params; const { videoDuration, videoAspectRatio, videoResolution } = videoParams; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts index c611db14ae7..b33c9cdde5c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts @@ -61,7 +61,7 @@ export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => const startingFrameImage = selectStartingFrameImage(state); if (startingFrameImage) { - const startingFrameImageField = zImageField.parse(startingFrameImage); + const startingFrameImageField = zImageField.parse(startingFrameImage.crop?.image ?? startingFrameImage.original); // @ts-expect-error: This node is not available in the OSS application veo3VideoNode.starting_image = startingFrameImageField; } From aa8f028e844d1e6205d3d5938121028f565818ef Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:30:33 +1000 Subject: [PATCH 29/34] refactor(ui): remove "apply", "start" and "cancel" concepts from editor --- invokeai/frontend/web/public/locales/en.json | 1 + .../src/features/controlLayers/store/types.ts | 4 +- .../web/src/features/dnd/DndImage.tsx | 24 +--- .../components/EditImageModal.tsx | 10 +- .../components/EditorContainer.tsx | 104 +++++--------- .../editImageModal/hooks/useEditor.ts | 33 ----- .../src/features/editImageModal/lib/editor.ts | 131 +++--------------- .../features/editImageModal/store/index.ts | 35 ++--- .../MenuItems/ContextMenuItemSendToVideo.tsx | 3 +- .../features/parameters/store/videoSlice.ts | 15 +- .../web/src/features/queue/store/readiness.ts | 2 +- .../StartingFrameImage.tsx | 29 ++-- .../layouts/LaunchpadStartingFrameButton.tsx | 4 +- 13 files changed, 112 insertions(+), 283 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0be5afbf718..f7a310fc321 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -104,6 +104,7 @@ "copy": "Copy", "copyError": "$t(gallery.copy) Error", "clipboard": "Clipboard", + "crop": "Crop", "on": "On", "off": "Off", "or": "or", diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 5608335e110..39b85ecd7c4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -43,7 +43,7 @@ const zCropBox = z.object({ width: z.number().int().positive(), height: z.number().int().positive(), }); -export const zCroppableImage = z.object({ +export const zCroppableImageWithDims = z.object({ original: zImageWithDims, crop: z .object({ @@ -53,7 +53,7 @@ export const zCroppableImage = z.object({ }) .optional(), }); -export type CroppableImageWithDims = z.infer; +export type CroppableImageWithDims = z.infer; const zImageWithDimsDataURL = z.object({ dataURL: z.string(), diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index b3617583097..71488500b88 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -9,10 +9,8 @@ import { singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; -import { Editor } from 'features/editImageModal/lib/editor'; -import { openEditImageModal } from 'features/editImageModal/store'; import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; const sx = { @@ -28,14 +26,12 @@ const sx = { type Props = { imageDTO: ImageDTO; asThumbnail?: boolean; - editable?: boolean; } & ImageProps; export const DndImage = memo( - forwardRef(({ imageDTO, asThumbnail, editable, ...rest }: Props, forwardedRef) => { + forwardRef(({ imageDTO, asThumbnail, ...rest }: Props, forwardedRef) => { const store = useAppStore(); const crossOrigin = useStore($crossOrigin); - const [previewDataURL, setPreviewDataURl] = useState(null); const [isDragging, setIsDragging] = useState(false); const ref = useRef(null); @@ -73,32 +69,18 @@ export const DndImage = memo( useImageContextMenu(imageDTO, ref); - const edit = useCallback(() => { - if (!editable) { - return; - } - - const editor = new Editor(); - editor.onCropApply(async () => { - const previewDataURL = await editor.exportImage('dataURL', { withCropOverlay: true }); - setPreviewDataURl(previewDataURL); - }); - openEditImageModal(imageDTO.image_name, editor); - }, [editable, imageDTO.image_name]); - return ( <> {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx index 73788a21501..74be5dd4eaf 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx @@ -7,13 +7,17 @@ import { EditorContainer } from './EditorContainer'; export const EditImageModal = () => { const state = useStore($editImageModalState); + if (!state) { + return null; + } + return ( - + - Edit Image + Crop Image - {state.isOpen && } + diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 740022ecc17..4141f2cae2a 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -1,12 +1,15 @@ import { Button, Divider, Flex, Select, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import type { CropBox, Editor } from 'features/editImageModal/lib/editor'; +import type { CropBox } from 'features/editImageModal/lib/editor'; +import { closeEditImageModal, type EditImageModalState } from 'features/editImageModal/store'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useUploadImageMutation } from 'services/api/endpoints/images'; type Props = { - editor: Editor; + editor: EditImageModalState['editor']; + onApplyCrop: EditImageModalState['onApplyCrop']; + onReady: EditImageModalState['onReady']; }; const CROP_ASPECT_RATIO_MAP: Record = { @@ -19,7 +22,7 @@ const CROP_ASPECT_RATIO_MAP: Record = { '9:16': 9 / 16, }; -export const getAspectRatioString = (ratio: number | null) => { +const getAspectRatioString = (ratio: number | null) => { if (!ratio) { return 'free'; } @@ -32,53 +35,32 @@ export const getAspectRatioString = (ratio: number | null) => { return 'free'; }; -export const EditorContainer = ({ editor }: Props) => { +export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { const containerRef = useRef(null); const [zoom, setZoom] = useState(100); - const [cropInProgress, setCropInProgress] = useState(false); const [cropBox, setCropBox] = useState(null); - const [cropApplied, setCropApplied] = useState(false); const [aspectRatio, setAspectRatio] = useState('free'); const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); const setup = useCallback( - (container: HTMLDivElement) => { + async (container: HTMLDivElement) => { editor.init(container); editor.onZoomChange((zoom) => { setZoom(zoom); }); - editor.onCropStart(() => { - setCropInProgress(true); - setCropBox(null); - }); editor.onCropBoxChange((crop) => { setCropBox(crop); }); - editor.onCropApply(() => { - setCropApplied(true); - setCropInProgress(false); - setCropBox(null); - }); editor.onCropReset(() => { - setCropApplied(true); - setCropInProgress(false); setCropBox(null); }); - editor.onCropCancel(() => { - setCropInProgress(false); - setCropBox(null); - }); - editor.onImageLoad(() => { - // setCropInfo(''); - // setIsCropping(false); - // setHasCropBbox(false); - }); setAspectRatio(getAspectRatioString(editor.getCropAspectRatio())); + await onReady(); editor.fitToContainer(); }, - [editor] + [editor, onReady] ); useEffect(() => { @@ -98,14 +80,6 @@ export const EditorContainer = ({ editor }: Props) => { }; }, [editor, setup]); - const handleStartCrop = useCallback(() => { - editor.startCrop(); - // Apply current aspect ratio if not free - if (aspectRatio !== 'free') { - editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[aspectRatio] ?? null); - } - }, [aspectRatio, editor]); - const handleAspectRatioChange = useCallback( (e: React.ChangeEvent) => { const newRatio = e.target.value; @@ -120,18 +94,19 @@ export const EditorContainer = ({ editor }: Props) => { [editor] ); - const handleApplyCrop = useCallback(() => { - editor.applyCrop(); - }, [editor]); - - const handleCancelCrop = useCallback(() => { - editor.cancelCrop(); - }, [editor]); - const handleResetCrop = useCallback(() => { editor.resetCrop(); }, [editor]); + const handleApplyCrop = useCallback(async () => { + await onApplyCrop(); + closeEditImageModal(); + }, [onApplyCrop]); + + const handleCancelCrop = useCallback(() => { + closeEditImageModal(); + }, []); + const handleExport = useCallback(async () => { try { const blob = await editor.exportImage('blob'); @@ -144,7 +119,6 @@ export const EditorContainer = ({ editor }: Props) => { board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId, }).unwrap(); } catch (err) { - console.error('Export failed:', err); if (err instanceof Error && err.message.includes('tainted')) { alert( 'Cannot export image: The image is from a different domain (CORS issue). To fix this:\n\n1. Load images from the same domain\n2. Use images from CORS-enabled sources\n3. Upload a local image file instead' @@ -174,23 +148,19 @@ export const EditorContainer = ({ editor }: Props) => { return ( - {!cropInProgress && } - {cropApplied && } - {cropInProgress && ( - <> - - - - - )} + {cropBox && } + + + @@ -208,13 +178,9 @@ export const EditorContainer = ({ editor }: Props) => { Mouse wheel: Zoom Space + Drag: Pan - {cropInProgress && ( - <> - - Drag crop box or handles to adjust - - )} - {cropInProgress && cropBox && ( + + Drag crop box or handles to adjust + {cropBox && ( <> diff --git a/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts b/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts deleted file mode 100644 index f753dd4a8c0..00000000000 --- a/invokeai/frontend/web/src/features/editImageModal/hooks/useEditor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Editor } from "features/editImageModal/lib/editor"; -import type { RefObject} from "react"; -import { useEffect,useState } from "react"; - -export const useEditor = (arg: { containerRef: RefObject }) => { - const editor = useState(() => new Editor())[0]; - - useEffect(() => { - const container = arg.containerRef.current; - if (container) { - editor.init(container); - - // Handle window resize - const handleResize = () => { - editor.resize(container.clientWidth, container.clientHeight); - }; - - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - } - }, [arg.containerRef, editor]); - - // Clean up editor on unmount - useEffect(() => { - return () => { - editor.destroy(); - }; - }, [editor]); - - return editor; -}; diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 3cf6d28a8b5..410066282bb 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -18,11 +18,8 @@ export type CropBox = { * The callbacks supported by the editor. */ type EditorCallbacks = { - onCropStart: Set<() => void>; onCropBoxChange: Set<(crop: CropBox) => void>; - onCropApply: Set<(crop: CropBox) => void>; onCropReset: Set<() => void>; - onCropCancel: Set<() => void>; onZoomChange: Set<(zoom: number) => void>; onImageLoad: Set<() => void>; }; @@ -185,11 +182,8 @@ export class Editor { private aspectRatio: number | null = null; private callbacks: EditorCallbacks = { - onCropApply: new Set(), onCropBoxChange: new Set(), - onCropCancel: new Set(), onCropReset: new Set(), - onCropStart: new Set(), onZoomChange: new Set(), onImageLoad: new Set(), }; @@ -197,7 +191,6 @@ export class Editor { private cropBox: CropBox | null = null; // State - private isCropping = false; private isPanning = false; private lastPointerPosition: { x: number; y: number } | null = null; private isSpacePressed = false; @@ -234,10 +227,6 @@ export class Editor { }; this.setupListeners(); - - if (this.originalImage) { - this.updateImage(); - } }; /** @@ -314,7 +303,7 @@ export class Editor { * Create the Konva objects used for crop interaction (the crop box, resize handles, and guides). */ private createKonvaCropInteractionObjects = (): KonvaObjects['crop']['interaction'] => { - const group = new Konva.Group({ visible: false }); + const group = new Konva.Group(); const rect = this.createKonvaCropInteractionRect(); const handles = { @@ -669,7 +658,7 @@ export class Editor { * * This shouldn't be called during normal renders. */ - private updateImage = () => { + private updateImage = (initial?: { cropBox: CropBox; aspectRatio: number | null }) => { if (!this.originalImage || !this.konva) { return; } @@ -694,8 +683,18 @@ export class Editor { // Center image at 100% zoom this.resetView(); - if (this.cropBox) { - this.updateKonvaCropOverlay(); + if (initial) { + this.aspectRatio = initial.aspectRatio; + this.updateCropBox(initial.cropBox); + } else { + // Create default crop box (centered, 80% of image) + const imgWidth = this.konva.image.image.width(); + const imgHeight = this.konva.image.image.height(); + const width = imgWidth * this.config.DEFAULT_CROP_BOX_SCALE; + const height = imgHeight * this.config.DEFAULT_CROP_BOX_SCALE; + const x = (imgWidth - width) / 2; + const y = (imgHeight - height) / 2; + this.updateCropBox({ x, y, width, height }); } }; @@ -1101,7 +1100,7 @@ export class Editor { * @param src The image source URL or data URL. * @returns A promise that resolves when the image is loaded or rejects on error. */ - loadImage = (src: string): Promise => { + loadImage = (src: string, initial?: { cropBox: CropBox; aspectRatio: number | null }): Promise => { return new Promise((resolve, reject) => { const img = new Image(); @@ -1109,7 +1108,7 @@ export class Editor { img.onload = () => { this.originalImage = img; - this.updateImage(); + this.updateImage(initial); this._invokeCallbacks('onImageLoad'); resolve(); }; @@ -1122,80 +1121,6 @@ export class Editor { }); }; - /** - * Start a cropping session with an optional initial crop box. - * @param initialCrop Optional initial crop box to use. If not provided, uses the current crop box or a default centered box. - */ - startCrop = (initialCrop?: CropBox) => { - if (!this.konva?.image.image || this.isCropping) { - return; - } - - // Calculate initial crop dimensions - let cropX: number; - let cropY: number; - let cropWidth: number; - let cropHeight: number; - - if (initialCrop) { - // User provided initial crop - cropX = initialCrop.x; - cropY = initialCrop.y; - cropWidth = initialCrop.width; - cropHeight = initialCrop.height; - } else if (this.cropBox) { - // Use the current crop as starting point - cropX = this.cropBox.x; - cropY = this.cropBox.y; - cropWidth = this.cropBox.width; - cropHeight = this.cropBox.height; - } else { - // Create default crop box (centered, 80% of image) - const imgWidth = this.konva.image.image.width(); - const imgHeight = this.konva.image.image.height(); - cropWidth = imgWidth * this.config.DEFAULT_CROP_BOX_SCALE; - cropHeight = imgHeight * this.config.DEFAULT_CROP_BOX_SCALE; - cropX = (imgWidth - cropWidth) / 2; - cropY = (imgHeight - cropHeight) / 2; - } - - this.updateCropBox({ - x: cropX, - y: cropY, - width: cropWidth, - height: cropHeight, - }); - this.isCropping = true; - this.konva.crop.interaction.group.visible(true); - - this._invokeCallbacks('onCropStart'); - }; - - /** - * Cancel the current cropping session and hide crop UI. - */ - cancelCrop = () => { - if (!this.isCropping || !this.konva) { - return; - } - this.isCropping = false; - this.konva.crop.interaction.group.visible(false); - this._invokeCallbacks('onCropCancel'); - }; - - /** - * Apply the current crop box and exit cropping mode. - */ - applyCrop = () => { - if (!this.isCropping || !this.cropBox || !this.konva) { - return; - } - - this.isCropping = false; - this.konva.crop.interaction.group.visible(false); - this._invokeCallbacks('onCropApply', this.cropBox); - }; - /** * Reset the crop box to encompass the entire image. */ @@ -1544,6 +1469,10 @@ export class Editor { this.updateCropBox(box); }; + getCropBox = (): CropBox | null => { + return this.cropBox; + }; + /** * Get the current crop aspect ratio constraint. * @returns The current aspect ratio (width / height) or null if no constraint is set. @@ -1583,30 +1512,21 @@ export class Editor { } }; - /** - * Register a callback for when the crop is applied. - */ - onCropApply = this._buildCallbackRegistrar('onCropApply'); - /** - * Register a callback for when the crop is canceled. - */ - onCropCancel = this._buildCallbackRegistrar('onCropCancel'); /** * Register a callback for when the crop is reset. */ onCropReset = this._buildCallbackRegistrar('onCropReset'); - /** - * Register a callback for when cropping starts. - */ - onCropStart = this._buildCallbackRegistrar('onCropStart'); + /** * Register a callback for when the crop box changes (moved or resized). */ onCropBoxChange = this._buildCallbackRegistrar('onCropBoxChange'); + /** * Register a callback for when a new image is loaded. */ onImageLoad = this._buildCallbackRegistrar('onImageLoad'); + /** * Register a callback for when the zoom level changes. */ @@ -1641,11 +1561,6 @@ export class Editor { cleanup(); } - // Cancel any ongoing crop operation - if (this.isCropping) { - this.cancelCrop(); - } - this.konva?.stage.destroy(); // Clear all references diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts index aa6b5e2a6ff..843b23270a6 100644 --- a/invokeai/frontend/web/src/features/editImageModal/store/index.ts +++ b/invokeai/frontend/web/src/features/editImageModal/store/index.ts @@ -1,33 +1,20 @@ import type { Editor } from 'features/editImageModal/lib/editor'; import { atom } from 'nanostores'; -type EditImageModalState = - | { - isOpen: false; - editor: null; - } - | { - isOpen: true; - editor: Editor; - }; +export type EditImageModalState = { + editor: Editor; + onApplyCrop: () => Promise | void; + onReady: () => Promise | void; +}; -export const $editImageModalState = atom({ - isOpen: false, - editor: null, -}); +export const $editImageModalState = atom(null); -export const openEditImageModal = (editor: Editor) => { - $editImageModalState.set({ - isOpen: true, - editor, - }); +export const openEditImageModal = (state: EditImageModalState) => { + $editImageModalState.set(state); }; export const closeEditImageModal = () => { - const { editor } = $editImageModalState.get(); - editor?.destroy(); - $editImageModalState.set({ - isOpen: false, - editor: null, - }); + const state = $editImageModalState.get(); + state?.editor.destroy(); + $editImageModalState.set(null); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx index 430b51f2ace..b59f0addd7d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx @@ -1,4 +1,5 @@ import { MenuItem } from '@invoke-ai/ui-library'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -13,7 +14,7 @@ export const ContextMenuItemSendToVideo = memo(() => { const dispatch = useDispatch(); const onClick = useCallback(() => { - dispatch(startingFrameImageChanged(imageDTO)); + dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO))); navigationApi.switchToTab('video'); }, [imageDTO, dispatch]); diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 742cb32d5dc..2d15979a990 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -16,7 +16,7 @@ import { isVeo3AspectRatioID, isVeo3DurationID, isVeo3Resolution, - zCroppableImage, + zCroppableImageWithDims, zVideoAspectRatio, zVideoDuration, zVideoResolution, @@ -30,8 +30,8 @@ import { assert } from 'tsafe'; import z from 'zod'; const zVideoState = z.object({ - _version: z.literal(1), - startingFrameImage: zCroppableImage.nullable(), + _version: z.literal(2), + startingFrameImage: zCroppableImageWithDims.nullable(), videoModel: zModelIdentifierField.nullable(), videoResolution: zVideoResolution, videoDuration: zVideoDuration, @@ -42,7 +42,7 @@ export type VideoState = z.infer; const getInitialState = (): VideoState => { return { - _version: 1, + _version: 2, startingFrameImage: null, videoModel: null, videoResolution: '1080p', @@ -119,6 +119,13 @@ export const videoSliceConfig: SliceConfig = { if (!('_version' in state)) { state._version = 1; } + if (state._version === 1) { + state._version = 2; + if (state.startingFrameImage) { + // startingFrameImage changed from ImageWithDims to CroppableImageWithDims + state.startingFrameImage = zCroppableImageWithDims.parse({ original: state.startingFrameImage }); + } + } return zVideoState.parse(state); }, }, diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 542a661b7ac..2cac5270037 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -309,7 +309,7 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); } - if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.image_name) { + if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image_name) { reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') }); } diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index e22827c84ea..7d2dffb5300 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -44,7 +44,7 @@ export const StartingFrameImage = () => { [dispatch] ); - const edit = useCallback(async () => { + const edit = useCallback(() => { if (!originalImageDTO) { return; } @@ -52,18 +52,11 @@ export const StartingFrameImage = () => { // We will create a new editor instance each time the user wants to edit const editor = new Editor(); - // Initialize w/ the existing crop & ratio if there is one - if (startingFrameImage?.crop) { - editor.setCropBox(startingFrameImage.crop.box); - // Due to floating point precision, we need to record the ratio separately - cannot infer from w/hof box - // TODO(psyche): figure out how to not need to save ratio separately, maybe use some "close enough" logic? - editor.setCropAspectRatio(startingFrameImage.crop.ratio); - } - // When the user applies the crop, we will upload the cropped image and store the applied crop box so if the user // re-opens the editor they see the same crop - editor.onCropApply(async (box) => { - if (objectEquals(box, startingFrameImage?.crop?.box)) { + const onApplyCrop = async () => { + const box = editor.getCropBox(); + if (!box || objectEquals(box, startingFrameImage?.crop?.box)) { // If the box hasn't changed, don't do anything return; } @@ -85,11 +78,17 @@ export const StartingFrameImage = () => { }) ) ); - }); + }; + + const onReady = async () => { + const initial = startingFrameImage?.crop + ? { cropBox: startingFrameImage.crop.box, aspectRatio: startingFrameImage.crop.ratio } + : undefined; + // Load the image into the editor and open the modal once it's ready + await editor.loadImage(originalImageDTO.image_url, initial); + }; - // Load the image into the editor and open the modal once it's ready - await editor.loadImage(originalImageDTO.image_url); - openEditImageModal(editor); + openEditImageModal({ editor, onApplyCrop, onReady }); }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); const fitsCurrentAspectRatio = useMemo(() => { diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx index a65d0a668dd..2f6d12ba3c8 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx @@ -1,7 +1,7 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; @@ -21,7 +21,7 @@ export const LaunchpadStartingFrameButton = memo((props: { extraAction?: () => v () => ({ onUpload: (imageDTO: ImageDTO) => { - dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + dispatch(startingFrameImageChanged(imageDTOToCroppableImage(imageDTO))); props.extraAction?.(); }, allowMultiple: false, From 6e33657292ee0256b9716afeb1e21febb2f1da6e Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:38:32 +1000 Subject: [PATCH 30/34] feat(ui): revert to original image when crop discarded --- .../web/src/features/editImageModal/lib/editor.ts | 10 ++-------- .../VideoSettingsAccordion/StartingFrameImage.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index 410066282bb..f4e78af680c 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -18,7 +18,7 @@ export type CropBox = { * The callbacks supported by the editor. */ type EditorCallbacks = { - onCropBoxChange: Set<(crop: CropBox) => void>; + onCropBoxChange: Set<(crop: Readonly) => void>; onCropReset: Set<() => void>; onZoomChange: Set<(zoom: number) => void>; onImageLoad: Set<() => void>; @@ -598,13 +598,7 @@ export class Editor { * Update the crop box state and re-render all related Konva objects. */ private updateCropBox = (cropBox: CropBox) => { - const { x, y, width, height } = cropBox; - this.cropBox = { - x: Math.floor(x), - y: Math.floor(y), - width: Math.floor(width), - height: Math.floor(height), - }; + this.cropBox = cropBox; this.updateKonvaCropOverlay(); this.updateKonvaCropInteractionRect(); this.updateKonvaCropInteractionGuides(); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 7d2dffb5300..24294b27a9c 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -56,10 +56,15 @@ export const StartingFrameImage = () => { // re-opens the editor they see the same crop const onApplyCrop = async () => { const box = editor.getCropBox(); - if (!box || objectEquals(box, startingFrameImage?.crop?.box)) { + if (objectEquals(box, startingFrameImage?.crop?.box)) { // If the box hasn't changed, don't do anything return; } + if (!box || objectEquals(box, { x: 0, y: 0, width: originalImageDTO.width, height: originalImageDTO.height })) { + // There is a crop applied but it is the whole iamge - revert to original image + dispatch(startingFrameImageChanged(imageDTOToCroppableImage(originalImageDTO))); + return; + } const blob = await editor.exportImage('blob'); const file = new File([blob], 'image.png', { type: 'image/png' }); From 644246131939eec514d40e29e7925c8cca07f3b2 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:02:37 +1000 Subject: [PATCH 31/34] fix(ui): store floats for box --- .../src/features/controlLayers/store/types.ts | 8 +- .../components/EditorContainer.tsx | 68 ++++++++----- .../src/features/editImageModal/lib/editor.ts | 95 +++++++------------ 3 files changed, 80 insertions(+), 91 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 39b85ecd7c4..76f59460257 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -38,10 +38,10 @@ export const zImageWithDims = z.object({ export type ImageWithDims = z.infer; const zCropBox = z.object({ - x: z.number().int().min(0), - y: z.number().int().min(0), - width: z.number().int().positive(), - height: z.number().int().positive(), + x: z.number().min(0), + y: z.number().min(0), + width: z.number().positive(), + height: z.number().positive(), }); export const zCroppableImageWithDims = z.object({ original: zImageWithDims, diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx index 4141f2cae2a..541c6baac13 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx @@ -1,4 +1,14 @@ -import { Button, Divider, Flex, Select, Spacer, Text } from '@invoke-ai/ui-library'; +import { + Button, + ButtonGroup, + Divider, + Flex, + FormControl, + FormLabel, + Select, + Spacer, + Text, +} from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import type { CropBox } from 'features/editImageModal/lib/editor'; import { closeEditImageModal, type EditImageModalState } from 'features/editImageModal/store'; @@ -53,9 +63,6 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { editor.onCropBoxChange((crop) => { setCropBox(crop); }); - editor.onCropReset(() => { - setCropBox(null); - }); setAspectRatio(getAspectRatioString(editor.getCropAspectRatio())); await onReady(); editor.fitToContainer(); @@ -147,27 +154,38 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { return ( - - {cropBox && } - - - - - - - - - - + + + Aspect Ratio: + + + + + + + + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts index f4e78af680c..711fd9213fd 100644 --- a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts +++ b/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts @@ -19,13 +19,9 @@ export type CropBox = { */ type EditorCallbacks = { onCropBoxChange: Set<(crop: Readonly) => void>; - onCropReset: Set<() => void>; onZoomChange: Set<(zoom: number) => void>; - onImageLoad: Set<() => void>; }; -type SetElement = T extends Set ? U : never; - /** * Crop box resize handle names. */ @@ -183,9 +179,7 @@ export class Editor { private callbacks: EditorCallbacks = { onCropBoxChange: new Set(), - onCropReset: new Set(), onZoomChange: new Set(), - onImageLoad: new Set(), }; private cropBox: CropBox | null = null; @@ -603,7 +597,9 @@ export class Editor { this.updateKonvaCropInteractionRect(); this.updateKonvaCropInteractionGuides(); this.updateKonvaCropInteractionHandlePositions(); - this._invokeCallbacks('onCropBoxChange', cropBox); + for (const cb of this.callbacks.onCropBoxChange) { + cb(cropBox); + } }; /** @@ -1007,7 +1003,9 @@ export class Editor { // Update handle scaling to maintain constant screen size this.updateKonvaCropInteractionHandleScales(); this.updateKonvaBg(); - this._invokeCallbacks('onZoomChange', newScale); + for (const cb of this.callbacks.onZoomChange) { + cb(newScale); + } }; /** @@ -1103,7 +1101,6 @@ export class Editor { img.onload = () => { this.originalImage = img; this.updateImage(initial); - this._invokeCallbacks('onImageLoad'); resolve(); }; @@ -1119,14 +1116,14 @@ export class Editor { * Reset the crop box to encompass the entire image. */ resetCrop = () => { - if (this.konva?.image.image) { - this.updateCropBox({ - x: 0, - y: 0, - ...this.konva.image.image.size(), - }); + if (!this.konva?.image.image) { + return; } - this._invokeCallbacks('onCropReset'); + this.updateCropBox({ + x: 0, + y: 0, + ...this.konva.image.image.size(), + }); }; /** @@ -1276,7 +1273,9 @@ export class Editor { this.updateKonvaBg(); - this._invokeCallbacks('onZoomChange', scale); + for (const cb of this.callbacks.onZoomChange) { + cb(scale); + } }; /** @@ -1330,7 +1329,9 @@ export class Editor { this.updateKonvaBg(); - this._invokeCallbacks('onZoomChange', 1); + for (const cb of this.callbacks.onZoomChange) { + cb(1); + } }; /** @@ -1366,7 +1367,9 @@ export class Editor { this.updateKonvaBg(); - this._invokeCallbacks('onZoomChange', scale); + for (const cb of this.callbacks.onZoomChange) { + cb(scale); + } }; /** @@ -1476,56 +1479,24 @@ export class Editor { }; /** - * Helper to build a callback registrar function for a specific event name. - * @param name The callback event name. + * Register a callback for when the crop box changes (moved or resized). */ - _buildCallbackRegistrar = (name: T) => { - return (cb: SetElement): (() => void) => { - (this.callbacks[name] as Set).add(cb); - return () => { - (this.callbacks[name] as Set).delete(cb); - }; + onCropBoxChange = (cb: (crop: Readonly) => void): (() => void) => { + this.callbacks.onCropBoxChange.add(cb); + return () => { + this.callbacks.onCropBoxChange.delete(cb); }; }; - /** - * Invoke all callbacks registered for a specific event. - * @param name The callback event name. - * @param args The arguments to pass to each callback. - */ - private _invokeCallbacks = ( - name: T, - ...args: EditorCallbacks[T] extends Set<(...args: infer P) => void> ? P : never - ): void => { - const callbacks = this.callbacks[name]; - if (callbacks && callbacks.size > 0) { - callbacks.forEach((cb) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (cb as (...args: any[]) => void)(...args); - }); - } - }; - - /** - * Register a callback for when the crop is reset. - */ - onCropReset = this._buildCallbackRegistrar('onCropReset'); - - /** - * Register a callback for when the crop box changes (moved or resized). - */ - onCropBoxChange = this._buildCallbackRegistrar('onCropBoxChange'); - - /** - * Register a callback for when a new image is loaded. - */ - onImageLoad = this._buildCallbackRegistrar('onImageLoad'); - /** * Register a callback for when the zoom level changes. */ - onZoomChange = this._buildCallbackRegistrar('onZoomChange'); - + onZoomChange = (cb: (zoom: number) => void): (() => void) => { + this.callbacks.onZoomChange.add(cb); + return () => { + this.callbacks.onZoomChange.delete(cb); + }; + }; /** * Resize the editor container and adjust the Konva stage accordingly. * From c4e817575b09f962d002f8e9af24077a2a1a56ad Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:30:23 +1000 Subject: [PATCH 32/34] tidy(ui): rename from "editor" to "cropper", minor cleanup --- .../app/components/GlobalModalIsolator.tsx | 4 +- .../components/CropImageEditor.tsx} | 64 +++++++++---------- .../components/CropImageModal.tsx} | 18 ++++-- .../{editImageModal => cropper}/lib/editor.ts | 0 .../web/src/features/cropper/store/index.ts | 26 ++++++++ .../features/editImageModal/store/index.ts | 20 ------ .../StartingFrameImage.tsx | 6 +- 7 files changed, 73 insertions(+), 65 deletions(-) rename invokeai/frontend/web/src/features/{editImageModal/components/EditorContainer.tsx => cropper/components/CropImageEditor.tsx} (79%) rename invokeai/frontend/web/src/features/{editImageModal/components/EditImageModal.tsx => cropper/components/CropImageModal.tsx} (50%) rename invokeai/frontend/web/src/features/{editImageModal => cropper}/lib/editor.ts (100%) create mode 100644 invokeai/frontend/web/src/features/cropper/store/index.ts delete mode 100644 invokeai/frontend/web/src/features/editImageModal/store/index.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index 2f0d080cb33..b5aec1dd561 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -2,11 +2,11 @@ import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys'; import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; import { CanvasPasteModal } from 'features/controlLayers/components/CanvasPasteModal'; import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { CropImageModal } from 'features/cropper/components/CropImageModal'; import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteImageModal'; import { DeleteVideoModal } from 'features/deleteVideoModal/components/DeleteVideoModal'; import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; -import { EditImageModal } from 'features/editImageModal/components/EditImageModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; import { ImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; import { VideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; @@ -59,7 +59,7 @@ export const GlobalModalIsolator = memo(() => { - + ); }); diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx similarity index 79% rename from invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx rename to invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx index 541c6baac13..8f88f9151ce 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditorContainer.tsx +++ b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx @@ -10,42 +10,35 @@ import { Text, } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import type { CropBox } from 'features/editImageModal/lib/editor'; -import { closeEditImageModal, type EditImageModalState } from 'features/editImageModal/store'; +import type { AspectRatioID } from 'features/controlLayers/store/types'; +import { ASPECT_RATIO_MAP, isAspectRatioID } from 'features/controlLayers/store/types'; +import type { CropBox } from 'features/cropper/lib/editor'; +import { cropImageModalApi, type CropImageModalState } from 'features/cropper/store'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useUploadImageMutation } from 'services/api/endpoints/images'; +import { objectEntries } from 'tsafe'; type Props = { - editor: EditImageModalState['editor']; - onApplyCrop: EditImageModalState['onApplyCrop']; - onReady: EditImageModalState['onReady']; + editor: CropImageModalState['editor']; + onApplyCrop: CropImageModalState['onApplyCrop']; + onReady: CropImageModalState['onReady']; }; -const CROP_ASPECT_RATIO_MAP: Record = { - '16:9': 16 / 9, - '3:2': 3 / 2, - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - '2:3': 2 / 3, - '9:16': 9 / 16, -}; - -const getAspectRatioString = (ratio: number | null) => { +const getAspectRatioString = (ratio: number | null): AspectRatioID => { if (!ratio) { - return 'free'; + return 'Free'; } - const entries = Object.entries(CROP_ASPECT_RATIO_MAP); + const entries = objectEntries(ASPECT_RATIO_MAP); for (const [key, value] of entries) { - if (value === ratio) { + if (value.ratio === ratio) { return key; } } - return 'free'; + return 'Free'; }; -export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { +export const CropImageEditor = memo(({ editor, onApplyCrop, onReady }: Props) => { const containerRef = useRef(null); const [zoom, setZoom] = useState(100); const [cropBox, setCropBox] = useState(null); @@ -90,12 +83,15 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { const handleAspectRatioChange = useCallback( (e: React.ChangeEvent) => { const newRatio = e.target.value; + if (!isAspectRatioID(newRatio)) { + return; + } setAspectRatio(newRatio); - if (newRatio === 'free') { + if (newRatio === 'Free') { editor.setCropAspectRatio(null); } else { - editor.setCropAspectRatio(CROP_ASPECT_RATIO_MAP[newRatio] ?? null); + editor.setCropAspectRatio(ASPECT_RATIO_MAP[newRatio]?.ratio ?? null); } }, [editor] @@ -107,11 +103,11 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { const handleApplyCrop = useCallback(async () => { await onApplyCrop(); - closeEditImageModal(); + cropImageModalApi.close(); }, [onApplyCrop]); const handleCancelCrop = useCallback(() => { - closeEditImageModal(); + cropImageModalApi.close(); }, []); const handleExport = useCallback(async () => { @@ -157,15 +153,15 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { Aspect Ratio: - + - + - - + + @@ -184,7 +180,7 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { - + @@ -212,4 +208,6 @@ export const EditorContainer = ({ editor, onApplyCrop, onReady }: Props) => { ); -}; +}); + +CropImageEditor.displayName = 'CropImageEditor'; diff --git a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx b/invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx similarity index 50% rename from invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx rename to invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx index 74be5dd4eaf..03126c55ad1 100644 --- a/invokeai/frontend/web/src/features/editImageModal/components/EditImageModal.tsx +++ b/invokeai/frontend/web/src/features/cropper/components/CropImageModal.tsx @@ -1,25 +1,29 @@ import { Modal, ModalBody, ModalContent, ModalHeader, ModalOverlay } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; -import { $editImageModalState, closeEditImageModal } from 'features/editImageModal/store'; +import { cropImageModalApi } from 'features/cropper/store'; +import { memo } from 'react'; -import { EditorContainer } from './EditorContainer'; +import { CropImageEditor } from './CropImageEditor'; -export const EditImageModal = () => { - const state = useStore($editImageModalState); +export const CropImageModal = memo(() => { + const state = useStore(cropImageModalApi.$state); if (!state) { return null; } return ( - + // This modal is always open when this component is rendered + Crop Image - + ); -}; +}); + +CropImageModal.displayName = 'CropImageModal'; diff --git a/invokeai/frontend/web/src/features/editImageModal/lib/editor.ts b/invokeai/frontend/web/src/features/cropper/lib/editor.ts similarity index 100% rename from invokeai/frontend/web/src/features/editImageModal/lib/editor.ts rename to invokeai/frontend/web/src/features/cropper/lib/editor.ts diff --git a/invokeai/frontend/web/src/features/cropper/store/index.ts b/invokeai/frontend/web/src/features/cropper/store/index.ts new file mode 100644 index 00000000000..0f063c7cca1 --- /dev/null +++ b/invokeai/frontend/web/src/features/cropper/store/index.ts @@ -0,0 +1,26 @@ +import type { Editor } from 'features/cropper/lib/editor'; +import { atom } from 'nanostores'; + +export type CropImageModalState = { + editor: Editor; + onApplyCrop: () => Promise | void; + onReady: () => Promise | void; +}; + +const $state = atom(null); + +const open = (state: CropImageModalState) => { + $state.set(state); +}; + +const close = () => { + const state = $state.get(); + state?.editor.destroy(); + $state.set(null); +}; + +export const cropImageModalApi = { + $state, + open, + close, +}; diff --git a/invokeai/frontend/web/src/features/editImageModal/store/index.ts b/invokeai/frontend/web/src/features/editImageModal/store/index.ts deleted file mode 100644 index 843b23270a6..00000000000 --- a/invokeai/frontend/web/src/features/editImageModal/store/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Editor } from 'features/editImageModal/lib/editor'; -import { atom } from 'nanostores'; - -export type EditImageModalState = { - editor: Editor; - onApplyCrop: () => Promise | void; - onReady: () => Promise | void; -}; - -export const $editImageModalState = atom(null); - -export const openEditImageModal = (state: EditImageModalState) => { - $editImageModalState.set(state); -}; - -export const closeEditImageModal = () => { - const state = $editImageModalState.get(); - state?.editor.destroy(); - $editImageModalState.set(null); -}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 24294b27a9c..839200901d1 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -4,12 +4,12 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { ASPECT_RATIO_MAP } from 'features/controlLayers/store/types'; import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { Editor } from 'features/cropper/lib/editor'; +import { cropImageModalApi } from 'features/cropper/store'; import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon, imageButtonSx } from 'features/dnd/DndImageIcon'; -import { Editor } from 'features/editImageModal/lib/editor'; -import { openEditImageModal } from 'features/editImageModal/store'; import { selectStartingFrameImage, selectVideoAspectRatio, @@ -93,7 +93,7 @@ export const StartingFrameImage = () => { await editor.loadImage(originalImageDTO.image_url, initial); }; - openEditImageModal({ editor, onApplyCrop, onReady }); + cropImageModalApi.open({ editor, onApplyCrop, onReady }); }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); const fitsCurrentAspectRatio = useMemo(() => { From c8211eb9f0d7c2062f6638849569964a4f18458b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:54:17 +1000 Subject: [PATCH 33/34] fix(ui): issue w/ setting initial aspect ratio in cropper --- .../cropper/components/CropImageEditor.tsx | 4 ++- .../web/src/features/cropper/lib/editor.ts | 19 +++++++++- .../StartingFrameImage.tsx | 36 +++++++++---------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx index 8f88f9151ce..0790608041b 100644 --- a/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx +++ b/invokeai/frontend/web/src/features/cropper/components/CropImageEditor.tsx @@ -56,7 +56,9 @@ export const CropImageEditor = memo(({ editor, onApplyCrop, onReady }: Props) => editor.onCropBoxChange((crop) => { setCropBox(crop); }); - setAspectRatio(getAspectRatioString(editor.getCropAspectRatio())); + editor.onAspectRatioChange((ratio) => { + setAspectRatio(getAspectRatioString(ratio)); + }); await onReady(); editor.fitToContainer(); }, diff --git a/invokeai/frontend/web/src/features/cropper/lib/editor.ts b/invokeai/frontend/web/src/features/cropper/lib/editor.ts index 711fd9213fd..6249e3bb255 100644 --- a/invokeai/frontend/web/src/features/cropper/lib/editor.ts +++ b/invokeai/frontend/web/src/features/cropper/lib/editor.ts @@ -19,6 +19,7 @@ export type CropBox = { */ type EditorCallbacks = { onCropBoxChange: Set<(crop: Readonly) => void>; + onAspectRatioChange: Set<(ratio: number | null) => void>; onZoomChange: Set<(zoom: number) => void>; }; @@ -180,6 +181,7 @@ export class Editor { private callbacks: EditorCallbacks = { onCropBoxChange: new Set(), onZoomChange: new Set(), + onAspectRatioChange: new Set(), }; private cropBox: CropBox | null = null; @@ -674,7 +676,7 @@ export class Editor { this.resetView(); if (initial) { - this.aspectRatio = initial.aspectRatio; + this.setCropAspectRatio(initial.aspectRatio); this.updateCropBox(initial.cropBox); } else { // Create default crop box (centered, 80% of image) @@ -1460,6 +1462,10 @@ export class Editor { width: newWidth, height: newHeight, }); + + for (const cb of this.callbacks.onAspectRatioChange) { + cb(ratio); + } }; setCropBox = (box: CropBox) => { @@ -1497,6 +1503,17 @@ export class Editor { this.callbacks.onZoomChange.delete(cb); }; }; + + /** + * Register a callback for when the aspect ratio changes. + */ + onAspectRatioChange = (cb: (ratio: number | null) => void): (() => void) => { + this.callbacks.onAspectRatioChange.add(cb); + return () => { + this.callbacks.onAspectRatioChange.delete(cb); + }; + }; + /** * Resize the editor container and adjust the Konva stage accordingly. * diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 839200901d1..fa36e6f091d 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, FormLabel, Icon, IconButton, Text, Tooltip } from '@invoke-ai/ui-library'; +import { Flex, FormLabel, Icon, IconButton, Text, Tooltip } from '@invoke-ai/ui-library'; import { objectEquals } from '@observ33r/object-equals'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; @@ -97,33 +97,31 @@ export const StartingFrameImage = () => { }, [dispatch, originalImageDTO, startingFrameImage?.crop, uploadImage]); const fitsCurrentAspectRatio = useMemo(() => { - if (!originalImageDTO) { + const imageDTO = croppedImageDTO ?? originalImageDTO; + if (!imageDTO) { return true; } - return originalImageDTO.width / originalImageDTO.height === ASPECT_RATIO_MAP[videoAspectRatio]?.ratio; - }, [originalImageDTO, videoAspectRatio]); + const imageRatio = imageDTO.width / imageDTO.height; + const targetRatio = ASPECT_RATIO_MAP[videoAspectRatio].ratio; + + // Call it a fit if the image is within 10% of the target aspect ratio + return Math.abs((imageRatio - targetRatio) / targetRatio) < 0.1; + }, [croppedImageDTO, originalImageDTO, videoAspectRatio]); return ( {t('parameters.startingFrameImage')} - - - - - + {!fitsCurrentAspectRatio && ( + + + + + + )} - + {!originalImageDTO && ( Date: Wed, 17 Sep 2025 13:24:58 +1000 Subject: [PATCH 34/34] feat(ui): make ref images croppable --- .../listeners/modelsLoaded.ts | 12 +- .../components/RefImage/RefImageImage.tsx | 97 +++++++++++++++-- .../components/RefImage/RefImageList.tsx | 4 +- .../components/RefImage/RefImagePreview.tsx | 8 +- .../components/RefImage/RefImageSettings.tsx | 14 ++- .../RegionalGuidanceIPAdapterSettings.tsx | 7 +- .../RegionalGuidanceRefImageImage.tsx | 103 ++++++++++++++++++ .../controlLayers/hooks/addLayerHooks.ts | 6 +- .../controlLayers/hooks/saveCanvasHooks.ts | 11 +- .../controlLayers/store/canvasSlice.ts | 23 ++-- .../controlLayers/store/refImagesSlice.ts | 14 ++- .../src/features/controlLayers/store/types.ts | 80 +++++++++++--- .../src/features/controlLayers/store/util.ts | 12 +- .../features/deleteImageModal/store/state.ts | 12 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 4 +- .../ContextMenuItemUseAsRefImage.tsx | 4 +- .../web/src/features/imageActions/actions.ts | 9 +- .../web/src/features/metadata/parsing.tsx | 2 +- .../util/graph/generation/addFLUXRedux.ts | 2 +- .../util/graph/generation/addIPAdapters.ts | 4 +- .../nodes/util/graph/generation/addRegions.ts | 8 +- .../graph/generation/buildChatGPT4oGraph.ts | 2 +- .../graph/generation/buildFluxKontextGraph.ts | 2 +- .../graph/generation/buildGemini2_5Graph.ts | 2 +- .../web/src/features/queue/store/readiness.ts | 2 +- .../StartingFrameImage.tsx | 4 +- .../ui/layouts/LaunchpadAddStyleReference.tsx | 4 +- .../web/src/services/api/endpoints/images.ts | 8 ++ 28 files changed, 368 insertions(+), 92 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceRefImageImage.tsx diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index 7a9fcc1c694..62f398b5ed8 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -12,7 +12,13 @@ import { } from 'features/controlLayers/store/paramsSlice'; import { refImageModelChanged, selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice'; import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { getEntityIdentifier, isFLUXReduxConfig, isIPAdapterConfig } from 'features/controlLayers/store/types'; +import { + getEntityIdentifier, + isFLUXReduxConfig, + isIPAdapterConfig, + isRegionalGuidanceFLUXReduxConfig, + isRegionalGuidanceIPAdapterConfig, +} from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import { modelSelected } from 'features/parameters/store/actions'; import { @@ -252,7 +258,7 @@ const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { entity.referenceImages.forEach(({ id: referenceImageId, config }) => { - if (!isIPAdapterConfig(config)) { + if (!isRegionalGuidanceIPAdapterConfig(config)) { return; } @@ -295,7 +301,7 @@ const handleFLUXReduxModels: ModelHandler = (models, state, dispatch, log) => { selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { entity.referenceImages.forEach(({ id: referenceImageId, config }) => { - if (!isFLUXReduxConfig(config)) { + if (!isRegionalGuidanceFLUXReduxConfig(config)) { return; } diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx index e0042de290d..a754f0e4da4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageImage.tsx @@ -1,12 +1,16 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; +import { objectEquals } from '@observ33r/object-equals'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { bboxSizeOptimized, bboxSizeRecalled } from 'features/controlLayers/store/canvasSlice'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; import { sizeOptimized, sizeRecalled } from 'features/controlLayers/store/paramsSlice'; -import type { ImageWithDims } from 'features/controlLayers/store/types'; +import type { CroppableImageWithDims } from 'features/controlLayers/store/types'; +import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { Editor } from 'features/cropper/lib/editor'; +import { cropImageModalApi } from 'features/cropper/store'; import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; @@ -14,14 +18,14 @@ import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiArrowCounterClockwiseBold, PiRulerBold } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { PiArrowCounterClockwiseBold, PiCropBold, PiRulerBold } from 'react-icons/pi'; +import { useGetImageDTOQuery, useUploadImageMutation } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; type Props = { - image: ImageWithDims | null; - onChangeImage: (imageDTO: ImageDTO | null) => void; + image: CroppableImageWithDims | null; + onChangeImage: (croppableImage: CroppableImageWithDims | null) => void; dndTarget: T; dndTargetData: ReturnType; }; @@ -38,20 +42,28 @@ export const RefImageImage = memo( const isConnected = useStore($isConnected); const tab = useAppSelector(selectActiveTab); const isStaging = useCanvasIsStaging(); - const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); + const imageWithDims = image?.crop?.image ?? image?.original.image ?? null; + const croppedImageDTOReq = useGetImageDTOQuery(image?.crop?.image?.image_name ?? skipToken); + const originalImageDTOReq = useGetImageDTOQuery(image?.original.image.image_name ?? skipToken); + const [uploadImage] = useUploadImageMutation(); + + const originalImageDTO = originalImageDTOReq.currentData; + const croppedImageDTO = croppedImageDTOReq.currentData; + const imageDTO = croppedImageDTO ?? originalImageDTO; + const handleResetControlImage = useCallback(() => { onChangeImage(null); }, [onChangeImage]); useEffect(() => { - if (isConnected && isError) { + if ((isConnected && croppedImageDTOReq.isError) || originalImageDTOReq.isError) { handleResetControlImage(); } - }, [handleResetControlImage, isError, isConnected]); + }, [handleResetControlImage, isConnected, croppedImageDTOReq.isError, originalImageDTOReq.isError]); const onUpload = useCallback( (imageDTO: ImageDTO) => { - onChangeImage(imageDTO); + onChangeImage(imageDTOToCroppableImage(imageDTO)); }, [onChangeImage] ); @@ -70,13 +82,67 @@ export const RefImageImage = memo( } }, [imageDTO, isStaging, store, tab]); + const edit = useCallback(() => { + if (!originalImageDTO) { + return; + } + + // We will create a new editor instance each time the user wants to edit + const editor = new Editor(); + + // When the user applies the crop, we will upload the cropped image and store the applied crop box so if the user + // re-opens the editor they see the same crop + const onApplyCrop = async () => { + const box = editor.getCropBox(); + if (objectEquals(box, image?.crop?.box)) { + // If the box hasn't changed, don't do anything + return; + } + if (!box || objectEquals(box, { x: 0, y: 0, width: originalImageDTO.width, height: originalImageDTO.height })) { + // There is a crop applied but it is the whole iamge - revert to original image + onChangeImage(imageDTOToCroppableImage(originalImageDTO)); + return; + } + const blob = await editor.exportImage('blob'); + const file = new File([blob], 'image.png', { type: 'image/png' }); + + const newCroppedImageDTO = await uploadImage({ + file, + is_intermediate: true, + image_category: 'user', + }).unwrap(); + + onChangeImage( + imageDTOToCroppableImage(originalImageDTO, { + image: imageDTOToImageWithDims(newCroppedImageDTO), + box, + ratio: editor.getCropAspectRatio(), + }) + ); + }; + + const onReady = async () => { + const initial = image?.crop ? { cropBox: image.crop.box, aspectRatio: image.crop.ratio } : undefined; + // Load the image into the editor and open the modal once it's ready + await editor.loadImage(originalImageDTO.image_url, initial); + }; + + cropImageModalApi.open({ editor, onApplyCrop, onReady }); + }, [image?.crop, onChangeImage, originalImageDTO, uploadImage]); + return ( - + {!imageDTO && ( @@ -99,6 +165,15 @@ export const RefImageImage = memo( isDisabled={!imageDTO || (tab === 'canvas' && isStaging)} /> + + + } + tooltip={t('common.crop')} + isDisabled={!imageDTO} + /> + )} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx index d5f39a111b8..6683e247b05 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImageList.tsx @@ -13,7 +13,7 @@ import { selectRefImageEntityIds, selectSelectedRefEntityId, } from 'features/controlLayers/store/refImagesSlice'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -92,7 +92,7 @@ const AddRefImageDropTargetAndButton = memo(() => { ({ onUpload: (imageDTO: ImageDTO) => { const config = getDefaultRefImageConfig(getState); - config.image = imageDTOToImageWithDims(imageDTO); + config.image = imageDTOToCroppableImage(imageDTO); dispatch(refImageAdded({ overrides: { config } })); }, allowMultiple: false, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx index 4439e04ea13..0d9bd14955a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx @@ -1,6 +1,5 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, Icon, IconButton, Image, Skeleton, Text, Tooltip } from '@invoke-ai/ui-library'; -import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { round } from 'es-toolkit/compat'; import { useRefImageEntity } from 'features/controlLayers/components/RefImage/useRefImageEntity'; @@ -15,7 +14,7 @@ import { isIPAdapterConfig } from 'features/controlLayers/store/types'; import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi'; -import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useImageDTOFromCroppableImage } from 'services/api/endpoints/images'; import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent'; @@ -72,7 +71,8 @@ export const RefImagePreview = memo(() => { const selectedEntityId = useAppSelector(selectSelectedRefEntityId); const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen); const [showWeightDisplay, setShowWeightDisplay] = useState(false); - const { data: imageDTO } = useGetImageDTOQuery(entity.config.image?.image_name ?? skipToken); + + const imageDTO = useImageDTOFromCroppableImage(entity.config.image); const sx = useMemo(() => { if (!isIPAdapterConfig(entity.config)) { @@ -145,7 +145,7 @@ export const RefImagePreview = memo(() => { overflow="hidden" > { ); const onChangeImage = useCallback( - (imageDTO: ImageDTO | null) => { - dispatch(refImageImageChanged({ id, imageDTO })); + (croppableImage: CroppableImageWithDims | null) => { + dispatch(refImageImageChanged({ id, croppableImage })); }, [dispatch, id] ); const dndTargetData = useMemo( - () => setGlobalReferenceImageDndTarget.getData({ id }, config.image?.image_name), - [id, config.image?.image_name] + () => + setGlobalReferenceImageDndTarget.getData( + { id }, + config.image?.crop?.image.image_name ?? config.image?.original.image.image_name + ), + [id, config.image?.crop?.image.image_name, config.image?.original.image.image_name] ); const isFLUX = useAppSelector(selectIsFLUX); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx index c49a5a1658e..402487bd39d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -6,7 +6,6 @@ import { FLUXReduxImageInfluence } from 'features/controlLayers/components/commo import { IPAdapterCLIPVisionModel } from 'features/controlLayers/components/common/IPAdapterCLIPVisionModel'; import { Weight } from 'features/controlLayers/components/common/Weight'; import { IPAdapterMethod } from 'features/controlLayers/components/RefImage/IPAdapterMethod'; -import { RefImageImage } from 'features/controlLayers/components/RefImage/RefImageImage'; import { RegionalGuidanceIPAdapterSettingsEmptyState } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettingsEmptyState'; import { RegionalReferenceImageModel } from 'features/controlLayers/components/RegionalGuidance/RegionalReferenceImageModel'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -37,6 +36,8 @@ import { PiBoundingBoxBold, PiXBold } from 'react-icons/pi'; import type { FLUXReduxModelConfig, ImageDTO, IPAdapterModelConfig } from 'services/api/types'; import { assert } from 'tsafe'; +import { RegionalGuidanceRefImageImage } from './RegionalGuidanceRefImageImage'; + type Props = { referenceImageId: string; }; @@ -114,7 +115,7 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro { entityIdentifier, referenceImageId }, config.image?.image_name ), - [entityIdentifier, config.image?.image_name, referenceImageId] + [entityIdentifier, config.image, referenceImageId] ); const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId); @@ -170,7 +171,7 @@ const RegionalGuidanceIPAdapterSettingsContent = memo(({ referenceImageId }: Pro )} - void; + dndTarget: typeof setRegionalGuidanceReferenceImageDndTarget; + dndTargetData: ReturnType<(typeof setRegionalGuidanceReferenceImageDndTarget)['getData']>; +}; + +export const RegionalGuidanceRefImageImage = memo(({ image, onChangeImage, dndTarget, dndTargetData }: Props) => { + const { t } = useTranslation(); + const store = useAppStore(); + const isConnected = useStore($isConnected); + const tab = useAppSelector(selectActiveTab); + const isStaging = useCanvasIsStaging(); + const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); + const handleResetControlImage = useCallback(() => { + onChangeImage(null); + }, [onChangeImage]); + + useEffect(() => { + if (isConnected && isError) { + handleResetControlImage(); + } + }, [handleResetControlImage, isError, isConnected]); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + onChangeImage(imageDTO); + }, + [onChangeImage] + ); + + const recallSizeAndOptimize = useCallback(() => { + if (!imageDTO || (tab === 'canvas' && isStaging)) { + return; + } + const { width, height } = imageDTO; + if (tab === 'canvas') { + store.dispatch(bboxSizeRecalled({ width, height })); + store.dispatch(bboxSizeOptimized()); + } else if (tab === 'generate') { + store.dispatch(sizeRecalled({ width, height })); + store.dispatch(sizeOptimized()); + } + }, [imageDTO, isStaging, store, tab]); + + return ( + + {!imageDTO && ( + + )} + {imageDTO && ( + <> + + + } + tooltip={t('common.reset')} + /> + + + } + tooltip={t('parameters.useSize')} + isDisabled={!imageDTO || (tab === 'canvas' && isStaging)} + /> + + + )} + + + ); +}); + +RegionalGuidanceRefImageImage.displayName = 'RegionalGuidanceRefImageImage'; diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts index 75b424ed286..062937edcd0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts @@ -30,6 +30,7 @@ import type { FluxKontextReferenceImageConfig, Gemini2_5ReferenceImageConfig, IPAdapterConfig, + RegionalGuidanceIPAdapterConfig, T2IAdapterConfig, } from 'features/controlLayers/store/types'; import { @@ -38,6 +39,7 @@ import { initialFluxKontextReferenceImage, initialGemini2_5ReferenceImage, initialIPAdapter, + initialRegionalGuidanceIPAdapter, initialT2IAdapter, } from 'features/controlLayers/store/util'; import { zModelIdentifierField } from 'features/nodes/types/common'; @@ -125,7 +127,7 @@ export const getDefaultRefImageConfig = ( return config; }; -export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): IPAdapterConfig => { +export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): RegionalGuidanceIPAdapterConfig => { // Regional guidance ref images do not support ChatGPT-4o, so we always return the IP Adapter config. const state = getState(); @@ -138,7 +140,7 @@ export const getDefaultRegionalGuidanceRefImageConfig = (getState: AppGetState): const modelConfig = ipAdapterModelConfigs.find((m) => m.base === base); // Clone the initial IP Adapter config and set the model if available. - const config = deepClone(initialIPAdapter); + const config = deepClone(initialRegionalGuidanceIPAdapter); if (modelConfig) { config.model = zModelIdentifierField.parse(modelConfig); diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts index f164b1f2f16..6b089c4592b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/saveCanvasHooks.ts @@ -32,7 +32,12 @@ import type { RefImageState, RegionalGuidanceRefImageState, } from 'features/controlLayers/store/types'; -import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util'; +import { + imageDTOToCroppableImage, + imageDTOToImageObject, + imageDTOToImageWithDims, + initialControlNet, +} from 'features/controlLayers/store/util'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; import type { BoardId } from 'features/gallery/store/types'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; @@ -209,7 +214,7 @@ export const useNewGlobalReferenceImageFromBbox = () => { const overrides: Partial = { config: { ...getDefaultRefImageConfig(getState), - image: imageDTOToImageWithDims(imageDTO), + image: imageDTOToCroppableImage(imageDTO), }, }; dispatch(refImageAdded({ overrides })); @@ -312,7 +317,7 @@ export const usePullBboxIntoGlobalReferenceImage = (id: string) => { const arg = useMemo(() => { const onSave = (imageDTO: ImageDTO, _: Rect) => { - dispatch(refImageImageChanged({ id, imageDTO })); + dispatch(refImageImageChanged({ id, croppableImage: imageDTOToCroppableImage(imageDTO) })); }; return { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 8c8987d5462..ee1a9c6ba44 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -82,10 +82,10 @@ import { IMAGEN_ASPECT_RATIOS, isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, - isFLUXReduxConfig, isGemini2_5AspectRatioID, isImagenAspectRatioID, - isIPAdapterConfig, + isRegionalGuidanceFLUXReduxConfig, + isRegionalGuidanceIPAdapterConfig, zCanvasState, } from './types'; import { @@ -99,6 +99,7 @@ import { initialControlNet, initialFLUXRedux, initialIPAdapter, + initialRegionalGuidanceIPAdapter, initialT2IAdapter, makeDefaultRasterLayerAdjustments, } from './util'; @@ -804,7 +805,7 @@ const slice = createSlice({ if (!entity) { return; } - const config = { id: referenceImageId, config: deepClone(initialIPAdapter) }; + const config = { id: referenceImageId, config: deepClone(initialRegionalGuidanceIPAdapter) }; merge(config, overrides); entity.referenceImages.push(config); }, @@ -847,7 +848,7 @@ const slice = createSlice({ if (!referenceImage) { return; } - if (!isIPAdapterConfig(referenceImage.config)) { + if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) { return; } @@ -864,7 +865,7 @@ const slice = createSlice({ if (!referenceImage) { return; } - if (!isIPAdapterConfig(referenceImage.config)) { + if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) { return; } referenceImage.config.beginEndStepPct = beginEndStepPct; @@ -880,7 +881,7 @@ const slice = createSlice({ if (!referenceImage) { return; } - if (!isIPAdapterConfig(referenceImage.config)) { + if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) { return; } referenceImage.config.method = method; @@ -899,7 +900,7 @@ const slice = createSlice({ if (!referenceImage) { return; } - if (!isFLUXReduxConfig(referenceImage.config)) { + if (!isRegionalGuidanceFLUXReduxConfig(referenceImage.config)) { return; } @@ -928,7 +929,7 @@ const slice = createSlice({ return; } - if (isIPAdapterConfig(referenceImage.config) && isFluxReduxModelConfig(modelConfig)) { + if (isRegionalGuidanceIPAdapterConfig(referenceImage.config) && isFluxReduxModelConfig(modelConfig)) { // Switching from ip_adapter to flux_redux referenceImage.config = { ...initialFLUXRedux, @@ -938,7 +939,7 @@ const slice = createSlice({ return; } - if (isFLUXReduxConfig(referenceImage.config) && isIPAdapterModelConfig(modelConfig)) { + if (isRegionalGuidanceFLUXReduxConfig(referenceImage.config) && isIPAdapterModelConfig(modelConfig)) { // Switching from flux_redux to ip_adapter referenceImage.config = { ...initialIPAdapter, @@ -948,7 +949,7 @@ const slice = createSlice({ return; } - if (isIPAdapterConfig(referenceImage.config)) { + if (isRegionalGuidanceIPAdapterConfig(referenceImage.config)) { referenceImage.config.model = zModelIdentifierField.parse(modelConfig); // Ensure that the IP Adapter model is compatible with the CLIP Vision model @@ -971,7 +972,7 @@ const slice = createSlice({ if (!referenceImage) { return; } - if (!isIPAdapterConfig(referenceImage.config)) { + if (!isRegionalGuidanceIPAdapterConfig(referenceImage.config)) { return; } referenceImage.config.clipVisionModel = clipVisionModel; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts index 539d94ac33a..e787d08fca0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts @@ -6,13 +6,16 @@ import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { clamp } from 'es-toolkit/compat'; import { getPrefixedId } from 'features/controlLayers/konva/util'; -import type { FLUXReduxImageInfluence, RefImagesState } from 'features/controlLayers/store/types'; +import type { + CroppableImageWithDims, + FLUXReduxImageInfluence, + RefImagesState, +} from 'features/controlLayers/store/types'; import { zModelIdentifierField } from 'features/nodes/types/common'; import type { ChatGPT4oModelConfig, FLUXKontextModelConfig, FLUXReduxModelConfig, - ImageDTO, IPAdapterModelConfig, } from 'services/api/types'; import { assert } from 'tsafe'; @@ -22,7 +25,6 @@ import type { CLIPVisionModelV2, IPMethodV2, RefImageState } from './types'; import { getInitialRefImagesState, isFLUXReduxConfig, isIPAdapterConfig, zRefImagesState } from './types'; import { getReferenceImageState, - imageDTOToImageWithDims, initialChatGPT4oReferenceImage, initialFluxKontextReferenceImage, initialFLUXRedux, @@ -65,13 +67,13 @@ const slice = createSlice({ state.entities.push(...entities); } }, - refImageImageChanged: (state, action: PayloadActionWithId<{ imageDTO: ImageDTO | null }>) => { - const { id, imageDTO } = action.payload; + refImageImageChanged: (state, action: PayloadActionWithId<{ croppableImage: CroppableImageWithDims | null }>) => { + const { id, croppableImage } = action.payload; const entity = selectRefImageEntity(state, id); if (!entity) { return; } - entity.config.image = imageDTO ? imageDTOToImageWithDims(imageDTO) : null; + entity.config.image = croppableImage; }, refImageIPAdapterMethodChanged: (state, action: PayloadActionWithId<{ method: IPMethodV2 }>) => { const { id, method } = action.payload; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 76f59460257..3163bd85b2a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -43,16 +43,37 @@ const zCropBox = z.object({ width: z.number().positive(), height: z.number().positive(), }); -export const zCroppableImageWithDims = z.object({ - original: zImageWithDims, - crop: z - .object({ - box: zCropBox, - ratio: z.number().gt(0).nullable(), - image: zImageWithDims, - }) - .optional(), -}); +// This new schema is an extension of zImageWithDims, with an optional crop field. +// +// When we added cropping support to certain entities (e.g. Ref Images, video Starting Frame Image), we changed +// their schemas from using zImageWithDims to this new schema. To support loading pre-existing entities that +// were created before cropping was supported, we can use zod's preprocess to transform old data into the new format. +// Its essentially a data migration step. +// +// This parsing happens currently in two places: +// - Recalling metadata. +// - Loading/rehydrating persisted client state from storage. +export const zCroppableImageWithDims = z.preprocess( + (val) => { + try { + const imageWithDims = zImageWithDims.parse(val); + const migrated = { original: { image: deepClone(imageWithDims) } }; + return migrated; + } catch { + return val; + } + }, + z.object({ + original: z.object({ image: zImageWithDims }), + crop: z + .object({ + box: zCropBox, + ratio: z.number().gt(0).nullable(), + image: zImageWithDims, + }) + .optional(), + }) +); export type CroppableImageWithDims = z.infer; const zImageWithDimsDataURL = z.object({ @@ -253,7 +274,7 @@ export type CanvasObjectState = z.infer; const zIPAdapterConfig = z.object({ type: z.literal('ip_adapter'), - image: zImageWithDims.nullable(), + image: zCroppableImageWithDims.nullable(), model: zModelIdentifierField.nullable(), weight: z.number().gte(-1).lte(2), beginEndStepPct: zBeginEndStepPct, @@ -262,21 +283,39 @@ const zIPAdapterConfig = z.object({ }); export type IPAdapterConfig = z.infer; +const zRegionalGuidanceIPAdapterConfig = z.object({ + type: z.literal('ip_adapter'), + image: zImageWithDims.nullable(), + model: zModelIdentifierField.nullable(), + weight: z.number().gte(-1).lte(2), + beginEndStepPct: zBeginEndStepPct, + method: zIPMethodV2, + clipVisionModel: zCLIPVisionModelV2, +}); +export type RegionalGuidanceIPAdapterConfig = z.infer; + const zFLUXReduxImageInfluence = z.enum(['lowest', 'low', 'medium', 'high', 'highest']); export const isFLUXReduxImageInfluence = (v: unknown): v is FLUXReduxImageInfluence => zFLUXReduxImageInfluence.safeParse(v).success; export type FLUXReduxImageInfluence = z.infer; const zFLUXReduxConfig = z.object({ type: z.literal('flux_redux'), - image: zImageWithDims.nullable(), + image: zCroppableImageWithDims.nullable(), model: zModelIdentifierField.nullable(), imageInfluence: zFLUXReduxImageInfluence.default('highest'), }); export type FLUXReduxConfig = z.infer; +const zRegionalGuidanceFLUXReduxConfig = z.object({ + type: z.literal('flux_redux'), + image: zImageWithDims.nullable(), + model: zModelIdentifierField.nullable(), + imageInfluence: zFLUXReduxImageInfluence.default('highest'), +}); +type RegionalGuidanceFLUXReduxConfig = z.infer; const zChatGPT4oReferenceImageConfig = z.object({ type: z.literal('chatgpt_4o_reference_image'), - image: zImageWithDims.nullable(), + image: zCroppableImageWithDims.nullable(), /** * TODO(psyche): Technically there is no model for ChatGPT 4o reference images - it's just a field in the API call. * But we use a model drop down to switch between different ref image types, so there needs to be a model here else @@ -288,14 +327,14 @@ export type ChatGPT4oReferenceImageConfig = z.infer; const zFluxKontextReferenceImageConfig = z.object({ type: z.literal('flux_kontext_reference_image'), - image: zImageWithDims.nullable(), + image: zCroppableImageWithDims.nullable(), model: zModelIdentifierField.nullable(), }); export type FluxKontextReferenceImageConfig = z.infer; @@ -325,6 +364,7 @@ export const isIPAdapterConfig = (config: RefImageState['config']): config is IP export const isFLUXReduxConfig = (config: RefImageState['config']): config is FLUXReduxConfig => config.type === 'flux_redux'; + export const isChatGPT4oReferenceImageConfig = ( config: RefImageState['config'] ): config is ChatGPT4oReferenceImageConfig => config.type === 'chatgpt_4o_reference_image'; @@ -344,10 +384,18 @@ const zFill = z.object({ style: zFillStyle, color: zRgbColor }); const zRegionalGuidanceRefImageState = z.object({ id: zId, - config: z.discriminatedUnion('type', [zIPAdapterConfig, zFLUXReduxConfig]), + config: z.discriminatedUnion('type', [zRegionalGuidanceIPAdapterConfig, zRegionalGuidanceFLUXReduxConfig]), }); export type RegionalGuidanceRefImageState = z.infer; +export const isRegionalGuidanceIPAdapterConfig = ( + config: RegionalGuidanceRefImageState['config'] +): config is RegionalGuidanceIPAdapterConfig => config.type === 'ip_adapter'; + +export const isRegionalGuidanceFLUXReduxConfig = ( + config: RegionalGuidanceRefImageState['config'] +): config is RegionalGuidanceFLUXReduxConfig => config.type === 'flux_redux'; + const zCanvasRegionalGuidanceState = zCanvasEntityBase.extend({ type: z.literal('regional_guidance'), position: zCoordinate, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts index 620ae6d11d1..54b484e78ae 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -18,6 +18,7 @@ import type { IPAdapterConfig, RasterLayerAdjustments, RefImageState, + RegionalGuidanceIPAdapterConfig, RgbColor, T2IAdapterConfig, } from 'features/controlLayers/store/types'; @@ -52,7 +53,7 @@ export const imageDTOToCroppableImage = ( ): CroppableImageWithDims => { const { image_name, width, height } = originalImageDTO; const val: CroppableImageWithDims = { - original: { image_name, width, height }, + original: { image: { image_name, width, height } }, }; if (crop) { val.crop = deepClone(crop); @@ -95,6 +96,15 @@ export const initialIPAdapter: IPAdapterConfig = { clipVisionModel: 'ViT-H', weight: 1, }; +export const initialRegionalGuidanceIPAdapter: RegionalGuidanceIPAdapterConfig = { + type: 'ip_adapter', + image: null, + model: null, + beginEndStepPct: [0, 1], + method: 'full', + clipVisionModel: 'ViT-H', + weight: 1, +}; export const initialFLUXRedux: FLUXReduxConfig = { type: 'flux_redux', image: null, diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index 26738b42150..38aa8b039f3 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -236,8 +236,11 @@ const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, image const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, image_name: string) => { selectReferenceImageEntities(state).forEach((entity) => { - if (entity.config.image?.image_name === image_name) { - dispatch(refImageImageChanged({ id: entity.id, imageDTO: null })); + if ( + entity.config.image?.original.image.image_name === image_name || + entity.config.image?.crop?.image.image_name === image_name + ) { + dispatch(refImageImageChanged({ id: entity.id, croppableImage: null })); } }); }; @@ -284,7 +287,10 @@ export const getImageUsage = ( const isUpscaleImage = upscale.upscaleInitialImage?.image_name === image_name; - const isReferenceImage = refImages.entities.some(({ config }) => config.image?.image_name === image_name); + const isReferenceImage = refImages.entities.some( + ({ config }) => + config.image?.original.image.image_name === image_name || config.image?.crop?.image.image_name === image_name + ); const isRasterLayerImage = canvas.rasterLayers.entities.some(({ objects }) => objects.some((obj) => obj.type === 'image' && 'image_name' in obj.image && obj.image.image_name === image_name) diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index 6e5f60017d2..0aef104869f 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -4,7 +4,7 @@ import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerH import { getPrefixedId } from 'features/controlLayers/konva/util'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import type { CanvasEntityIdentifier, CanvasEntityType } from 'features/controlLayers/store/types'; -import { imageDTOToCroppableImage, imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common'; import type { BoardId } from 'features/gallery/store/types'; import { @@ -211,7 +211,7 @@ export const addGlobalReferenceImageDndTarget: DndTarget< handler: ({ sourceData, dispatch, getState }) => { const { imageDTO } = sourceData.payload; const config = getDefaultRefImageConfig(getState); - config.image = imageDTOToImageWithDims(imageDTO); + config.image = imageDTOToCroppableImage(imageDTO); dispatch(refImageAdded({ overrides: { config } })); }, }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx index ea789356c39..41505ae81d5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx @@ -2,7 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppStore } from 'app/store/storeHooks'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; @@ -17,7 +17,7 @@ export const ContextMenuItemUseAsRefImage = memo(() => { const onClickNewGlobalReferenceImageFromImage = useCallback(() => { const { dispatch, getState } = store; const config = getDefaultRefImageConfig(getState); - config.image = imageDTOToImageWithDims(imageDTO); + config.image = imageDTOToCroppableImage(imageDTO); dispatch(refImageAdded({ overrides: { config } })); toast({ id: 'SENT_TO_CANVAS', diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index c27f415da6d..14d27e900c1 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -26,7 +26,12 @@ import type { CanvasRasterLayerState, CanvasRegionalGuidanceState, } from 'features/controlLayers/store/types'; -import { imageDTOToImageObject, imageDTOToImageWithDims, initialControlNet } from 'features/controlLayers/store/util'; +import { + imageDTOToCroppableImage, + imageDTOToImageObject, + imageDTOToImageWithDims, + initialControlNet, +} from 'features/controlLayers/store/util'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; @@ -44,7 +49,7 @@ import { assert } from 'tsafe'; export const setGlobalReferenceImage = (arg: { imageDTO: ImageDTO; id: string; dispatch: AppDispatch }) => { const { imageDTO, id, dispatch } = arg; - dispatch(refImageImageChanged({ id, imageDTO })); + dispatch(refImageImageChanged({ id, croppableImage: imageDTOToCroppableImage(imageDTO) })); }; export const setRegionalGuidanceReferenceImage = (arg: { diff --git a/invokeai/frontend/web/src/features/metadata/parsing.tsx b/invokeai/frontend/web/src/features/metadata/parsing.tsx index d9b8b082db2..10cd0e32f7f 100644 --- a/invokeai/frontend/web/src/features/metadata/parsing.tsx +++ b/invokeai/frontend/web/src/features/metadata/parsing.tsx @@ -975,7 +975,7 @@ const RefImages: CollectionMetadataHandler = { for (const refImage of parsed) { if (refImage.config.image) { - await throwIfImageDoesNotExist(refImage.config.image.image_name, store); + await throwIfImageDoesNotExist(refImage.config.image.original.image.image_name, store); } if (refImage.config.model) { await throwIfModelDoesNotExist(refImage.config.model.key, store); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts index 5ac97a80a14..10ae0b66c99 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts @@ -87,7 +87,7 @@ const addFLUXRedux = (id: string, ipAdapter: FLUXReduxConfig, g: Graph, collecto type: 'flux_redux', redux_model: fluxReduxModel, image: { - image_name: image.image_name, + image_name: image.crop?.image.image_name ?? image.original.image.image_name, }, ...IMAGE_INFLUENCE_TO_SETTINGS[ipAdapter.imageInfluence ?? 'highest'], }); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts index 6c8e0af122d..f9cdf471f24 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addIPAdapters.ts @@ -58,7 +58,7 @@ const addIPAdapter = (id: string, ipAdapter: IPAdapterConfig, g: Graph, collecto begin_step_percent: beginEndStepPct[0], end_step_percent: beginEndStepPct[1], image: { - image_name: image.image_name, + image_name: image.crop?.image.image_name ?? image.original.image.image_name, }, }); } else { @@ -77,7 +77,7 @@ const addIPAdapter = (id: string, ipAdapter: IPAdapterConfig, g: Graph, collecto begin_step_percent: beginEndStepPct[0], end_step_percent: beginEndStepPct[1], image: { - image_name: image.image_name, + image_name: image.crop?.image.image_name ?? image.original.image.image_name, }, }); } diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts index 7ab6c7e7fac..e96f698e631 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addRegions.ts @@ -5,8 +5,8 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { type CanvasRegionalGuidanceState, - isFLUXReduxConfig, - isIPAdapterConfig, + isRegionalGuidanceFLUXReduxConfig, + isRegionalGuidanceIPAdapterConfig, type Rect, } from 'features/controlLayers/store/types'; import { getRegionalGuidanceWarnings } from 'features/controlLayers/store/validators'; @@ -279,7 +279,7 @@ export const addRegions = async ({ } for (const { id, config } of region.referenceImages) { - if (isIPAdapterConfig(config)) { + if (isRegionalGuidanceIPAdapterConfig(config)) { assert(!isFLUX, 'Regional IP adapters are not supported for FLUX.'); result.addedIPAdapters++; @@ -304,7 +304,7 @@ export const addRegions = async ({ // Connect the mask to the conditioning g.addEdge(maskToTensor, 'mask', ipAdapterNode, 'mask'); g.addEdge(ipAdapterNode, 'ip_adapter', ipAdapterCollect, 'item'); - } else if (isFLUXReduxConfig(config)) { + } else if (isRegionalGuidanceFLUXReduxConfig(config)) { assert(isFLUX, 'Regional FLUX Redux requires FLUX.'); assert(fluxReduxCollect !== null, 'FLUX Redux collector is required.'); result.addedFLUXReduxes++; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts index f64373c0d57..0e0e31667af 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildChatGPT4oGraph.ts @@ -50,7 +50,7 @@ export const buildChatGPT4oGraph = async (arg: GraphBuilderArg): Promise for (const entity of validRefImages) { assert(entity.config.image, 'Image is required for reference image'); reference_images.push({ - image_name: entity.config.image.image_name, + image_name: entity.config.image.crop?.image.image_name ?? entity.config.image.original.image.image_name, }); } } diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index 2cac5270037..e8a22e104ea 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -309,7 +309,7 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); } - if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image_name) { + if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.original.image.image_name) { reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') }); } diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index fa36e6f091d..ae8e03dcc6e 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -28,10 +28,10 @@ export const StartingFrameImage = () => { const dispatch = useAppDispatch(); const requiresStartingFrame = useAppSelector(selectVideoModelRequiresStartingFrame); const startingFrameImage = useAppSelector(selectStartingFrameImage); - const originalImageDTO = useImageDTO(startingFrameImage?.original.image_name); + const originalImageDTO = useImageDTO(startingFrameImage?.original.image.image_name); const croppedImageDTO = useImageDTO(startingFrameImage?.crop?.image.image_name); const videoAspectRatio = useAppSelector(selectVideoAspectRatio); - const [uploadImage] = useUploadImageMutation({ fixedCacheKey: 'editorContainer' }); + const [uploadImage] = useUploadImageMutation(); const onReset = useCallback(() => { dispatch(startingFrameImageChanged(null)); diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx index 8582f3911fb..c3746c875f6 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadAddStyleReference.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { imageDTOToCroppableImage } from 'features/controlLayers/store/util'; import { addGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; @@ -23,7 +23,7 @@ export const LaunchpadAddStyleReference = memo((props: { extraAction?: () => voi ({ onUpload: (imageDTO: ImageDTO) => { const config = getDefaultRefImageConfig(getState); - config.image = imageDTOToImageWithDims(imageDTO); + config.image = imageDTOToCroppableImage(imageDTO); dispatch(refImageAdded({ overrides: { config } })); props.extraAction?.(); }, diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 8d1f1783d3e..b5b2827ee73 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -1,6 +1,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { $authToken } from 'app/store/nanostores/authToken'; import { getStore } from 'app/store/nanostores/store'; +import type { CroppableImageWithDims } from 'features/controlLayers/store/types'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import type { components, paths } from 'services/api/schema'; import type { @@ -593,3 +594,10 @@ export const useImageDTO = (imageName: string | null | undefined) => { const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); return imageDTO ?? null; }; + +export const useImageDTOFromCroppableImage = (croppableImage: CroppableImageWithDims | null) => { + const { currentData: imageDTO } = useGetImageDTOQuery( + croppableImage?.crop?.image.image_name ?? croppableImage?.original.image.image_name ?? skipToken + ); + return imageDTO ?? null; +};