diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 944d8df328..414320519b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -6,7 +6,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { DndImage } from 'features/dnd/DndImage'; import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; -import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper'; +import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; import type { AnimationProps } from 'framer-motion'; import { motion } from 'framer-motion'; @@ -58,13 +58,14 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { }, []); const { t } = useTranslation(); return ( - + @@ -80,7 +81,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { )} - + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Notes/NotesNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Notes/NotesNode.tsx index cb4c51dd86..23d3cc8c0a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Notes/NotesNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Notes/NotesNode.tsx @@ -3,8 +3,8 @@ import { createSelector } from '@reduxjs/toolkit'; import type { Node, NodeProps } from '@xyflow/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import NodeCollapseButton from 'features/nodes/components/flow/nodes/common/NodeCollapseButton'; -import NodeTitle from 'features/nodes/components/flow/nodes/common/NodeTitle'; -import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper'; +import NonInvocationNodeTitle from 'features/nodes/components/flow/nodes/common/NonInvocationNodeTitle'; +import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper'; import { notesNodeValueChanged } from 'features/nodes/store/nodesSlice'; import { selectNodes } from 'features/nodes/store/selectors'; import { NO_DRAG_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants'; @@ -34,7 +34,7 @@ const NotesNode = (props: NodeProps>) => { } return ( - + >) => { h={8} > - + {isOpen && ( @@ -73,7 +73,7 @@ const NotesNode = (props: NodeProps>) => { )} - + ); }; 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 7151060595..26d46b1b2f 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 @@ -1,4 +1,4 @@ -import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import type { ChakraProps } from '@invoke-ai/ui-library'; import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context'; @@ -12,6 +12,8 @@ import { zNodeStatus } from 'features/nodes/types/invocation'; import type { MouseEvent, PropsWithChildren } from 'react'; import { memo, useCallback } from 'react'; +import { containerSx, inProgressSx, shadowsSx } from './shared'; + type NodeWrapperProps = PropsWithChildren & { nodeId: string; selected: boolean; @@ -19,100 +21,6 @@ type NodeWrapperProps = PropsWithChildren & { isMissingTemplate?: boolean; }; -// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large -// workflows even when the animations are GPU-accelerated CSS. - -const containerSx: SystemStyleObject = { - h: 'full', - position: 'relative', - borderRadius: 'base', - transitionProperty: 'none', - cursor: 'grab', - '--border-color': 'var(--invoke-colors-base-500)', - '--border-color-selected': 'var(--invoke-colors-blue-300)', - '--header-bg-color': 'var(--invoke-colors-base-900)', - '&[data-status="warning"]': { - '--border-color': 'var(--invoke-colors-warning-500)', - '--border-color-selected': 'var(--invoke-colors-warning-500)', - '--header-bg-color': 'var(--invoke-colors-warning-700)', - }, - '&[data-status="error"]': { - '--border-color': 'var(--invoke-colors-error-500)', - '--border-color-selected': 'var(--invoke-colors-error-500)', - '--header-bg-color': 'var(--invoke-colors-error-700)', - }, - // The action buttons are hidden by default and shown on hover - '& .node-selection-overlay': { - display: 'block', - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - borderRadius: 'base', - transitionProperty: 'none', - pointerEvents: 'none', - shadow: '0 0 0 1px var(--border-color)', - }, - '&[data-is-mouse-over-node="true"] .node-selection-overlay': { - display: 'block', - }, - '&[data-is-mouse-over-form-field="true"] .node-selection-overlay': { - display: 'block', - bg: 'invokeBlueAlpha.100', - }, - _hover: { - '& .node-selection-overlay': { - display: 'block', - shadow: '0 0 0 1px var(--border-color-selected)', - }, - '&[data-is-selected="true"] .node-selection-overlay': { - display: 'block', - shadow: '0 0 0 2px var(--border-color-selected)', - }, - }, - '&[data-is-selected="true"] .node-selection-overlay': { - display: 'block', - shadow: '0 0 0 2px var(--border-color-selected)', - }, - '&[data-is-editor-locked="true"]': { - '& *': { - cursor: 'not-allowed', - pointerEvents: 'none', - }, - }, -}; - -const shadowsSx: SystemStyleObject = { - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - borderRadius: 'base', - pointerEvents: 'none', - zIndex: -1, - shadow: 'var(--invoke-shadows-xl), var(--invoke-shadows-base), var(--invoke-shadows-base)', -}; - -const inProgressSx: SystemStyleObject = { - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - borderRadius: 'md', - pointerEvents: 'none', - transitionProperty: 'none', - opacity: 0.7, - zIndex: -1, - display: 'none', - shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)', - '&[data-is-in-progress="true"]': { - display: 'block', - }, -}; - const NodeWrapper = (props: NodeWrapperProps) => { const { nodeId, width, children, isMissingTemplate, selected } = props; const ctx = useInvocationNodeContext(); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeTitle.tsx new file mode 100644 index 0000000000..be58d895d0 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeTitle.tsx @@ -0,0 +1,69 @@ +import { Flex, Input, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEditable } from 'common/hooks/useEditable'; +import { nodeLabelChanged } from 'features/nodes/store/nodesSlice'; +import { selectNodes } from 'features/nodes/store/selectors'; +import { NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants'; +import { memo, useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + nodeId: string; + title: string; +}; + +const NonInvocationNodeTitle = ({ nodeId, title }: Props) => { + const dispatch = useAppDispatch(); + const selectNodeLabel = useMemo( + () => + createSelector(selectNodes, (nodes) => { + const node = nodes.find((n) => n.id === nodeId); + return node?.data?.label ?? ''; + }), + [nodeId] + ); + const label = useAppSelector(selectNodeLabel); + const { t } = useTranslation(); + const inputRef = useRef(null); + + const onChange = useCallback( + (label: string) => { + dispatch(nodeLabelChanged({ nodeId, label })); + }, + [dispatch, nodeId] + ); + + const editable = useEditable({ + value: label || title || t('nodes.problemSettingTitle'), + defaultValue: title || t('nodes.problemSettingTitle'), + onChange, + inputRef, + }); + + return ( + + {!editable.isEditing && ( + + {editable.value} + + )} + {editable.isEditing && ( + + )} + + ); +}; + +export default memo(NonInvocationNodeTitle); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper.tsx new file mode 100644 index 0000000000..84014d7dd9 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper.tsx @@ -0,0 +1,85 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; +import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; +import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; +import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode'; +import { selectNodeOpacity } from 'features/nodes/store/workflowSettingsSlice'; +import { DRAG_HANDLE_CLASSNAME, NO_FIT_ON_DOUBLE_CLICK_CLASS, NODE_WIDTH } from 'features/nodes/types/constants'; +import { zNodeStatus } from 'features/nodes/types/invocation'; +import type { MouseEvent, PropsWithChildren } from 'react'; +import { memo, useCallback } from 'react'; + +import { containerSx, inProgressSx, shadowsSx } from './shared'; + +type NonInvocationNodeWrapperProps = PropsWithChildren & { + nodeId: string; + selected: boolean; + width?: ChakraProps['w']; + isMissingTemplate?: boolean; +}; + +const NonInvocationNodeWrapper = (props: NonInvocationNodeWrapperProps) => { + const { nodeId, width, children, isMissingTemplate, selected } = props; + // Skip needsUpdate check since we don't have invocation context + const mouseOverNode = useMouseOverNode(nodeId); + const mouseOverFormField = useMouseOverFormField(nodeId); + const zoomToNode = useZoomToNode(nodeId); + const isLocked = useIsWorkflowEditorLocked(); + + const executionState = useNodeExecutionState(nodeId); + const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS; + + const opacity = useAppSelector(selectNodeOpacity); + const globalMenu = useGlobalMenuClose(); + + const onDoubleClick = useCallback( + (e: MouseEvent) => { + if (!(e.target instanceof HTMLElement)) { + // We have to manually narrow the type here thanks to a TS quirk + return; + } + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement || + e.target instanceof HTMLButtonElement || + e.target instanceof HTMLAnchorElement + ) { + // Don't fit the view if the user is editing a text field, select, button, or link + return; + } + if (e.target.closest(`.${NO_FIT_ON_DOUBLE_CLICK_CLASS}`) !== null) { + // This target is marked as not fitting the view on double click + return; + } + zoomToNode(); + }, + [zoomToNode] + ); + + return ( + + + + {children} + + + ); +}; + +export default memo(NonInvocationNodeWrapper); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/shared.ts b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/shared.ts new file mode 100644 index 0000000000..721c816b19 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/shared.ts @@ -0,0 +1,95 @@ +// Certain CSS transitions are disabled as a performance optimization - they can cause massive slowdowns in large +// workflows even when the animations are GPU-accelerated CSS. + +import type { SystemStyleObject } from '@invoke-ai/ui-library'; + +export const containerSx: SystemStyleObject = { + h: 'full', + position: 'relative', + borderRadius: 'base', + transitionProperty: 'none', + cursor: 'grab', + '--border-color': 'var(--invoke-colors-base-500)', + '--border-color-selected': 'var(--invoke-colors-blue-300)', + '--header-bg-color': 'var(--invoke-colors-base-900)', + '&[data-status="warning"]': { + '--border-color': 'var(--invoke-colors-warning-500)', + '--border-color-selected': 'var(--invoke-colors-warning-500)', + '--header-bg-color': 'var(--invoke-colors-warning-700)', + }, + '&[data-status="error"]': { + '--border-color': 'var(--invoke-colors-error-500)', + '--border-color-selected': 'var(--invoke-colors-error-500)', + '--header-bg-color': 'var(--invoke-colors-error-700)', + }, + // The action buttons are hidden by default and shown on hover + '& .node-selection-overlay': { + display: 'block', + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + borderRadius: 'base', + transitionProperty: 'none', + pointerEvents: 'none', + shadow: '0 0 0 1px var(--border-color)', + }, + '&[data-is-mouse-over-node="true"] .node-selection-overlay': { + display: 'block', + }, + '&[data-is-mouse-over-form-field="true"] .node-selection-overlay': { + display: 'block', + bg: 'invokeBlueAlpha.100', + }, + _hover: { + '& .node-selection-overlay': { + display: 'block', + shadow: '0 0 0 1px var(--border-color-selected)', + }, + '&[data-is-selected="true"] .node-selection-overlay': { + display: 'block', + shadow: '0 0 0 2px var(--border-color-selected)', + }, + }, + '&[data-is-selected="true"] .node-selection-overlay': { + display: 'block', + shadow: '0 0 0 2px var(--border-color-selected)', + }, + '&[data-is-editor-locked="true"]': { + '& *': { + cursor: 'not-allowed', + pointerEvents: 'none', + }, + }, +}; + +export const shadowsSx: SystemStyleObject = { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + borderRadius: 'base', + pointerEvents: 'none', + zIndex: -1, + shadow: 'var(--invoke-shadows-xl), var(--invoke-shadows-base), var(--invoke-shadows-base)', +}; + +export const inProgressSx: SystemStyleObject = { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + borderRadius: 'md', + pointerEvents: 'none', + transitionProperty: 'none', + opacity: 0.7, + zIndex: -1, + display: 'none', + shadow: '0 0 0 2px var(--invoke-colors-yellow-400), 0 0 20px 2px var(--invoke-colors-orange-700)', + '&[data-is-in-progress="true"]': { + display: 'block', + }, +};