From b9767e9c6e25f940ec4925e2d260d50ac7873170 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Tue, 11 Jul 2023 07:18:13 +1200 Subject: [PATCH 1/6] feat: Save and Loads Nodes From Disk --- invokeai/frontend/web/public/locales/en.json | 9 ++- .../src/features/nodes/components/Flow.tsx | 10 +++ .../components/panels/TopCenterPanel.tsx | 6 +- .../nodes/components/ui/LoadNodesButton.tsx | 80 +++++++++++++++++++ .../nodes/components/ui/SaveNodesButton.tsx | 39 +++++++++ .../src/features/nodes/store/nodesSlice.ts | 15 ++++ 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 6734f1dcd1..173cd1fa51 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -593,7 +593,10 @@ "metadataLoadFailed": "Failed to load metadata", "initialImageSet": "Initial Image Set", "initialImageNotSet": "Initial Image Not Set", - "initialImageNotSetDesc": "Could not load initial image" + "initialImageNotSetDesc": "Could not load initial image", + "nodesSaved": "Nodes Saved", + "nodesLoaded": "Nodes Loaded", + "nodesLoadedFailed": "Failed To Load Nodes" }, "tooltip": { "feature": { @@ -674,5 +677,9 @@ "showProgressImages": "Show Progress Images", "hideProgressImages": "Hide Progress Images", "swapSizes": "Swap Sizes" + }, + "nodes": { + "saveNodes": "Save Nodes", + "loadNodes": "Load Nodes" } } diff --git a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx index e29ec38c96..c31fc4e9b7 100644 --- a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx @@ -7,6 +7,7 @@ import { OnConnectEnd, OnConnectStart, OnEdgesChange, + OnInit, OnNodesChange, ReactFlow, } from 'reactflow'; @@ -16,6 +17,7 @@ import { connectionStarted, edgesChanged, nodesChanged, + setEditorInstance, } from '../store/nodesSlice'; import { InvocationComponent } from './InvocationComponent'; import ProgressImageNode from './ProgressImageNode'; @@ -67,6 +69,13 @@ export const Flow = () => { dispatch(connectionEnded()); }, [dispatch]); + const onInit: OnInit = useCallback( + (v) => { + dispatch(setEditorInstance(v)); + }, + [dispatch] + ); + return ( { onConnectStart={onConnectStart} onConnect={onConnect} onConnectEnd={onConnectEnd} + onInit={onInit} defaultEdgeOptions={{ style: { strokeWidth: 2 }, }} diff --git a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx index 223791e4ad..fab68d7599 100644 --- a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx @@ -1,11 +1,13 @@ import { HStack } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; import IAIButton from 'common/components/IAIButton'; +import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; import { memo, useCallback } from 'react'; import { Panel } from 'reactflow'; import { receivedOpenAPISchema } from 'services/api/thunks/schema'; +import LoadNodesButton from '../ui/LoadNodesButton'; import NodeInvokeButton from '../ui/NodeInvokeButton'; -import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; +import SaveNodesButton from '../ui/SaveNodesButton'; const TopCenterPanel = () => { const dispatch = useAppDispatch(); @@ -20,6 +22,8 @@ const TopCenterPanel = () => { Reload Schema + + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx new file mode 100644 index 0000000000..e32003b2b2 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx @@ -0,0 +1,80 @@ +import { FileButton } from '@mantine/core'; +import { makeToast } from 'app/components/Toaster'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { loadFileEdges, loadFileNodes } from 'features/nodes/store/nodesSlice'; +import { addToast } from 'features/system/store/systemSlice'; +import { memo, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BiUpload } from 'react-icons/bi'; +import { useReactFlow } from 'reactflow'; + +const LoadNodesButton = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { setViewport } = useReactFlow(); + + const uploadedFileRef = useRef<() => void>(null); + + const restoreJSONToEditor = useCallback( + (v: File | null) => { + if (!v) return; + const reader = new FileReader(); + reader.onload = async () => { + const json = reader.result; + const retrievedNodeTree = await JSON.parse(String(json)); + + if (!retrievedNodeTree) { + dispatch( + addToast( + makeToast({ + title: t('toast.nodesLoadedFailed'), + status: 'error', + }) + ) + ); + } + + if (retrievedNodeTree) { + const { x = 0, y = 0, zoom = 1 } = retrievedNodeTree.viewport; + dispatch(loadFileNodes(retrievedNodeTree.nodes)); + dispatch(loadFileEdges(retrievedNodeTree.edges)); + setViewport({ x, y, zoom }); + dispatch( + addToast( + makeToast({ title: t('toast.nodesLoaded'), status: 'success' }) + ) + ); + } + + // Cleanup + reader.abort(); + }; + + reader.readAsText(v); + + // Cleanup + uploadedFileRef.current?.(); + }, + [setViewport, dispatch, t] + ); + return ( + + {(props) => ( + } + fontSize={20} + tooltip={t('nodes.loadNodes')} + aria-label={t('nodes.loadNodes')} + {...props} + /> + )} + + ); +}; + +export default memo(LoadNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx new file mode 100644 index 0000000000..af1a54d9d0 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx @@ -0,0 +1,39 @@ +import { RootState } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BiSave } from 'react-icons/bi'; + +const SaveNodesButton = () => { + const { t } = useTranslation(); + const editorInstance = useAppSelector( + (state: RootState) => state.nodes.editorInstance + ); + + const saveEditorToJSON = useCallback(() => { + if (editorInstance) { + const editorState = editorInstance.toObject(); + const nodeSetupJSON = new Blob([JSON.stringify(editorState)]); + const nodeDownloadElement = document.createElement('a'); + nodeDownloadElement.href = URL.createObjectURL(nodeSetupJSON); + nodeDownloadElement.download = 'MyNodes.json'; + document.body.appendChild(nodeDownloadElement); + nodeDownloadElement.click(); + // Cleanup + nodeDownloadElement.remove(); + } + }, [editorInstance]); + + return ( + } + fontSize={20} + tooltip={t('nodes.saveNodes')} + aria-label={t('nodes.saveNodes')} + onClick={saveEditorToJSON} + /> + ); +}; + +export default memo(SaveNodesButton); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 4fa69c626b..094a43b944 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -13,6 +13,7 @@ import { Node, NodeChange, OnConnectStartParams, + ReactFlowInstance, } from 'reactflow'; import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { ImageField } from 'services/api/types'; @@ -25,6 +26,7 @@ export type NodesState = { invocationTemplates: Record; connectionStartParams: OnConnectStartParams | null; shouldShowGraphOverlay: boolean; + editorInstance: ReactFlowInstance | undefined; }; export const initialNodesState: NodesState = { @@ -34,6 +36,7 @@ export const initialNodesState: NodesState = { invocationTemplates: {}, connectionStartParams: null, shouldShowGraphOverlay: false, + editorInstance: undefined, }; const nodesSlice = createSlice({ @@ -121,6 +124,15 @@ const nodesSlice = createSlice({ nodeEditorReset: () => { return { ...initialNodesState }; }, + setEditorInstance: (state, action) => { + state.editorInstance = action.payload; + }, + loadFileNodes: (state, action: PayloadAction[]>) => { + state.nodes = action.payload; + }, + loadFileEdges: (state, action: PayloadAction) => { + state.edges = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => { @@ -141,6 +153,9 @@ export const { nodeTemplatesBuilt, nodeEditorReset, imageCollectionFieldValueChanged, + setEditorInstance, + loadFileNodes, + loadFileEdges, } = nodesSlice.actions; export default nodesSlice.reducer; From bbdb26511aa04dfb1ef90f5285bde7a30a57afb8 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Tue, 11 Jul 2023 09:44:36 +1200 Subject: [PATCH 2/6] feat: Fit to view on load rather than using older position --- .../src/features/nodes/components/ui/LoadNodesButton.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx index e32003b2b2..d71da68fa9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx @@ -12,7 +12,7 @@ import { useReactFlow } from 'reactflow'; const LoadNodesButton = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const { setViewport } = useReactFlow(); + const { fitView } = useReactFlow(); const uploadedFileRef = useRef<() => void>(null); @@ -36,10 +36,10 @@ const LoadNodesButton = () => { } if (retrievedNodeTree) { - const { x = 0, y = 0, zoom = 1 } = retrievedNodeTree.viewport; dispatch(loadFileNodes(retrievedNodeTree.nodes)); dispatch(loadFileEdges(retrievedNodeTree.edges)); - setViewport({ x, y, zoom }); + fitView(); + dispatch( addToast( makeToast({ title: t('toast.nodesLoaded'), status: 'success' }) @@ -56,7 +56,7 @@ const LoadNodesButton = () => { // Cleanup uploadedFileRef.current?.(); }, - [setViewport, dispatch, t] + [fitView, dispatch, t] ); return ( Date: Tue, 11 Jul 2023 18:22:50 +1200 Subject: [PATCH 3/6] feat(node-editor): fit view on init --- invokeai/frontend/web/src/features/nodes/components/Flow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx index c31fc4e9b7..7b0718182b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx @@ -72,6 +72,7 @@ export const Flow = () => { const onInit: OnInit = useCallback( (v) => { dispatch(setEditorInstance(v)); + if (v) v.fitView(); }, [dispatch] ); From b6b22dc7994942534a2b7b39bbcc4597cc679544 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Wed, 12 Jul 2023 07:50:11 +1200 Subject: [PATCH 4/6] feat: Update Reload Schema button --- invokeai/frontend/web/public/locales/en.json | 1 + .../components/panels/TopCenterPanel.tsx | 14 +++-------- .../components/ui/ReloadSchemaButton.tsx | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/ui/ReloadSchemaButton.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 173cd1fa51..f169b742e8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -679,6 +679,7 @@ "swapSizes": "Swap Sizes" }, "nodes": { + "reloadSchema": "Reload Schema", "saveNodes": "Save Nodes", "loadNodes": "Load Nodes" } diff --git a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx index fab68d7599..10e59f2af9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/panels/TopCenterPanel.tsx @@ -1,27 +1,19 @@ import { HStack } from '@chakra-ui/react'; -import { useAppDispatch } from 'app/store/storeHooks'; -import IAIButton from 'common/components/IAIButton'; import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; -import { memo, useCallback } from 'react'; +import { memo } from 'react'; import { Panel } from 'reactflow'; -import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import LoadNodesButton from '../ui/LoadNodesButton'; import NodeInvokeButton from '../ui/NodeInvokeButton'; +import ReloadSchemaButton from '../ui/ReloadSchemaButton'; import SaveNodesButton from '../ui/SaveNodesButton'; const TopCenterPanel = () => { - const dispatch = useAppDispatch(); - - const handleReloadSchema = useCallback(() => { - dispatch(receivedOpenAPISchema()); - }, [dispatch]); - return ( - Reload Schema + diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/ReloadSchemaButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/ReloadSchemaButton.tsx new file mode 100644 index 0000000000..613297d217 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/ui/ReloadSchemaButton.tsx @@ -0,0 +1,25 @@ +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BiRefresh } from 'react-icons/bi'; +import { receivedOpenAPISchema } from 'services/api/thunks/schema'; + +export default function ReloadSchemaButton() { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const handleReloadSchema = useCallback(() => { + dispatch(receivedOpenAPISchema()); + }, [dispatch]); + + return ( + } + fontSize={24} + tooltip={t('nodes.reloadSchema')} + aria-label={t('nodes.reloadSchema')} + onClick={handleReloadSchema} + /> + ); +} From 84af35597d9662ecdcf7704d55f66ff3e76f5047 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Wed, 12 Jul 2023 13:58:14 +1200 Subject: [PATCH 5/6] fix: Update Load & Save Icons to FontAwesome --- .../src/features/nodes/components/ui/LoadNodesButton.tsx | 5 ++--- .../src/features/nodes/components/ui/SaveNodesButton.tsx | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx index d71da68fa9..10aecc9fcc 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/LoadNodesButton.tsx @@ -6,7 +6,7 @@ import { loadFileEdges, loadFileNodes } from 'features/nodes/store/nodesSlice'; import { addToast } from 'features/system/store/systemSlice'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { BiUpload } from 'react-icons/bi'; +import { FaUpload } from 'react-icons/fa'; import { useReactFlow } from 'reactflow'; const LoadNodesButton = () => { @@ -66,8 +66,7 @@ const LoadNodesButton = () => { > {(props) => ( } - fontSize={20} + icon={} tooltip={t('nodes.loadNodes')} aria-label={t('nodes.loadNodes')} {...props} diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx index af1a54d9d0..8623fa5cbe 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx @@ -3,7 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { BiSave } from 'react-icons/bi'; +import { FaSave } from 'react-icons/fa'; const SaveNodesButton = () => { const { t } = useTranslation(); @@ -27,8 +27,8 @@ const SaveNodesButton = () => { return ( } - fontSize={20} + icon={} + fontSize={18} tooltip={t('nodes.saveNodes')} aria-label={t('nodes.saveNodes')} onClick={saveEditorToJSON} From 0282aa83c5f4ee23f0d80aaf75f84e751cd8e127 Mon Sep 17 00:00:00 2001 From: blessedcoolant <54517381+blessedcoolant@users.noreply.github.com> Date: Wed, 12 Jul 2023 14:32:54 +1200 Subject: [PATCH 6/6] feat: Do not store edge styling data when saving a graph setup --- .../src/features/nodes/components/ui/SaveNodesButton.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx index 8623fa5cbe..14bf0a1ce8 100644 --- a/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/ui/SaveNodesButton.tsx @@ -1,6 +1,7 @@ import { RootState } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; +import { map, omit } from 'lodash-es'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FaSave } from 'react-icons/fa'; @@ -14,6 +15,11 @@ const SaveNodesButton = () => { const saveEditorToJSON = useCallback(() => { if (editorInstance) { const editorState = editorInstance.toObject(); + + editorState.edges = map(editorState.edges, (edge) => { + return omit(edge, ['style']); + }); + const nodeSetupJSON = new Blob([JSON.stringify(editorState)]); const nodeDownloadElement = document.createElement('a'); nodeDownloadElement.href = URL.createObjectURL(nodeSetupJSON);