From 2c979d1b680377aef6e227e26688a44ab98bd893 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 17 Nov 2023 18:06:26 +1100 Subject: [PATCH] wip --- invokeai/frontend/web/public/locales/en.json | 14 +- .../flow/nodes/Invocation/NotesTextarea.tsx | 17 +-- .../flow/nodes/common/NodeTitle.tsx | 4 + .../inspector/InspectorDetailsTab.tsx | 48 ++----- .../sidePanel/inspector/InspectorNotesTab.tsx | 43 ++++++ .../sidePanel/inspector/InspectorPanel.tsx | 47 ++++++- ...OutputsTab.tsx => InspectorResultsTab.tsx} | 4 +- .../inspector/details/EditableNodeTitle.tsx | 133 +++++++++++++----- .../inspector/details/InputFields.tsx | 20 +++ .../nodes/hooks/useNodeInputFields.ts | 56 ++++++++ .../src/features/nodes/hooks/useNodeNotes.ts | 28 ++++ .../features/nodes/hooks/useNodeVersion.ts | 18 ++- 12 files changed, 339 insertions(+), 93 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorNotesTab.tsx rename invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/{InspectorOutputsTab.tsx => InspectorResultsTab.tsx} (97%) create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/InputFields.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeInputFields.ts create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeNotes.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 561b577a46..be54798c2b 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -49,6 +49,7 @@ "back": "Back", "batch": "Batch Manager", "cancel": "Cancel", + "clickToEdit": "Click to Edit", "close": "Close", "on": "On", "communityLabel": "Community", @@ -853,7 +854,7 @@ "noConnectionData": "No connection data", "noConnectionInProgress": "No connection in progress", "node": "Node", - "nodeOutputs": "Node Outputs", + "nodeOutputs": "Node Results", "nodeSearch": "Search for nodes", "nodeTemplate": "Node Template", "nodeType": "Node Type", @@ -863,9 +864,9 @@ "noMatchingNodes": "No matching nodes", "noNodeSelected": "No node selected", "nodeOpacity": "Node Opacity", - "noOutputRecorded": "No outputs recorded", + "noOutputRecorded": "No results recorded", "noOutputSchemaName": "No output schema name found in ref object", - "notes": "Notes", + "notes": "Node Notes", "notesDescription": "Add notes about your workflow", "oNNXModelField": "ONNX Model", "oNNXModelFieldDescription": "ONNX model field.", @@ -943,7 +944,12 @@ "workflowValidation": "Workflow Validation Error", "workflowVersion": "Version", "zoomInNodes": "Zoom In", - "zoomOutNodes": "Zoom Out" + "zoomOutNodes": "Zoom Out", + "tabDetails": "Details", + "tabNotes": "Notes", + "tabResults": "Results", + "tabData": "Data", + "tabTemplate": "Template" }, "parameters": { "aspectRatio": "Aspect Ratio", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NotesTextarea.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NotesTextarea.tsx index 5e85f5ba3c..1ded57417d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NotesTextarea.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/NotesTextarea.tsx @@ -1,15 +1,15 @@ -import { FormControl, FormLabel } from '@chakra-ui/react'; +import { FormControl, FormLabel, Flex } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; import IAITextarea from 'common/components/IAITextarea'; -import { useNodeData } from 'features/nodes/hooks/useNodeData'; +import { useNodeNotes } from 'features/nodes/hooks/useNodeNotes'; import { nodeNotesChanged } from 'features/nodes/store/nodesSlice'; -import { isInvocationNodeData } from 'features/nodes/types/types'; +import { isNil } from 'lodash-es'; import { ChangeEvent, memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; const NotesTextarea = ({ nodeId }: { nodeId: string }) => { const dispatch = useAppDispatch(); - const data = useNodeData(nodeId); + const notes = useNodeNotes(nodeId); const { t } = useTranslation(); const handleNotesChanged = useCallback( (e: ChangeEvent) => { @@ -17,16 +17,17 @@ const NotesTextarea = ({ nodeId }: { nodeId: string }) => { }, [dispatch, nodeId] ); - if (!isInvocationNodeData(data)) { + if (isNil(notes)) { return null; } return ( - + {t('nodes.notes')} ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeTitle.tsx index e31ac19be0..6ef9a02dd1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeTitle.tsx @@ -28,6 +28,10 @@ const NodeTitle = ({ nodeId, title }: Props) => { const [localTitle, setLocalTitle] = useState(''); const handleSubmit = useCallback( async (newTitle: string) => { + if (!newTitle.trim()) { + setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle')); + return; + } dispatch(nodeLabelChanged({ nodeId, label: newTitle })); setLocalTitle( label || title || templateTitle || t('nodes.problemSettingTitle') diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx index a627f33f24..86ac003a34 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx @@ -22,9 +22,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { FaSync } from 'react-icons/fa'; import { Node } from 'reactflow'; -import NotesTextarea from '../../flow/nodes/Invocation/NotesTextarea'; import ScrollableContent from '../ScrollableContent'; -import EditableNodeTitle from './details/EditableNodeTitle'; +import InputFields from './details/InputFields'; const selector = createSelector( stateSelector, @@ -82,42 +81,23 @@ const Content = (props: { sx={{ flexDir: 'column', position: 'relative', - p: 1, gap: 2, w: 'full', }} > - - - - Node Type - - {props.template.title} - - - - - Node Version - - {props.node.data.version} - - - {needsUpdate && ( - } - onClick={updateNode} - /> - )} - - - + + Type + + {props.template.title} ({props.template.type}) + + + + Description + + {props.template.description} + + + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorNotesTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorNotesTab.tsx new file mode 100644 index 0000000000..4ed991afa1 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorNotesTab.tsx @@ -0,0 +1,43 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { isInvocationNode } from 'features/nodes/types/types'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import NotesTextarea from '../../flow/nodes/Invocation/NotesTextarea'; + +const selector = createSelector( + stateSelector, + ({ nodes }) => { + const lastSelectedNodeId = + nodes.selectedNodes[nodes.selectedNodes.length - 1]; + + const lastSelectedNode = nodes.nodes.find( + (node) => node.id === lastSelectedNodeId + ); + + if (!isInvocationNode(lastSelectedNode)) { + return; + } + + return lastSelectedNode.id; + }, + defaultSelectorOptions +); + +const InspectorNotesTab = () => { + const nodeId = useAppSelector(selector); + const { t } = useTranslation(); + + if (!nodeId) { + return ( + + ); + } + + return ; +}; + +export default memo(InspectorNotesTab); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx index e3dc6645c5..b56bbb0d38 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx @@ -6,13 +6,41 @@ import { TabPanels, Tabs, } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { isInvocationNode } from 'features/nodes/types/types'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import InspectorDataTab from './InspectorDataTab'; -import InspectorOutputsTab from './InspectorOutputsTab'; -import InspectorTemplateTab from './InspectorTemplateTab'; import InspectorDetailsTab from './InspectorDetailsTab'; +import InspectorNotesTab from './InspectorNotesTab'; +import InspectorResultsTab from './InspectorResultsTab'; +import InspectorTemplateTab from './InspectorTemplateTab'; +import EditableNodeTitle from './details/EditableNodeTitle'; +const selector = createSelector( + stateSelector, + ({ nodes }) => { + const lastSelectedNodeId = + nodes.selectedNodes[nodes.selectedNodes.length - 1]; + + const lastSelectedNode = nodes.nodes.find( + (node) => node.id === lastSelectedNodeId + ); + + if (!isInvocationNode(lastSelectedNode)) { + return; + } + + return lastSelectedNode.id; + }, + defaultSelectorOptions +); const InspectorPanel = () => { + const { t } = useTranslation(); + const nodeId = useAppSelector(selector); return ( { gap: 2, }} > + - Details - Outputs - Data - Template + {t('nodes.tabDetails')} + {t('nodes.tabNotes')} + {t('nodes.tabResults')} + {t('nodes.tabData')} + {t('nodes.tabTemplate')} @@ -41,7 +71,10 @@ const InspectorPanel = () => { - + + + + diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorResultsTab.tsx similarity index 97% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorResultsTab.tsx index f4abc621b4..171600497e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorResultsTab.tsx @@ -39,7 +39,7 @@ const selector = createSelector( defaultSelectorOptions ); -const InspectorOutputsTab = () => { +const InspectorResultsTab = () => { const { node, template, nes } = useAppSelector(selector); const { t } = useTranslation(); @@ -91,6 +91,6 @@ const InspectorOutputsTab = () => { ); }; -export default memo(InspectorOutputsTab); +export default memo(InspectorResultsTab); const getKey = (result: AnyResult, i: number) => `${result.type}-${i}`; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx index bf32046c6c..40c50ccc02 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx @@ -3,20 +3,89 @@ import { EditableInput, EditablePreview, Flex, + Text, } from '@chakra-ui/react'; import { useAppDispatch } from 'app/store/storeHooks'; +import IAIIconButton from 'common/components/IAIIconButton'; import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel'; import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle'; +import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion'; import { nodeLabelChanged } from 'features/nodes/store/nodesSlice'; import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { FaSync } from 'react-icons/fa'; -type Props = { - nodeId: string; - title?: string; +type EditableNodeTitleProps = { + nodeId?: string; }; -const EditableNodeTitle = ({ nodeId, title }: Props) => { +const EditableNodeTitle = (props: EditableNodeTitleProps) => { + if (!props.nodeId) { + return ( + + No node selected + + ); + } + + return ( + + + + + ); +}; + +type VersionProps = { + nodeId: string; +}; + +const Version = memo(({ nodeId }: VersionProps) => { + const { version, needsUpdate, updateNode } = useNodeVersion(nodeId); + + const { t } = useTranslation(); + + return ( + + + v{version} + + {needsUpdate && ( + } + variant="link" + onClick={updateNode} + /> + )} + + ); +}); + +Version.displayName = 'Version'; + +type EditableTitleProps = { + nodeId: string; +}; + +const EditableTitle = memo(({ nodeId }: EditableTitleProps) => { const dispatch = useAppDispatch(); const label = useNodeLabel(nodeId); const templateTitle = useNodeTemplateTitle(nodeId); @@ -25,12 +94,14 @@ const EditableNodeTitle = ({ nodeId, title }: Props) => { const [localTitle, setLocalTitle] = useState(''); const handleSubmit = useCallback( async (newTitle: string) => { + if (!newTitle.trim()) { + setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle')); + return; + } dispatch(nodeLabelChanged({ nodeId, label: newTitle })); - setLocalTitle( - label || title || templateTitle || t('nodes.problemSettingTitle') - ); + setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle')); }, - [dispatch, nodeId, title, templateTitle, label, t] + [dispatch, nodeId, templateTitle, label, t] ); const handleChange = useCallback((newTitle: string) => { @@ -39,36 +110,28 @@ const EditableNodeTitle = ({ nodeId, title }: Props) => { useEffect(() => { // Another component may change the title; sync local title with global state - setLocalTitle( - label || title || templateTitle || t('nodes.problemSettingTitle') - ); - }, [label, templateTitle, title, t]); + setLocalTitle(label || templateTitle || t('nodes.problemSettingTitle')); + }, [label, templateTitle, t]); return ( - - - - - - + + + ); -}; +}); + +EditableTitle.displayName = 'EditableTitle'; export default memo(EditableNodeTitle); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/InputFields.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/InputFields.tsx new file mode 100644 index 0000000000..11287619cf --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/InputFields.tsx @@ -0,0 +1,20 @@ +import { FormControl, FormLabel, Text } from '@chakra-ui/react'; +import { useNodeInputFields } from 'features/nodes/hooks/useNodeInputFields'; +import { memo } from 'react'; + +type Props = { nodeId: string }; +const InputFields = ({ nodeId }: Props) => { + const inputs = useNodeInputFields(nodeId); + return ( +
+ {inputs?.map(({ fieldData, fieldTemplate }) => ( + + {fieldData.label || fieldTemplate.title} + {fieldData.type} + + ))} +
+ ); +}; + +export default memo(InputFields); diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeInputFields.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeInputFields.ts new file mode 100644 index 0000000000..5af069be30 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeInputFields.ts @@ -0,0 +1,56 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useMemo } from 'react'; +import { + InputFieldTemplate, + InputFieldValue, + isInvocationNode, +} from '../types/types'; + +export const useNodeInputFields = ( + nodeId: string +): { fieldData: InputFieldValue; fieldTemplate: InputFieldTemplate }[] => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return []; + } + + const template = nodes.nodeTemplates[node.data.type]; + + if (!template) { + return []; + } + + const inputs = Object.values(node.data.inputs).reduce< + { + fieldData: InputFieldValue; + fieldTemplate: InputFieldTemplate; + }[] + >((acc, fieldData) => { + const fieldTemplate = template.inputs[fieldData.name]; + if (fieldTemplate) { + acc.push({ + fieldData, + fieldTemplate, + }); + } + return acc; + }, []); + + return inputs; + }, + defaultSelectorOptions + ), + [nodeId] + ); + + const inputs = useAppSelector(selector); + return inputs; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeNotes.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNotes.ts new file mode 100644 index 0000000000..3d84346330 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNotes.ts @@ -0,0 +1,28 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { useMemo } from 'react'; +import { isInvocationNode } from '../types/types'; + +export const useNodeNotes = (nodeId: string) => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return; + } + return node.data.notes; + }, + defaultSelectorOptions + ), + [nodeId] + ); + + const nodeNotes = useAppSelector(selector); + + return nodeNotes; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts index 1f213d6481..331d86e72b 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts @@ -1,10 +1,12 @@ import { createSelector } from '@reduxjs/toolkit'; +import { useAppToaster } from 'app/components/Toaster'; import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { satisfies } from 'compare-versions'; import { cloneDeep, defaultsDeep } from 'lodash-es'; import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { Node } from 'reactflow'; import { AnyInvocationType } from 'services/events/types'; import { nodeReplaced } from '../store/nodesSlice'; @@ -16,8 +18,6 @@ import { isInvocationNode, zParsedSemver, } from '../types/types'; -import { useAppToaster } from 'app/components/Toaster'; -import { useTranslation } from 'react-i18next'; export const getNeedsUpdate = ( node?: Node, @@ -115,5 +115,17 @@ export const useNodeVersion = (nodeId: string) => { dispatch(nodeReplaced({ nodeId: updatedNode.id, node: updatedNode })); }, [dispatch, node, nodeTemplate, t, toast]); - return { needsUpdate, mayUpdate, updateNode: _updateNode }; + const version = useMemo(() => { + if (!isInvocationNode(node)) { + return ''; + } + return node.data.version; + }, [node]); + + return { + needsUpdate, + mayUpdate, + updateNode: _updateNode, + version, + }; };