Compare commits

...

5 Commits

Author SHA1 Message Date
psychedelicious
70ac58e64a tidy(ui): remove unused props 2025-07-25 18:51:21 +10:00
psychedelicious
e653837236 fix(ui): add separate wrapper components for notes and current image nodes that do not need invocation node context 2025-07-25 18:51:21 +10:00
psychedelicious
2bbfcc2f13 fix(ui): ensure all node context provider wraps all calls to useInvocationNodeContext 2025-07-25 18:51:21 +10:00
psychedelicious
d6e0e439c5 perf(ui): imperatively get nodes and edges in autolayout hook 2025-07-25 18:50:59 +10:00
psychedelicious
26aab60f81 chore: bump version to v6.2.0 2025-07-25 18:41:00 +10:00
9 changed files with 280 additions and 113 deletions

View File

@@ -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 (
<NodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
<NonInvocationNodeWrapper nodeId={props.nodeProps.id} selected={props.nodeProps.selected} width={384}>
<Flex
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={DRAG_HANDLE_CLASSNAME}
position="relative"
flexDirection="column"
aspectRatio="1/1"
>
<Flex layerStyle="nodeHeader" borderTopRadius="base" alignItems="center" justifyContent="center" h={8}>
<Text fontSize="sm" fontWeight="semibold" color="base.200">
@@ -80,7 +81,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => {
)}
</Flex>
</Flex>
</NodeWrapper>
</NonInvocationNodeWrapper>
);
};

View File

@@ -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<Node<NotesNodeData>>) => {
}
return (
<NodeWrapper nodeId={nodeId} selected={selected}>
<NonInvocationNodeWrapper nodeId={nodeId} selected={selected}>
<Flex
layerStyle="nodeHeader"
borderTopRadius="base"
@@ -44,7 +44,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
h={8}
>
<NodeCollapseButton nodeId={nodeId} isOpen={isOpen} />
<NodeTitle nodeId={nodeId} title="Notes" />
<NonInvocationNodeTitle nodeId={nodeId} title="Notes" />
<Box minW={8} />
</Flex>
{isOpen && (
@@ -73,7 +73,7 @@ const NotesNode = (props: NodeProps<Node<NotesNodeData>>) => {
</Flex>
</>
)}
</NodeWrapper>
</NonInvocationNodeWrapper>
);
};

View File

@@ -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();

View File

@@ -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<HTMLInputElement>(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 (
<Flex overflow="hidden" w="full" h="full" alignItems="center" justifyContent="center">
{!editable.isEditing && (
<Text
className={NO_FIT_ON_DOUBLE_CLICK_CLASS}
fontWeight="semibold"
color="base.200"
onDoubleClick={editable.startEditing}
noOfLines={1}
>
{editable.value}
</Text>
)}
{editable.isEditing && (
<Input
ref={inputRef}
{...editable.inputProps}
variant="outline"
_focusVisible={{ borderRadius: 'base', h: 'unset' }}
/>
)}
</Flex>
);
};
export default memo(NonInvocationNodeTitle);

View File

@@ -0,0 +1,80 @@
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 { 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'];
};
const NonInvocationNodeWrapper = (props: NonInvocationNodeWrapperProps) => {
const { nodeId, width, children, selected } = props;
const mouseOverNode = useMouseOverNode(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 (
<Box
onClick={globalMenu.onCloseGlobal}
onDoubleClick={onDoubleClick}
onMouseOver={mouseOverNode.handleMouseOver}
onMouseOut={mouseOverNode.handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
>
<Box sx={shadowsSx} />
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
{children}
<Box className="node-selection-overlay" />
</Box>
);
};
export default memo(NonInvocationNodeWrapper);

View File

@@ -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',
},
};

View File

@@ -18,6 +18,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon
import { withResultAsync } from 'common/util/result';
import { parseify } from 'common/util/serialize';
import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import {
@@ -89,7 +90,11 @@ const OutputFields = memo(() => {
{t('workflows.builder.noOutputNodeSelected')}
</Text>
)}
{outputNodeId && <OutputFieldsContent outputNodeId={outputNodeId} />}
{outputNodeId && (
<InvocationNodeContextProvider nodeId={outputNodeId}>
<OutputFieldsContent outputNodeId={outputNodeId} />
</InvocationNodeContextProvider>
)}
</Flex>
);
});
@@ -127,7 +132,11 @@ const PublishableInputFields = memo(() => {
<Text fontWeight="semibold">{t('workflows.builder.publishedWorkflowInputs')}</Text>
<Divider />
{inputs.publishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
return (
<InvocationNodeContextProvider nodeId={nodeId} key={`${nodeId}-${fieldName}`}>
<NodeInputFieldPreview nodeId={nodeId} fieldName={fieldName} />
</InvocationNodeContextProvider>
);
})}
</Flex>
);
@@ -149,7 +158,11 @@ const UnpublishableInputFields = memo(() => {
</Text>
<Divider />
{inputs.unpublishable.map(({ nodeId, fieldName }) => {
return <NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />;
return (
<InvocationNodeContextProvider nodeId={nodeId} key={`${nodeId}-${fieldName}`}>
<NodeInputFieldPreview key={`${nodeId}-${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
</InvocationNodeContextProvider>
);
})}
</Flex>
);

View File

@@ -1,6 +1,6 @@
import { graphlib, layout } from '@dagrejs/dagre';
import type { Edge, NodePositionChange } from '@xyflow/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { nodesChanged } from 'features/nodes/store/nodesSlice';
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
import {
@@ -36,9 +36,7 @@ const getNodeWidth = (node: AnyNode): number => {
};
export const useAutoLayout = (): (() => void) => {
const dispatch = useAppDispatch();
const nodes = useAppSelector(selectNodes);
const edges = useAppSelector(selectEdges);
const store = useAppStore();
const nodeSpacing = useAppSelector(selectNodeSpacing);
const layerSpacing = useAppSelector(selectLayerSpacing);
const layeringStrategy = useAppSelector(selectLayeringStrategy);
@@ -46,6 +44,9 @@ export const useAutoLayout = (): (() => void) => {
const nodeAlignment = useAppSelector(selectNodeAlignment);
const autoLayout = useCallback(() => {
const state = store.getState();
const nodes = selectNodes(state);
const edges = selectEdges(state);
// We'll do graph layout using dagre, then convert the results to reactflow position changes
const g = new graphlib.Graph();
@@ -131,8 +132,8 @@ export const useAutoLayout = (): (() => void) => {
return { id: node.id, type: 'position', position: newPosition };
});
dispatch(nodesChanged(positionChanges));
}, [dispatch, edges, nodes, nodeSpacing, layerSpacing, layeringStrategy, layoutDirection, nodeAlignment]);
store.dispatch(nodesChanged(positionChanges));
}, [layerSpacing, layeringStrategy, layoutDirection, nodeAlignment, nodeSpacing, store]);
return autoLayout;
};

View File

@@ -1 +1 @@
__version__ = "6.1.0"
__version__ = "6.2.0"