diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f4bfa282fa..7d426efbb6 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -718,12 +718,12 @@ "swapSizes": "Swap Sizes" }, "nodes": { - "reloadSchema": "Reload Node Templates", - "saveGraph": "Save Workflow", - "loadGraph": "Load Workflow", - "resetGraph": "Reset Workflow", - "clearGraph": "Reset Graph", - "clearGraphDesc": "Are you sure you want to clear all nodes?", + "reloadNodeTemplates": "Reload Node Templates", + "saveWorkflow": "Save Workflow", + "loadWorkflow": "Load Workflow", + "resetWorkflow": "Reset Workflow", + "resetWorkflowDesc": "Are you sure you want to reset this workflow?", + "resetWorkflowDesc2": "Resetting the workflow will clear all nodes, edges and workflow details.", "zoomInNodes": "Zoom In", "zoomOutNodes": "Zoom Out", "fitViewportNodes": "Fit View", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx index 1b48b3b48a..885a2c6e87 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx @@ -24,34 +24,40 @@ type NodeWrapperProps = PropsWithChildren & { const NodeWrapper = (props: NodeWrapperProps) => { const { nodeId, width, children, selected } = props; - const selectNodeExecutionState = useMemo( + const selectIsInProgress = useMemo( () => createSelector( stateSelector, - ({ nodes }) => nodes.nodeExecutionStates[nodeId] + ({ nodes }) => + nodes.nodeExecutionStates[nodeId]?.status === NodeStatus.IN_PROGRESS ), [nodeId] ); - const nodeExecutionState = useAppSelector(selectNodeExecutionState); + const isInProgress = useAppSelector(selectIsInProgress); const [ - nodeSelectedOutlineLight, - nodeSelectedOutlineDark, + nodeSelectedLight, + nodeSelectedDark, + nodeInProgressLight, + nodeInProgressDark, shadowsXl, shadowsBase, ] = useToken('shadows', [ - 'nodeSelectedOutline.light', - 'nodeSelectedOutline.dark', + 'nodeSelected.light', + 'nodeSelected.dark', + 'nodeInProgress.light', + 'nodeInProgress.dark', 'shadows.xl', 'shadows.base', ]); const dispatch = useAppDispatch(); - const shadow = useColorModeValue( - nodeSelectedOutlineLight, - nodeSelectedOutlineDark + const selectedShadow = useColorModeValue(nodeSelectedLight, nodeSelectedDark); + const inProgressShadow = useColorModeValue( + nodeInProgressLight, + nodeInProgressDark ); const opacity = useAppSelector((state) => state.nodes.nodeOpacity); @@ -71,24 +77,9 @@ const NodeWrapper = (props: NodeWrapperProps) => { w: width ?? NODE_WIDTH, transitionProperty: 'common', transitionDuration: '0.1s', - shadow: selected - ? nodeExecutionState?.status === NodeStatus.IN_PROGRESS - ? undefined - : shadow - : undefined, + shadow: selected ? selectedShadow : undefined, cursor: 'grab', opacity, - borderWidth: 2, - borderColor: - nodeExecutionState?.status === NodeStatus.IN_PROGRESS - ? 'warning.300' - : 'base.200', - _dark: { - borderColor: - nodeExecutionState?.status === NodeStatus.IN_PROGRESS - ? 'warning.500' - : 'base.900', - }, }} > { zIndex: -1, }} /> + {children} ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 260655723e..15d8d58d7b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -1,20 +1,19 @@ -import { ButtonGroup, Tooltip } from '@chakra-ui/react'; +import { ButtonGroup } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; +import { + // shouldShowFieldTypeLegendChanged, + shouldShowMinimapPanelChanged, +} from 'features/nodes/store/nodesSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FaExpand, // FaInfo, FaMapMarkerAlt, - FaMinus, - FaPlus, } from 'react-icons/fa'; +import { FaMagnifyingGlassMinus, FaMagnifyingGlassPlus } from 'react-icons/fa6'; import { useReactFlow } from 'reactflow'; -import { - // shouldShowFieldTypeLegendChanged, - shouldShowMinimapPanelChanged, -} from 'features/nodes/store/nodesSlice'; const ViewportControls = () => { const { t } = useTranslation(); @@ -49,27 +48,24 @@ const ViewportControls = () => { return ( - - } - /> - - - } - /> - - - } - /> - + } + /> + } + /> + } + /> {/* { icon={} /> */} - - } - /> - + aria-label={ + shouldShowMinimapPanel + ? t('nodes.hideMinimapnodes') + : t('nodes.showMinimapnodes') + } + isChecked={shouldShowMinimapPanel} + onClick={handleClickedToggleMiniMapPanel} + icon={} + /> ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/LoadWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/LoadWorkflowButton.tsx index 6a413cbf04..8454f5539f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/LoadWorkflowButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/LoadWorkflowButton.tsx @@ -2,9 +2,11 @@ import { FileButton } from '@mantine/core'; import IAIIconButton from 'common/components/IAIIconButton'; import { useLoadWorkflowFromFile } from 'features/nodes/hooks/useLoadWorkflowFromFile'; import { memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { FaUpload } from 'react-icons/fa'; const LoadWorkflowButton = () => { + const { t } = useTranslation(); const resetRef = useRef<() => void>(null); const loadWorkflowFromFile = useLoadWorkflowFromFile(); return ( @@ -16,8 +18,8 @@ const LoadWorkflowButton = () => { {(props) => ( } - tooltip="Load Workflow" - aria-label="Load Workflow" + tooltip={t('nodes.loadWorkflow')} + aria-label={t('nodes.loadWorkflow')} {...props} /> )} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ReloadSchemaButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ReloadSchemaButton.tsx index ea51a57edd..905b0b74a2 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ReloadSchemaButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ReloadSchemaButton.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { FaSyncAlt } from 'react-icons/fa'; import { receivedOpenAPISchema } from 'services/api/thunks/schema'; -const ReloadSchemaButton = () => { +const ReloadNodeTemplatesButton = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -16,13 +16,13 @@ const ReloadSchemaButton = () => { return ( } - tooltip={t('nodes.reloadSchema')} - aria-label={t('nodes.reloadSchema')} + tooltip={t('nodes.reloadNodeTemplates')} + aria-label={t('nodes.reloadNodeTemplates')} onClick={handleReloadSchema} > - {t('nodes.reloadSchema')} + {t('nodes.reloadNodeTemplates')} ); }; -export default memo(ReloadSchemaButton); +export default memo(ReloadNodeTemplatesButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ClearGraphButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ResetWorkflowButton.tsx similarity index 78% rename from invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ClearGraphButton.tsx rename to invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ResetWorkflowButton.tsx index 1501d0270b..62e8bf7b4b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ClearGraphButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/ResetWorkflowButton.tsx @@ -6,7 +6,10 @@ import { AlertDialogHeader, AlertDialogOverlay, Button, + Divider, + Flex, Text, + VStack, useDisclosure, } from '@chakra-ui/react'; import { RootState } from 'app/store/store'; @@ -19,7 +22,7 @@ import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { FaTrash } from 'react-icons/fa'; -const ClearGraphButton = () => { +const ResetWorkflowButton = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -48,8 +51,8 @@ const ClearGraphButton = () => { <> } - tooltip={t('nodes.clearGraph')} - aria-label={t('nodes.clearGraph')} + tooltip={t('nodes.resetWorkflow')} + aria-label={t('nodes.resetWorkflow')} onClick={onOpen} isDisabled={!nodesCount} /> @@ -64,18 +67,21 @@ const ClearGraphButton = () => { - {t('nodes.clearGraph')} + {t('nodes.resetWorkflow')} - - {t('nodes.clearGraphDesc')} + + + {t('nodes.resetWorkflowDesc')} + {t('nodes.resetWorkflowDesc2')} + - @@ -85,4 +91,4 @@ const ClearGraphButton = () => { ); }; -export default memo(ClearGraphButton); +export default memo(ResetWorkflowButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/SaveWorkflowButton.tsx new file mode 100644 index 0000000000..45764307a3 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/SaveWorkflowButton.tsx @@ -0,0 +1,29 @@ +import IAIIconButton from 'common/components/IAIIconButton'; +import { useWorkflow } from 'features/nodes/hooks/useWorkflow'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaSave } from 'react-icons/fa'; + +const SaveWorkflowButton = () => { + const { t } = useTranslation(); + const workflow = useWorkflow(); + const handleSave = useCallback(() => { + const blob = new Blob([JSON.stringify(workflow, null, 2)]); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${workflow.name || 'My Workflow'}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + }, [workflow]); + return ( + } + tooltip={t('nodes.saveWorkflow')} + aria-label={t('nodes.saveWorkflow')} + onClick={handleSave} + /> + ); +}; + +export default memo(SaveWorkflowButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/TopCenterPanel.tsx index 38fbbca397..29e21acd03 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/TopCenterPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/TopCenterPanel.tsx @@ -1,16 +1,8 @@ -import { Flex } from '@chakra-ui/react'; import { memo } from 'react'; import { Panel } from 'reactflow'; -import WorkflowEditorControls from './WorkflowEditorControls'; const TopCenterPanel = () => { - return ( - - - - - - ); + return {null}; }; export default memo(TopCenterPanel); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/WorkflowEditorControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/WorkflowEditorControls.tsx index 66815deb5a..537d21902e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/WorkflowEditorControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopCenterPanel/WorkflowEditorControls.tsx @@ -1,15 +1,17 @@ import CancelButton from 'features/parameters/components/ProcessButtons/CancelButton'; import InvokeButton from 'features/parameters/components/ProcessButtons/InvokeButton'; import { memo } from 'react'; -import ClearGraphButton from './ClearGraphButton'; +import ResetWorkflowButton from './ResetWorkflowButton'; import LoadWorkflowButton from './LoadWorkflowButton'; +import SaveWorkflowButton from './SaveWorkflowButton'; const WorkflowEditorControls = () => { return ( <> - + + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx index e407eab955..cee6de70a6 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx @@ -1,8 +1,8 @@ -import { Tooltip } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; -import IAIButton from 'common/components/IAIButton'; +import IAIIconButton from 'common/components/IAIIconButton'; import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice'; import { memo, useCallback } from 'react'; +import { FaPlus } from 'react-icons/fa'; import { Panel } from 'reactflow'; const TopLeftPanel = () => { @@ -14,15 +14,12 @@ const TopLeftPanel = () => { return ( - - - Add Node - - + } + onClick={handleOpenAddNodePopover} + /> ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings.tsx index 20f33aba27..06a57bd875 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings.tsx @@ -27,7 +27,7 @@ import { import { ChangeEvent, memo, useCallback } from 'react'; import { FaCog } from 'react-icons/fa'; import { SelectionMode } from 'reactflow'; -import ReloadSchemaButton from '../TopCenterPanel/ReloadSchemaButton'; +import ReloadNodeTemplatesButton from '../TopCenterPanel/ReloadSchemaButton'; const formLabelProps: FormLabelProps = { fontWeight: 600, @@ -163,7 +163,7 @@ const WorkflowEditorSettings = () => { label="Validate Connections and Graph" helperText="Prevent invalid connections from being made, and invalid graphs from being invoked" /> - + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx index ed5386938f..aa3b1ad1be 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowJSONTab.tsx @@ -1,26 +1,10 @@ import { Flex } from '@chakra-ui/react'; -import { RootState } from 'app/store/store'; -import { useAppSelector } from 'app/store/storeHooks'; import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; -import { buildWorkflow } from 'features/nodes/util/buildWorkflow'; -import { memo, useMemo } from 'react'; -import { useDebounce } from 'use-debounce'; - -const useWatchWorkflow = () => { - const nodes = useAppSelector((state: RootState) => state.nodes); - const [debouncedNodes] = useDebounce(nodes, 300); - const workflow = useMemo( - () => buildWorkflow(debouncedNodes), - [debouncedNodes] - ); - - return { - workflow, - }; -}; +import { useWorkflow } from 'features/nodes/hooks/useWorkflow'; +import { memo } from 'react'; const WorkflowJSONTab = () => { - const { workflow } = useWatchWorkflow(); + const workflow = useWorkflow(); return ( { return ( @@ -21,8 +22,12 @@ const WorkflowPanel = () => { h: 'full', borderRadius: 'base', p: 4, + gap: 2, }} > + + + { + const nodes = useAppSelector((state: RootState) => state.nodes); + const [debouncedNodes] = useDebounce(nodes, 300); + const workflow = useMemo( + () => buildWorkflow(debouncedNodes), + [debouncedNodes] + ); + + return workflow; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 3b699ca435..dec0daabe9 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -600,9 +600,9 @@ const nodesSlice = createSlice({ state.workflow.contact = action.payload; }, workflowLoaded: (state, action: PayloadAction) => { - // TODO: validation const { nodes, edges, ...workflow } = action.payload; state.workflow = workflow; + state.nodes = applyNodeChanges( nodes.map((node) => ({ item: { ...node, dragHandle: `.${DRAG_HANDLE_CLASSNAME}` }, @@ -614,6 +614,16 @@ const nodesSlice = createSlice({ edges.map((edge) => ({ item: edge, type: 'add' })), [] ); + + state.nodeExecutionStates = nodes.reduce< + Record + >((acc, node) => { + acc[node.id] = { + nodeId: node.id, + ...initialNodeExecutionState, + }; + return acc; + }, {}); }, workflowReset: (state) => { state.workflow = cloneDeep(initialWorkflow); diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts index 33ab1991b8..26aa19bd9d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/types.ts +++ b/invokeai/frontend/web/src/features/nodes/types/types.ts @@ -807,7 +807,7 @@ export const zSemVer = z.string().refine((val) => { export type SemVer = z.infer; export const zWorkflow = z.object({ - name: z.string().trim().min(1), + name: z.string(), author: z.string(), description: z.string(), version: z.string(), diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/CancelButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/CancelButton.tsx index bb6e7e862d..e7bd36b931 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/CancelButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/CancelButton.tsx @@ -27,6 +27,7 @@ import { MdCancel, MdCancelScheduleSend } from 'react-icons/md'; import { ChevronDownIcon } from '@chakra-ui/icons'; import { sessionCanceled } from 'services/api/thunks/session'; +import IAIButton from 'common/components/IAIButton'; const cancelButtonSelector = createSelector( systemSelector, @@ -49,15 +50,14 @@ const cancelButtonSelector = createSelector( } ); -interface CancelButtonProps { +type Props = Omit & { btnGroupWidth?: string | number; -} + asIconButton?: boolean; +}; -const CancelButton = ( - props: CancelButtonProps & Omit -) => { +const CancelButton = (props: Props) => { const dispatch = useAppDispatch(); - const { btnGroupWidth = 'auto', ...rest } = props; + const { btnGroupWidth = 'auto', asIconButton = false, ...rest } = props; const { isProcessing, isConnected, @@ -124,16 +124,31 @@ const CancelButton = ( return ( - + {asIconButton ? ( + + ) : ( + + Cancel + + )} } /> - + ); diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts index 248e9d0a83..05ef152502 100644 --- a/invokeai/frontend/web/src/theme/theme.ts +++ b/invokeai/frontend/web/src/theme/theme.ts @@ -107,10 +107,15 @@ export const theme: ThemeOverride = { '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 3px var(--invokeai-colors-accent-500)', dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 3px var(--invokeai-colors-accent-400)', }, - nodeSelectedOutline: { + nodeSelected: { light: `0 0 0 2px var(--invokeai-colors-accent-400)`, dark: `0 0 0 2px var(--invokeai-colors-accent-500)`, }, + nodeInProgress: { + light: + '0 0 4px 2px var(--invokeai-colors-accent-500), 0 0 15px 4px var(--invokeai-colors-accent-600)', + dark: '0 0 4px 2px var(--invokeai-colors-accent-400), 0 0 15px 4px var(--invokeai-colors-accent-400)', + }, }, colors: InvokeAIColors, components: {