diff --git a/invokeai/frontend/web/docs/PACKAGE_SCRIPTS.md b/invokeai/frontend/web/docs/PACKAGE_SCRIPTS.md index 90d85bb540..5f882717b1 100644 --- a/invokeai/frontend/web/docs/PACKAGE_SCRIPTS.md +++ b/invokeai/frontend/web/docs/PACKAGE_SCRIPTS.md @@ -15,15 +15,3 @@ The `postinstall` script patches a few packages and runs the Chakra CLI to gener ### Patch `@chakra-ui/cli` See: - -### Patch `redux-persist` - -We want to persist the canvas state to `localStorage` but many canvas operations change data very quickly, so we need to debounce the writes to `localStorage`. - -`redux-persist` is unfortunately unmaintained. The repo's current code is nonfunctional, but the last release's code depends on a package that was removed from `npm` for being malware, so we cannot just fork it. - -So, we have to patch it directly. Perhaps a better way would be to write a debounced storage adapter, but I couldn't figure out how to do that. - -### Patch `redux-deep-persist` - -This package makes blacklisting and whitelisting persist configs very simple, but we have to patch it to match `redux-persist` for the types to work. diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 404d20d937..317929c6a4 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -89,18 +89,13 @@ "react-i18next": "^12.2.2", "react-icons": "^4.7.1", "react-konva": "^18.2.7", - "react-konva-utils": "^1.0.4", "react-redux": "^8.0.5", "react-resizable-panels": "^0.0.42", - "react-rnd": "^10.4.1", - "react-transition-group": "^4.4.5", "react-use": "^17.4.0", "react-virtuoso": "^4.3.5", "react-zoom-pan-pinch": "^3.0.7", "reactflow": "^11.7.0", - "redux-deep-persist": "^1.0.7", "redux-dynamic-middlewares": "^2.2.0", - "redux-persist": "^6.0.0", "redux-remember": "^3.3.1", "roarr": "^7.15.0", "serialize-error": "^11.0.0", diff --git a/invokeai/frontend/web/patches/redux-deep-persist+1.0.7.patch b/invokeai/frontend/web/patches/redux-deep-persist+1.0.7.patch deleted file mode 100644 index 47a62e6aac..0000000000 --- a/invokeai/frontend/web/patches/redux-deep-persist+1.0.7.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/node_modules/redux-deep-persist/lib/types.d.ts b/node_modules/redux-deep-persist/lib/types.d.ts -index b67b8c2..7fc0fa1 100644 ---- a/node_modules/redux-deep-persist/lib/types.d.ts -+++ b/node_modules/redux-deep-persist/lib/types.d.ts -@@ -35,6 +35,7 @@ export interface PersistConfig { - whitelist?: Array; - transforms?: Array>; - throttle?: number; -+ debounce?: number; - migrate?: PersistMigrate; - stateReconciler?: false | StateReconciler; - getStoredState?: (config: PersistConfig) => Promise; -diff --git a/node_modules/redux-deep-persist/src/types.ts b/node_modules/redux-deep-persist/src/types.ts -index 398ac19..cbc5663 100644 ---- a/node_modules/redux-deep-persist/src/types.ts -+++ b/node_modules/redux-deep-persist/src/types.ts -@@ -91,6 +91,7 @@ export interface PersistConfig { - whitelist?: Array; - transforms?: Array>; - throttle?: number; -+ debounce?: number; - migrate?: PersistMigrate; - stateReconciler?: false | StateReconciler; - /** diff --git a/invokeai/frontend/web/patches/redux-persist+6.0.0.patch b/invokeai/frontend/web/patches/redux-persist+6.0.0.patch deleted file mode 100644 index 9e0a8492db..0000000000 --- a/invokeai/frontend/web/patches/redux-persist+6.0.0.patch +++ /dev/null @@ -1,116 +0,0 @@ -diff --git a/node_modules/redux-persist/es/createPersistoid.js b/node_modules/redux-persist/es/createPersistoid.js -index 8b43b9a..184faab 100644 ---- a/node_modules/redux-persist/es/createPersistoid.js -+++ b/node_modules/redux-persist/es/createPersistoid.js -@@ -6,6 +6,7 @@ export default function createPersistoid(config) { - var whitelist = config.whitelist || null; - var transforms = config.transforms || []; - var throttle = config.throttle || 0; -+ var debounce = config.debounce || 0; - var storageKey = "".concat(config.keyPrefix !== undefined ? config.keyPrefix : KEY_PREFIX).concat(config.key); - var storage = config.storage; - var serialize; -@@ -28,30 +29,37 @@ export default function createPersistoid(config) { - var timeIterator = null; - var writePromise = null; - -- var update = function update(state) { -- // add any changed keys to the queue -- Object.keys(state).forEach(function (key) { -- if (!passWhitelistBlacklist(key)) return; // is keyspace ignored? noop -+ // Timer for debounced `update()` -+ let timer = 0; - -- if (lastState[key] === state[key]) return; // value unchanged? noop -+ function update(state) { -+ // Debounce the update -+ clearTimeout(timer); -+ timer = setTimeout(() => { -+ // add any changed keys to the queue -+ Object.keys(state).forEach(function (key) { -+ if (!passWhitelistBlacklist(key)) return; // is keyspace ignored? noop - -- if (keysToProcess.indexOf(key) !== -1) return; // is key already queued? noop -+ if (lastState[key] === state[key]) return; // value unchanged? noop - -- keysToProcess.push(key); // add key to queue -- }); //if any key is missing in the new state which was present in the lastState, -- //add it for processing too -+ if (keysToProcess.indexOf(key) !== -1) return; // is key already queued? noop - -- Object.keys(lastState).forEach(function (key) { -- if (state[key] === undefined && passWhitelistBlacklist(key) && keysToProcess.indexOf(key) === -1 && lastState[key] !== undefined) { -- keysToProcess.push(key); -- } -- }); // start the time iterator if not running (read: throttle) -+ keysToProcess.push(key); // add key to queue -+ }); //if any key is missing in the new state which was present in the lastState, -+ //add it for processing too - -- if (timeIterator === null) { -- timeIterator = setInterval(processNextKey, throttle); -- } -+ Object.keys(lastState).forEach(function (key) { -+ if (state[key] === undefined && passWhitelistBlacklist(key) && keysToProcess.indexOf(key) === -1 && lastState[key] !== undefined) { -+ keysToProcess.push(key); -+ } -+ }); // start the time iterator if not running (read: throttle) -+ -+ if (timeIterator === null) { -+ timeIterator = setInterval(processNextKey, throttle); -+ } - -- lastState = state; -+ lastState = state; -+ }, debounce) - }; - - function processNextKey() { -diff --git a/node_modules/redux-persist/es/types.js.flow b/node_modules/redux-persist/es/types.js.flow -index c50d3cd..39d8be2 100644 ---- a/node_modules/redux-persist/es/types.js.flow -+++ b/node_modules/redux-persist/es/types.js.flow -@@ -19,6 +19,7 @@ export type PersistConfig = { - whitelist?: Array, - transforms?: Array, - throttle?: number, -+ debounce?: number, - migrate?: (PersistedState, number) => Promise, - stateReconciler?: false | Function, - getStoredState?: PersistConfig => Promise, // used for migrations -diff --git a/node_modules/redux-persist/lib/types.js.flow b/node_modules/redux-persist/lib/types.js.flow -index c50d3cd..39d8be2 100644 ---- a/node_modules/redux-persist/lib/types.js.flow -+++ b/node_modules/redux-persist/lib/types.js.flow -@@ -19,6 +19,7 @@ export type PersistConfig = { - whitelist?: Array, - transforms?: Array, - throttle?: number, -+ debounce?: number, - migrate?: (PersistedState, number) => Promise, - stateReconciler?: false | Function, - getStoredState?: PersistConfig => Promise, // used for migrations -diff --git a/node_modules/redux-persist/src/types.js b/node_modules/redux-persist/src/types.js -index c50d3cd..39d8be2 100644 ---- a/node_modules/redux-persist/src/types.js -+++ b/node_modules/redux-persist/src/types.js -@@ -19,6 +19,7 @@ export type PersistConfig = { - whitelist?: Array, - transforms?: Array, - throttle?: number, -+ debounce?: number, - migrate?: (PersistedState, number) => Promise, - stateReconciler?: false | Function, - getStoredState?: PersistConfig => Promise, // used for migrations -diff --git a/node_modules/redux-persist/types/types.d.ts b/node_modules/redux-persist/types/types.d.ts -index b3733bc..2a1696c 100644 ---- a/node_modules/redux-persist/types/types.d.ts -+++ b/node_modules/redux-persist/types/types.d.ts -@@ -35,6 +35,7 @@ declare module "redux-persist/es/types" { - whitelist?: Array; - transforms?: Array>; - throttle?: number; -+ debounce?: number; - migrate?: PersistMigrate; - stateReconciler?: false | StateReconciler; - /** diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f82b3af677..319a920025 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -552,8 +552,8 @@ "canceled": "Processing Canceled", "tempFoldersEmptied": "Temp Folder Emptied", "uploadFailed": "Upload failed", - "uploadFailedMultipleImagesDesc": "Multiple images pasted, may only upload one image at a time", "uploadFailedUnableToLoadDesc": "Unable to load file", + "uploadFailedInvalidUploadDesc": "Must be single PNG or JPEG image", "downloadImageStarted": "Image Download Started", "imageCopied": "Image Copied", "imageLinkCopied": "Image Link Copied", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index eb6496f43e..e1727eee64 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -2,9 +2,6 @@ import ImageUploader from 'common/components/ImageUploader'; import SiteHeader from 'features/system/components/SiteHeader'; import ProgressBar from 'features/system/components/ProgressBar'; import InvokeTabs from 'features/ui/components/InvokeTabs'; - -import useToastWatcher from 'features/system/hooks/useToastWatcher'; - import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton'; import FloatingParametersPanelButtons from 'features/ui/components/FloatingParametersPanelButtons'; import { Box, Flex, Grid, Portal } from '@chakra-ui/react'; @@ -17,13 +14,15 @@ import { motion, AnimatePresence } from 'framer-motion'; import Loading from 'common/components/Loading/Loading'; import { useIsApplicationReady } from 'features/system/hooks/useIsApplicationReady'; import { PartialAppConfig } from 'app/types/invokeai'; -import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; import { configChanged } from 'features/system/store/configSlice'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { useLogger } from 'app/logging/useLogger'; import ParametersDrawer from 'features/ui/components/ParametersDrawer'; import { languageSelector } from 'features/system/store/systemSelectors'; import i18n from 'i18n'; +import Toaster from './Toaster'; +import GlobalHotkeys from './GlobalHotkeys'; +import AuxiliaryProgressIndicator from './AuxiliaryProgressIndicator'; const DEFAULT_CONFIG = {}; @@ -38,9 +37,6 @@ const App = ({ headerComponent, setIsReady, }: Props) => { - useToastWatcher(); - useGlobalHotkeys(); - const language = useAppSelector(languageSelector); const log = useLogger(); @@ -77,65 +73,69 @@ const App = ({ }, [isApplicationReady, setIsReady]); return ( - - {isLightboxEnabled && } - - - - {headerComponent || } - + + {isLightboxEnabled && } + + + - - - - + {headerComponent || } + + + + + - - + + - - {!isApplicationReady && !loadingOverridden && ( - - - - - - - )} - + + {!isApplicationReady && !loadingOverridden && ( + + + + + + + )} + - - - - - - - + + + + + + + + + + ); }; diff --git a/invokeai/frontend/web/src/app/components/AuxiliaryProgressIndicator.tsx b/invokeai/frontend/web/src/app/components/AuxiliaryProgressIndicator.tsx new file mode 100644 index 0000000000..a0c5d22266 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/AuxiliaryProgressIndicator.tsx @@ -0,0 +1,44 @@ +import { Flex, Spinner, Tooltip } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { systemSelector } from 'features/system/store/systemSelectors'; +import { memo } from 'react'; + +const selector = createSelector(systemSelector, (system) => { + const { isUploading } = system; + + let tooltip = ''; + + if (isUploading) { + tooltip = 'Uploading...'; + } + + return { + tooltip, + shouldShow: isUploading, + }; +}); + +export const AuxiliaryProgressIndicator = () => { + const { shouldShow, tooltip } = useAppSelector(selector); + + if (!shouldShow) { + return null; + } + + return ( + + + + + + ); +}; + +export default memo(AuxiliaryProgressIndicator); diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/app/components/GlobalHotkeys.ts similarity index 89% rename from invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts rename to invokeai/frontend/web/src/app/components/GlobalHotkeys.ts index 3935a390fb..c4660416bf 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/app/components/GlobalHotkeys.ts @@ -10,6 +10,7 @@ import { togglePinParametersPanel, } from 'features/ui/store/uiSlice'; import { isEqual } from 'lodash-es'; +import React, { memo } from 'react'; import { isHotkeyPressed, useHotkeys } from 'react-hotkeys-hook'; const globalHotkeysSelector = createSelector( @@ -27,7 +28,11 @@ const globalHotkeysSelector = createSelector( // TODO: Does not catch keypresses while focused in an input. Maybe there is a way? -export const useGlobalHotkeys = () => { +/** + * Logical component. Handles app-level global hotkeys. + * @returns null + */ +const GlobalHotkeys: React.FC = () => { const dispatch = useAppDispatch(); const { shift } = useAppSelector(globalHotkeysSelector); @@ -75,4 +80,8 @@ export const useGlobalHotkeys = () => { useHotkeys('4', () => { dispatch(setActiveTab('nodes')); }); + + return null; }; + +export default memo(GlobalHotkeys); diff --git a/invokeai/frontend/web/src/app/components/Toaster.ts b/invokeai/frontend/web/src/app/components/Toaster.ts new file mode 100644 index 0000000000..66ba1d4925 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/Toaster.ts @@ -0,0 +1,65 @@ +import { useToast, UseToastOptions } from '@chakra-ui/react'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { toastQueueSelector } from 'features/system/store/systemSelectors'; +import { addToast, clearToastQueue } from 'features/system/store/systemSlice'; +import { useCallback, useEffect } from 'react'; + +export type MakeToastArg = string | UseToastOptions; + +/** + * Makes a toast from a string or a UseToastOptions object. + * If a string is passed, the toast will have the status 'info' and will be closable with a duration of 2500ms. + */ +export const makeToast = (arg: MakeToastArg): UseToastOptions => { + if (typeof arg === 'string') { + return { + title: arg, + status: 'info', + isClosable: true, + duration: 2500, + }; + } + + return { status: 'info', isClosable: true, duration: 2500, ...arg }; +}; + +/** + * Logical component. Watches the toast queue and makes toasts when the queue is not empty. + * @returns null + */ +const Toaster = () => { + const dispatch = useAppDispatch(); + const toastQueue = useAppSelector(toastQueueSelector); + const toast = useToast(); + useEffect(() => { + toastQueue.forEach((t) => { + toast(t); + }); + toastQueue.length > 0 && dispatch(clearToastQueue()); + }, [dispatch, toast, toastQueue]); + + return null; +}; + +/** + * Returns a function that can be used to make a toast. + * @example + * const toaster = useAppToaster(); + * toaster('Hello world!'); + * toaster({ title: 'Hello world!', status: 'success' }); + * @returns A function that can be used to make a toast. + * @see makeToast + * @see MakeToastArg + * @see UseToastOptions + */ +export const useAppToaster = () => { + const dispatch = useAppDispatch(); + const toaster = useCallback( + (arg: MakeToastArg) => dispatch(addToast(makeToast(arg))), + [dispatch] + ); + + return toaster; +}; + +export default Toaster; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 36bf6adfe7..f23e83a191 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -15,6 +15,10 @@ import { addUserInvokedCanvasListener } from './listeners/userInvokedCanvas'; import { addUserInvokedNodesListener } from './listeners/userInvokedNodes'; import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextToImage'; import { addUserInvokedImageToImageListener } from './listeners/userInvokedImageToImage'; +import { addCanvasSavedToGalleryListener } from './listeners/canvasSavedToGallery'; +import { addCanvasDownloadedAsImageListener } from './listeners/canvasDownloadedAsImage'; +import { addCanvasCopiedToClipboardListener } from './listeners/canvasCopiedToClipboard'; +import { addCanvasMergedListener } from './listeners/canvasMerged'; export const listenerMiddleware = createListenerMiddleware(); @@ -43,3 +47,8 @@ addUserInvokedCanvasListener(); addUserInvokedNodesListener(); addUserInvokedTextToImageListener(); addUserInvokedImageToImageListener(); + +addCanvasSavedToGalleryListener(); +addCanvasDownloadedAsImageListener(); +addCanvasCopiedToClipboardListener(); +addCanvasMergedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts new file mode 100644 index 0000000000..16642f1f32 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasCopiedToClipboard.ts @@ -0,0 +1,33 @@ +import { canvasCopiedToClipboard } from 'features/canvas/store/actions'; +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; +import { addToast } from 'features/system/store/systemSlice'; +import { copyBlobToClipboard } from 'features/canvas/util/copyBlobToClipboard'; + +const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); + +export const addCanvasCopiedToClipboardListener = () => { + startAppListening({ + actionCreator: canvasCopiedToClipboard, + effect: async (action, { dispatch, getState }) => { + const state = getState(); + + const blob = await getBaseLayerBlob(state); + + if (!blob) { + moduleLog.error('Problem getting base layer blob'); + dispatch( + addToast({ + title: 'Problem Copying Canvas', + description: 'Unable to export base layer', + status: 'error', + }) + ); + return; + } + + copyBlobToClipboard(blob); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts new file mode 100644 index 0000000000..ef4c63b31c --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasDownloadedAsImage.ts @@ -0,0 +1,33 @@ +import { canvasDownloadedAsImage } from 'features/canvas/store/actions'; +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { downloadBlob } from 'features/canvas/util/downloadBlob'; +import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; +import { addToast } from 'features/system/store/systemSlice'; + +const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); + +export const addCanvasDownloadedAsImageListener = () => { + startAppListening({ + actionCreator: canvasDownloadedAsImage, + effect: async (action, { dispatch, getState }) => { + const state = getState(); + + const blob = await getBaseLayerBlob(state); + + if (!blob) { + moduleLog.error('Problem getting base layer blob'); + dispatch( + addToast({ + title: 'Problem Downloading Canvas', + description: 'Unable to export base layer', + status: 'error', + }) + ); + return; + } + + downloadBlob(blob, 'mergedCanvas.png'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasGraphBuilt.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasGraphBuilt.ts deleted file mode 100644 index 532bac3eee..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasGraphBuilt.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { canvasGraphBuilt } from 'features/nodes/store/actions'; -import { startAppListening } from '..'; -import { - canvasSessionIdChanged, - stagingAreaInitialized, -} from 'features/canvas/store/canvasSlice'; -import { sessionInvoked } from 'services/thunks/session'; - -export const addCanvasGraphBuiltListener = () => - startAppListening({ - actionCreator: canvasGraphBuilt, - effect: async (action, { dispatch, getState, take }) => { - const [{ meta }] = await take(sessionInvoked.fulfilled.match); - const { sessionId } = meta.arg; - const state = getState(); - - if (!state.canvas.layerState.stagingArea.boundingBox) { - dispatch( - stagingAreaInitialized({ - sessionId, - boundingBox: { - ...state.canvas.boundingBoxCoordinates, - ...state.canvas.boundingBoxDimensions, - }, - }) - ); - } - - dispatch(canvasSessionIdChanged(sessionId)); - }, - }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts new file mode 100644 index 0000000000..d7a58c2050 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasMerged.ts @@ -0,0 +1,88 @@ +import { canvasMerged } from 'features/canvas/store/actions'; +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; +import { addToast } from 'features/system/store/systemSlice'; +import { imageUploaded } from 'services/thunks/image'; +import { v4 as uuidv4 } from 'uuid'; +import { deserializeImageResponse } from 'services/util/deserializeImageResponse'; +import { setMergedCanvas } from 'features/canvas/store/canvasSlice'; +import { getCanvasBaseLayer } from 'features/canvas/util/konvaInstanceProvider'; + +const moduleLog = log.child({ namespace: 'canvasCopiedToClipboardListener' }); + +export const addCanvasMergedListener = () => { + startAppListening({ + actionCreator: canvasMerged, + effect: async (action, { dispatch, getState, take }) => { + const state = getState(); + + const blob = await getBaseLayerBlob(state, true); + + if (!blob) { + moduleLog.error('Problem getting base layer blob'); + dispatch( + addToast({ + title: 'Problem Merging Canvas', + description: 'Unable to export base layer', + status: 'error', + }) + ); + return; + } + + const canvasBaseLayer = getCanvasBaseLayer(); + + if (!canvasBaseLayer) { + moduleLog.error('Problem getting canvas base layer'); + dispatch( + addToast({ + title: 'Problem Merging Canvas', + description: 'Unable to export base layer', + status: 'error', + }) + ); + return; + } + + const baseLayerRect = canvasBaseLayer.getClientRect({ + relativeTo: canvasBaseLayer.getParent(), + }); + + const filename = `mergedCanvas_${uuidv4()}.png`; + + dispatch( + imageUploaded({ + imageType: 'intermediates', + formData: { + file: new File([blob], filename, { type: 'image/png' }), + }, + }) + ); + + const [{ payload }] = await take( + (action): action is ReturnType => + imageUploaded.fulfilled.match(action) && + action.meta.arg.formData.file.name === filename + ); + + const mergedCanvasImage = deserializeImageResponse(payload.response); + + dispatch( + setMergedCanvas({ + kind: 'image', + layer: 'base', + image: mergedCanvasImage, + ...baseLayerRect, + }) + ); + + dispatch( + addToast({ + title: 'Canvas Merged', + status: 'success', + }) + ); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts new file mode 100644 index 0000000000..d8237d1d5c --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/canvasSavedToGallery.ts @@ -0,0 +1,40 @@ +import { canvasSavedToGallery } from 'features/canvas/store/actions'; +import { startAppListening } from '..'; +import { log } from 'app/logging/useLogger'; +import { imageUploaded } from 'services/thunks/image'; +import { getBaseLayerBlob } from 'features/canvas/util/getBaseLayerBlob'; +import { addToast } from 'features/system/store/systemSlice'; + +const moduleLog = log.child({ namespace: 'canvasSavedToGalleryListener' }); + +export const addCanvasSavedToGalleryListener = () => { + startAppListening({ + actionCreator: canvasSavedToGallery, + effect: async (action, { dispatch, getState }) => { + const state = getState(); + + const blob = await getBaseLayerBlob(state); + + if (!blob) { + moduleLog.error('Problem getting base layer blob'); + dispatch( + addToast({ + title: 'Problem Saving Canvas', + description: 'Unable to export base layer', + status: 'error', + }) + ); + return; + } + + dispatch( + imageUploaded({ + imageType: 'results', + formData: { + file: new File([blob], 'mergedCanvas.png', { type: 'image/png' }), + }, + }) + ); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts index c32da2e710..de06220ecd 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -3,6 +3,10 @@ import { startAppListening } from '..'; import { uploadAdded } from 'features/gallery/store/uploadsSlice'; import { imageSelected } from 'features/gallery/store/gallerySlice'; import { imageUploaded } from 'services/thunks/image'; +import { addToast } from 'features/system/store/systemSlice'; +import { initialImageSelected } from 'features/parameters/store/actions'; +import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice'; +import { resultAdded } from 'features/gallery/store/resultsSlice'; export const addImageUploadedListener = () => { startAppListening({ @@ -11,14 +15,31 @@ export const addImageUploadedListener = () => { action.payload.response.image_type !== 'intermediates', effect: (action, { dispatch, getState }) => { const { response } = action.payload; + const { imageType } = action.meta.arg; const state = getState(); const image = deserializeImageResponse(response); - dispatch(uploadAdded(image)); + if (imageType === 'uploads') { + dispatch(uploadAdded(image)); - if (state.gallery.shouldAutoSwitchToNewImages) { - dispatch(imageSelected(image)); + dispatch(addToast({ title: 'Image Uploaded', status: 'success' })); + + if (state.gallery.shouldAutoSwitchToNewImages) { + dispatch(imageSelected(image)); + } + + if (action.meta.arg.activeTabName === 'img2img') { + dispatch(initialImageSelected(image)); + } + + if (action.meta.arg.activeTabName === 'unifiedCanvas') { + dispatch(setInitialCanvasImage(image)); + } + } + + if (imageType === 'results') { + dispatch(resultAdded(image)); } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts index 6bc2f9e9bc..ae3a35f537 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/initialImageSelected.ts @@ -2,11 +2,11 @@ import { initialImageChanged } from 'features/parameters/store/generationSlice'; import { Image, isInvokeAIImage } from 'app/types/invokeai'; import { selectResultsById } from 'features/gallery/store/resultsSlice'; import { selectUploadsById } from 'features/gallery/store/uploadsSlice'; -import { makeToast } from 'features/system/hooks/useToastWatcher'; import { t } from 'i18next'; import { addToast } from 'features/system/store/systemSlice'; import { startAppListening } from '..'; import { initialImageSelected } from 'features/parameters/store/actions'; +import { makeToast } from 'app/components/Toaster'; export const addInitialImageSelectedListener = () => { startAppListening({ diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts index cdb2c83e12..2ebd3684e9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/userInvokedCanvas.ts @@ -1,6 +1,6 @@ import { startAppListening } from '..'; import { sessionCreated, sessionInvoked } from 'services/thunks/session'; -import { buildCanvasGraphAndBlobs } from 'features/nodes/util/graphBuilders/buildCanvasGraph'; +import { buildCanvasGraphComponents } from 'features/nodes/util/graphBuilders/buildCanvasGraph'; import { log } from 'app/logging/useLogger'; import { canvasGraphBuilt } from 'features/nodes/store/actions'; import { imageUploaded } from 'services/thunks/image'; @@ -11,9 +11,17 @@ import { stagingAreaInitialized, } from 'features/canvas/store/canvasSlice'; import { userInvoked } from 'app/store/actions'; +import { getCanvasData } from 'features/canvas/util/getCanvasData'; +import { getCanvasGenerationMode } from 'features/canvas/util/getCanvasGenerationMode'; +import { blobToDataURL } from 'features/canvas/util/blobToDataURL'; +import openBase64ImageInTab from 'common/util/openBase64ImageInTab'; const moduleLog = log.child({ namespace: 'invoke' }); +/** + * This listener is responsible for building the canvas graph and blobs when the user invokes the canvas. + * It is also responsible for uploading the base and mask layers to the server. + */ export const addUserInvokedCanvasListener = () => { startAppListening({ predicate: (action): action is ReturnType => @@ -21,25 +29,49 @@ export const addUserInvokedCanvasListener = () => { effect: async (action, { getState, dispatch, take }) => { const state = getState(); - const data = await buildCanvasGraphAndBlobs(state); + // Build canvas blobs + const canvasBlobsAndImageData = await getCanvasData(state); - if (!data) { + if (!canvasBlobsAndImageData) { + moduleLog.error('Unable to create canvas data'); + return; + } + + const { baseBlob, baseImageData, maskBlob, maskImageData } = + canvasBlobsAndImageData; + + // Determine the generation mode + const generationMode = getCanvasGenerationMode( + baseImageData, + maskImageData + ); + + if (state.system.enableImageDebugging) { + const baseDataURL = await blobToDataURL(baseBlob); + const maskDataURL = await blobToDataURL(maskBlob); + openBase64ImageInTab([ + { base64: maskDataURL, caption: 'mask b64' }, + { base64: baseDataURL, caption: 'image b64' }, + ]); + } + + moduleLog.debug(`Generation mode: ${generationMode}`); + + // Build the canvas graph + const graphComponents = await buildCanvasGraphComponents( + state, + generationMode + ); + + if (!graphComponents) { moduleLog.error('Problem building graph'); return; } - const { - rangeNode, - iterateNode, - baseNode, - edges, - baseBlob, - maskBlob, - generationMode, - } = data; + const { rangeNode, iterateNode, baseNode, edges } = graphComponents; + // Upload the base layer, to be used as init image const baseFilename = `${uuidv4()}.png`; - const maskFilename = `${uuidv4()}.png`; dispatch( imageUploaded({ @@ -66,6 +98,9 @@ export const addUserInvokedCanvasListener = () => { }; } + // Upload the mask layer image + const maskFilename = `${uuidv4()}.png`; + if (baseNode.type === 'inpaint') { dispatch( imageUploaded({ @@ -103,9 +138,12 @@ export const addUserInvokedCanvasListener = () => { dispatch(canvasGraphBuilt(graph)); moduleLog({ data: graph }, 'Canvas graph built'); + // Actually create the session dispatch(sessionCreated({ graph })); + // Wait for the session to be invoked (this is just the HTTP request to start processing) const [{ meta }] = await take(sessionInvoked.fulfilled.match); + const { sessionId } = meta.arg; if (!state.canvas.layerState.stagingArea.boundingBox) { diff --git a/invokeai/frontend/web/src/common/components/IAIInput.tsx b/invokeai/frontend/web/src/common/components/IAIInput.tsx index 3e90dca83a..3cba36d2c9 100644 --- a/invokeai/frontend/web/src/common/components/IAIInput.tsx +++ b/invokeai/frontend/web/src/common/components/IAIInput.tsx @@ -5,6 +5,7 @@ import { Input, InputProps, } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; import { ChangeEvent, memo } from 'react'; interface IAIInputProps extends InputProps { @@ -31,7 +32,7 @@ const IAIInput = (props: IAIInputProps) => { {...formControlProps} > {label !== '' && {label}} - + ); }; diff --git a/invokeai/frontend/web/src/common/components/IAINumberInput.tsx b/invokeai/frontend/web/src/common/components/IAINumberInput.tsx index 762182eb47..bf598f3b12 100644 --- a/invokeai/frontend/web/src/common/components/IAINumberInput.tsx +++ b/invokeai/frontend/web/src/common/components/IAINumberInput.tsx @@ -14,6 +14,7 @@ import { Tooltip, TooltipProps, } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; import { clamp } from 'lodash-es'; import { FocusEvent, memo, useEffect, useState } from 'react'; @@ -125,6 +126,7 @@ const IAINumberInput = (props: Props) => { onChange={handleOnChange} onBlur={handleBlur} {...rest} + onPaste={stopPastePropagation} > {showStepper && ( diff --git a/invokeai/frontend/web/src/common/components/IAITextarea.tsx b/invokeai/frontend/web/src/common/components/IAITextarea.tsx new file mode 100644 index 0000000000..b5247887bb --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAITextarea.tsx @@ -0,0 +1,9 @@ +import { Textarea, TextareaProps, forwardRef } from '@chakra-ui/react'; +import { stopPastePropagation } from 'common/util/stopPastePropagation'; +import { memo } from 'react'; + +const IAITextarea = forwardRef((props: TextareaProps, ref) => { + return