From 79e93f905e368ca88a34bc963e10c6e61cbbca73 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Fri, 25 Jul 2025 18:38:07 +1000
Subject: [PATCH] fix(ui): add separate wrapper components for notes and
current image nodes that do not need invocation node context
---
.../nodes/CurrentImage/CurrentImageNode.tsx | 7 +-
.../components/flow/nodes/Notes/NotesNode.tsx | 10 +-
.../flow/nodes/common/NodeWrapper.tsx | 98 +------------------
.../nodes/common/NonInvocationNodeTitle.tsx | 69 +++++++++++++
.../nodes/common/NonInvocationNodeWrapper.tsx | 85 ++++++++++++++++
.../components/flow/nodes/common/shared.ts | 95 ++++++++++++++++++
6 files changed, 261 insertions(+), 103 deletions(-)
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeTitle.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/shared.ts
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',
+ },
+};