refactor(ui): workflows component structure (WIP)

- Simplify and de-insane-ify component structure, hooks, selectors, etc.
- Some perf improvements by using data attributes for styling instead of dynamic CSS-in-JS.
- Add field notes and start of linear view config, got blocked when I ran into deeper layout issues that made it very difficult to handle field configs. So those are WIP in this commit.
This commit is contained in:
psychedelicious
2025-01-20 13:27:44 +11:00
parent 52947f40c3
commit e479cb5fe4
44 changed files with 800 additions and 487 deletions

View File

@@ -1,13 +1,14 @@
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { OutputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldGate';
import { OutputFieldNodesEditorView } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldNodesEditorView';
import { useFieldNames } from 'features/nodes/hooks/useFieldNames';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { memo } from 'react';
import InputField from './fields/InputField';
import OutputField from './fields/OutputField';
import { InputFieldViewNodes } from './fields/InputFieldViewNodes';
import InvocationNodeFooter from './InvocationNodeFooter';
import InvocationNodeHeader from './InvocationNodeHeader';
@@ -42,34 +43,28 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
<Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
{fieldNames.connectionFields.map((fieldName, i) => (
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
<InputFieldGate nodeId={nodeId} fieldName={fieldName}>
<InputFieldViewNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
</GridItem>
))}
{outputFieldNames.map((fieldName, i) => (
<GridItem gridColumnStart={2} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.output-field`}>
<OutputField nodeId={nodeId} fieldName={fieldName} />
<OutputFieldGate nodeId={nodeId} fieldName={fieldName}>
<OutputFieldNodesEditorView nodeId={nodeId} fieldName={fieldName} />
</OutputFieldGate>
</GridItem>
))}
</Grid>
{fieldNames.anyOrDirectFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldViewNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
{fieldNames.missingFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
<InputFieldGate key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldViewNodes nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
</Flex>
</Flex>

View File

@@ -1,72 +1,79 @@
import { Tooltip } from '@invoke-ai/ui-library';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Tooltip } from '@invoke-ai/ui-library';
import { getFieldColor } from 'features/nodes/components/flow/edges/util/getEdgeColor';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import type { ValidationResult } from 'features/nodes/store/util/validateConnection';
import { HANDLE_TOOLTIP_OPEN_DELAY, MODEL_TYPES } from 'features/nodes/types/constants';
import { type FieldInputTemplate, type FieldOutputTemplate, isSingle } from 'features/nodes/types/field';
import type { FieldInputTemplate, FieldOutputTemplate } from 'features/nodes/types/field';
import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { HandleType } from 'reactflow';
import { Handle, Position } from 'reactflow';
type FieldHandleProps = {
fieldTemplate: FieldInputTemplate | FieldOutputTemplate;
type Props = {
handleType: HandleType;
fieldTemplate: FieldInputTemplate | FieldOutputTemplate;
isConnectionInProgress: boolean;
isConnectionStartField: boolean;
validationResult: ValidationResult;
};
const FieldHandle = (props: FieldHandleProps) => {
const { fieldTemplate, handleType, isConnectionInProgress, isConnectionStartField, validationResult } = props;
const sx = {
position: 'relative',
width: 'full',
height: 'full',
borderStyle: 'solid',
borderWidth: 4,
pointerEvents: 'none',
'&[data-cardinality="SINGLE"]': {
borderWidth: 0,
},
borderRadius: '100%',
'&[data-is-model-field="true"], &[data-is-batch-field="true"]': {
borderRadius: 4,
},
'&[data-is-batch-field="true"]': {
transform: 'rotate(45deg)',
},
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="false"][data-is-connection-valid="false"]':
{
filter: 'opacity(0.4) grayscale(0.7)',
cursor: 'not-allowed',
},
'&[data-is-connection-in-progress="true"][data-is-connection-start-field="true"][data-is-connection-valid="false"]': {
cursor: 'grab',
},
'&[data-is-connection-in-progress="false"] &[data-is-connection-valid="true"]': {
cursor: 'crosshair',
},
} satisfies SystemStyleObject;
const handleStyleBase = {
position: 'absolute',
width: '1rem',
height: '1rem',
zIndex: 1,
background: 'none',
border: 'none',
} satisfies CSSProperties;
const targetHandleStyle = {
...handleStyleBase,
insetInlineStart: '-1rem',
} satisfies CSSProperties;
const sourceHandleStyle = {
...handleStyleBase,
insetInlineEnd: '-1rem',
} satisfies CSSProperties;
export const FieldHandle = memo((props: Props) => {
const { fieldTemplate, isConnectionInProgress, isConnectionStartField, validationResult, handleType } = props;
const { t } = useTranslation();
const { name } = fieldTemplate;
const type = fieldTemplate.type;
const fieldTypeName = useFieldTypeName(type);
const styles: CSSProperties = useMemo(() => {
const isModelType = MODEL_TYPES.some((t) => t === type.name);
const color = getFieldColor(type);
const s: CSSProperties = {
backgroundColor: !isSingle(type) ? colorTokenToCssVar('base.900') : color,
position: 'absolute',
width: '1rem',
height: '1rem',
borderWidth: !isSingle(type) ? 4 : 0,
borderStyle: 'solid',
borderColor: color,
borderRadius: isModelType || type.batch ? 4 : '100%',
zIndex: 1,
transformOrigin: 'center',
};
if (type.batch) {
s.transform = 'rotate(45deg) translateX(-0.3rem) translateY(-0.3rem)';
}
if (handleType === 'target') {
s.insetInlineStart = '-1rem';
} else {
s.insetInlineEnd = '-1rem';
}
if (isConnectionInProgress && !isConnectionStartField && !validationResult.isValid) {
s.filter = 'opacity(0.4) grayscale(0.7)';
}
if (isConnectionInProgress && !validationResult.isValid) {
if (isConnectionStartField) {
s.cursor = 'grab';
} else {
s.cursor = 'not-allowed';
}
} else {
s.cursor = 'crosshair';
}
return s;
}, [handleType, isConnectionInProgress, isConnectionStartField, type, validationResult.isValid]);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => MODEL_TYPES.some((t) => t === fieldTemplate.type.name), [fieldTemplate.type]);
const tooltip = useMemo(() => {
if (isConnectionInProgress && validationResult.messageTKey) {
@@ -83,12 +90,24 @@ const FieldHandle = (props: FieldHandleProps) => {
>
<Handle
type={handleType}
id={name}
id={fieldTemplate.name}
position={handleType === 'target' ? Position.Left : Position.Right}
style={styles}
/>
style={handleType === 'target' ? targetHandleStyle : sourceHandleStyle}
>
<Box
sx={sx}
data-cardinality={fieldTemplate.type.cardinality}
data-is-batch-field={fieldTemplate.type.batch}
data-is-model-field={isModelField}
data-is-connection-in-progress={isConnectionInProgress}
data-is-connection-start-field={isConnectionStartField}
data-is-connection-valid={validationResult.isValid}
backgroundColor={fieldTemplate.type.cardinality === 'SINGLE' ? fieldColor : 'base.900'}
borderColor={fieldColor}
/>
</Handle>
</Tooltip>
);
};
});
export default memo(FieldHandle);
FieldHandle.displayName = 'FieldHandle';

View File

@@ -0,0 +1,85 @@
import {
FormControl,
FormLabel,
IconButton,
Popover,
PopoverContent,
PopoverTrigger,
Select,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldIsExposed } from 'features/nodes/hooks/useFieldIsExposed';
import { useFieldLinearViewConfig } from 'features/nodes/hooks/useFieldLinearViewConfig';
import { fieldLinearViewConfigChanged } from 'features/nodes/store/nodesSlice';
import type { FieldInputInstance } from 'features/nodes/types/field';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiWrenchFill } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
const parseNotesDisplay = (
notesDisplay: string
): NonNullable<FieldInputInstance['linearViewConfig']>['notesDisplay'] => {
switch (notesDisplay) {
case 'none':
return 'none';
case 'helper-text':
return 'helper-text';
case 'icon-with-popover':
return 'icon-with-popover';
default:
return 'none';
}
};
export const FieldLinearViewConfigIconButton = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isExposed = useFieldIsExposed(nodeId, fieldName);
const linearViewConfig = useFieldLinearViewConfig(nodeId, fieldName);
const onChangeNotesDisplay = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const notesDisplay = parseNotesDisplay(e.target.value);
dispatch(
fieldLinearViewConfigChanged({ nodeId, fieldName, linearViewConfig: { ...linearViewConfig, notesDisplay } })
);
},
[dispatch, fieldName, linearViewConfig, nodeId]
);
if (!isExposed) {
return null;
}
return (
<Popover>
<PopoverTrigger>
<IconButton
variant="ghost"
tooltip="Linear View Config"
aria-label="Linear View Config"
icon={<PiWrenchFill />}
pointerEvents="auto"
size="xs"
/>
</PopoverTrigger>
<PopoverContent p={2} w={256}>
<FormControl orientation="vertical">
<FormLabel>{t('nodes.notesDisplay')}</FormLabel>
<Select value={linearViewConfig?.notesDisplay ?? 'none'} onChange={onChangeNotesDisplay}>
<option value="none">{t('common.none')}</option>
<option value="helper-text">{t('nodes.helperText')}</option>
<option value="icon-with-popover">{t('nodes.iconWithPopover')}</option>
</Select>
</FormControl>
</PopoverContent>
</Popover>
);
});
FieldLinearViewConfigIconButton.displayName = 'FieldLinearViewConfigIconButton';

View File

@@ -1,13 +1,12 @@
import { IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldIsExposed } from 'features/nodes/hooks/useFieldIsExposed';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import {
selectWorkflowSlice,
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
} from 'features/nodes/store/workflowSlice';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiMinusBold, PiPlusBold } from 'react-icons/pi';
@@ -20,15 +19,7 @@ const FieldLinearViewToggle = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const value = useFieldValue(nodeId, fieldName);
const selectIsExposed = useMemo(
() =>
createSelector(selectWorkflowSlice, (workflow) => {
return Boolean(workflow.exposedFields.find((f) => f.nodeId === nodeId && f.fieldName === fieldName));
}),
[fieldName, nodeId]
);
const isExposed = useAppSelector(selectIsExposed);
const isExposed = useFieldIsExposed(nodeId, fieldName);
const handleExposeField = useCallback(() => {
dispatch(workflowExposedFieldAdded({ nodeId, fieldName, value }));

View File

@@ -1,12 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { isEqual } from 'lodash-es';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { FieldResetValueButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetValueButton';
import { useFieldDefaultValue } from 'features/nodes/hooks/useFieldDefaultValue';
import { memo } from 'react';
type Props = {
nodeId: string;
@@ -14,29 +8,9 @@ type Props = {
};
const FieldResetToDefaultValueButton = ({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const value = useFieldValue(nodeId, fieldName);
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const isDisabled = useMemo(() => {
return isEqual(value, fieldTemplate.default);
}, [value, fieldTemplate.default]);
const onClick = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: fieldTemplate.default }));
}, [dispatch, fieldName, fieldTemplate.default, nodeId]);
const { isValueChanged, resetToDefaultValue } = useFieldDefaultValue(nodeId, fieldName);
return (
<IconButton
variant="ghost"
tooltip={t('nodes.resetToDefaultValue')}
aria-label={t('nodes.resetToDefaultValue')}
icon={<PiArrowCounterClockwiseBold />}
onClick={onClick}
isDisabled={isDisabled}
pointerEvents="auto"
size="xs"
/>
);
return <FieldResetValueButton onClick={resetToDefaultValue} isDisabled={!isValueChanged} />;
};
export default memo(FieldResetToDefaultValueButton);

View File

@@ -0,0 +1,16 @@
import { FieldResetValueButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetValueButton';
import { useFieldInitialLinearViewValue } from 'features/nodes/hooks/useFieldInitialLinearViewValue';
import { memo } from 'react';
type Props = {
nodeId: string;
fieldName: string;
};
const FieldResetToInitialLinearViewValueButton = ({ nodeId, fieldName }: Props) => {
const { isValueChanged, resetToInitialLinearViewValue } = useFieldInitialLinearViewValue(nodeId, fieldName);
return <FieldResetValueButton onClick={resetToInitialLinearViewValue} isDisabled={!isValueChanged} />;
};
export default memo(FieldResetToInitialLinearViewValueButton);

View File

@@ -0,0 +1,25 @@
import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
type Props = Omit<IconButtonProps, 'aria-label'>;
export const FieldResetValueButton = memo((props: Props) => {
const { t } = useTranslation();
return (
<IconButton
variant="ghost"
tooltip={t('nodes.resetToDefaultValue')}
aria-label={t('nodes.resetToDefaultValue')}
icon={<PiArrowCounterClockwiseBold />}
pointerEvents="auto"
size="xs"
{...props}
/>
);
});
FieldResetValueButton.displayName = 'FieldResetValueButton';

View File

@@ -0,0 +1,23 @@
import { InputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldUnknownPlaceholder';
import { useFieldInputInstanceExists } from 'features/nodes/hooks/useFieldInputInstanceExists';
import { useFieldInputTemplateExists } from 'features/nodes/hooks/useFieldInputTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const InputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
const hasInstance = useFieldInputInstanceExists(nodeId, fieldName);
const hasTemplate = useFieldInputTemplateExists(nodeId, fieldName);
if (!hasTemplate || !hasInstance) {
return <InputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
}
return children;
});
InputFieldGate.displayName = 'InputFieldGate';

View File

@@ -0,0 +1,27 @@
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
import { useFieldInputName } from 'features/nodes/hooks/useFieldInputName';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldUnknownPlaceholder = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useFieldInputName(nodeId, fieldName);
return (
<InputFieldWrapper shouldDim={false}>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="center" gap={2} h="full" w="full">
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</InputFieldWrapper>
);
});
InputFieldUnknownPlaceholder.displayName = 'InputFieldUnknownPlaceholder';

View File

@@ -3,23 +3,22 @@ import { Box, Circle, Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai
import { useAppDispatch } from 'app/store/storeHooks';
import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator';
import { FieldNotesIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldNotesIconButton';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import FieldResetToInitialLinearViewValueButton from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetToInitialLinearViewValueButton';
import { useLinearViewFieldDnd } from 'features/nodes/components/sidePanel/workflow/useLinearViewFieldDnd';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
import { PiInfoBold, PiTrashSimpleBold } from 'react-icons/pi';
import EditableFieldTitle from './EditableFieldTitle';
import FieldTooltipContent from './FieldTooltipContent';
import InputFieldRenderer from './InputFieldRenderer';
type Props = {
fieldIdentifier: FieldIdentifier;
nodeId: string;
fieldName: string;
};
const sx = {
@@ -35,25 +34,24 @@ const sx = {
transitionProperty: 'common',
} satisfies SystemStyleObject;
const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => {
export const InputFieldViewLinear = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { isValueChanged, onReset } = useFieldOriginalValue(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(fieldIdentifier.nodeId);
const { isMouseOverNode, handleMouseOut, handleMouseOver } = useMouseOverNode(nodeId);
const { t } = useTranslation();
const handleRemoveField = useCallback(() => {
dispatch(workflowExposedFieldRemoved(fieldIdentifier));
}, [dispatch, fieldIdentifier]);
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
}, [dispatch, fieldName, nodeId]);
const ref = useRef<HTMLDivElement>(null);
const [dndListState, isDragging] = useLinearViewFieldDnd(ref, fieldIdentifier);
const [dndListState, isDragging] = useLinearViewFieldDnd(ref, { nodeId, fieldName });
return (
<Box position="relative" w="full">
<Flex
ref={ref}
// This is used to trigger the post-move flash animation
data-field-name={`${fieldIdentifier.nodeId}-${fieldIdentifier.fieldName}`}
data-field-name={`${nodeId}-${fieldName}`}
data-is-dragging={isDragging}
onMouseEnter={handleMouseOver}
onMouseLeave={handleMouseOut}
@@ -61,28 +59,13 @@ const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => {
>
<Flex flexDir="column" w="full">
<Flex alignItems="center" gap={2}>
<EditableFieldTitle nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} kind="inputs" />
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" />
<Spacer />
{isMouseOverNode && <Circle me={2} size={2} borderRadius="full" bg="invokeBlue.500" />}
<FieldNotesIconButton nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
{isValueChanged && (
<IconButton
aria-label={t('nodes.resetToDefaultValue')}
tooltip={t('nodes.resetToDefaultValue')}
variant="ghost"
size="sm"
onClick={onReset}
icon={<PiArrowCounterClockwiseBold />}
/>
)}
<FieldNotesIconButton nodeId={nodeId} fieldName={fieldName} />
<FieldResetToInitialLinearViewValueButton nodeId={nodeId} fieldName={fieldName} />
<Tooltip
label={
<FieldTooltipContent
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
kind="inputs"
/>
}
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="inputs" />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
>
@@ -99,20 +82,12 @@ const LinearViewFieldInternal = ({ fieldIdentifier }: Props) => {
icon={<PiTrashSimpleBold />}
/>
</Flex>
<InputFieldRenderer nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
</Flex>
<DndListDropIndicator dndState={dndListState} />
</Box>
);
};
});
const LinearViewField = ({ fieldIdentifier }: Props) => {
return (
<InvocationInputFieldCheck nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<LinearViewFieldInternal fieldIdentifier={fieldIdentifier} />
</InvocationInputFieldCheck>
);
};
export default memo(LinearViewField);
InputFieldViewLinear.displayName = 'InputFieldViewLinear';

View File

@@ -1,4 +1,5 @@
import { Flex, FormControl } from '@invoke-ai/ui-library';
import { FieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldHandle';
import { FieldLinearViewConfigIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldLinearViewConfigIconButton';
import { FieldNotesIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldNotesIconButton';
import FieldResetToDefaultValueButton from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetToDefaultValueButton';
@@ -8,7 +9,6 @@ import { useFieldIsInvalid } from 'features/nodes/hooks/useFieldIsInvalid';
import { memo, useCallback, useState } from 'react';
import EditableFieldTitle from './EditableFieldTitle';
import FieldHandle from './FieldHandle';
import FieldLinearViewToggle from './FieldLinearViewToggle';
import InputFieldRenderer from './InputFieldRenderer';
import { InputFieldWrapper } from './InputFieldWrapper';
@@ -18,13 +18,13 @@ interface Props {
fieldName: string;
}
const InputField = ({ nodeId, fieldName }: Props) => {
export const InputFieldViewNodes = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const [isHovered, setIsHovered] = useState(false);
const isInvalid = useFieldIsInvalid(nodeId, fieldName);
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
useConnectionState({ nodeId, fieldName, kind: 'inputs' });
useConnectionState(nodeId, fieldName, 'inputs');
const onMouseEnter = useCallback(() => {
setIsHovered(true);
@@ -49,8 +49,8 @@ const InputField = ({ nodeId, fieldName }: Props) => {
</FormControl>
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="target"
fieldTemplate={fieldTemplate}
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
@@ -73,10 +73,14 @@ const InputField = ({ nodeId, fieldName }: Props) => {
<Flex flexDir="column" w="full" gap={1} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex gap={1}>
<EditableFieldTitle nodeId={nodeId} fieldName={fieldName} kind="inputs" isInvalid={isInvalid} withTooltip />
{isHovered && <FieldLinearViewConfigIconButton nodeId={nodeId} fieldName={fieldName} />}
{isHovered && <FieldNotesIconButton nodeId={nodeId} fieldName={fieldName} />}
{isHovered && <FieldResetToDefaultValueButton nodeId={nodeId} fieldName={fieldName} />}
{isHovered && <FieldLinearViewToggle nodeId={nodeId} fieldName={fieldName} />}
{isHovered && (
<>
<FieldLinearViewConfigIconButton nodeId={nodeId} fieldName={fieldName} />
<FieldNotesIconButton nodeId={nodeId} fieldName={fieldName} />
<FieldResetToDefaultValueButton nodeId={nodeId} fieldName={fieldName} />
<FieldLinearViewToggle nodeId={nodeId} fieldName={fieldName} />
</>
)}
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
@@ -84,8 +88,8 @@ const InputField = ({ nodeId, fieldName }: Props) => {
{fieldTemplate.input !== 'direct' && (
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="target"
fieldTemplate={fieldTemplate}
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
@@ -93,6 +97,6 @@ const InputField = ({ nodeId, fieldName }: Props) => {
)}
</InputFieldWrapper>
);
};
});
export default memo(InputField);
InputFieldViewNodes.displayName = 'InputFieldViewNodes';

View File

@@ -0,0 +1,31 @@
import { Flex, FormLabel, Spacer } from '@invoke-ai/ui-library';
import { FieldNotesIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldNotesIconButton';
import FieldResetToInitialLinearViewValueButton from 'features/nodes/components/flow/nodes/Invocation/fields/FieldResetToInitialLinearViewValueButton';
import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
import { memo } from 'react';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldViewSimple = memo(({ nodeId, fieldName }: Props) => {
const label = useFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, 'inputs');
return (
<Flex position="relative" w="full" gap="2" flexDir="column">
<Flex alignItems="center" gap={1}>
<FormLabel fontSize="sm">{label || fieldTemplateTitle}</FormLabel>
<Spacer />
<FieldResetToInitialLinearViewValueButton nodeId={nodeId} fieldName={fieldName} />
<FieldNotesIconButton nodeId={nodeId} fieldName={fieldName} readOnly />
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
);
});
InputFieldViewSimple.displayName = 'InputFieldViewSimple';

View File

@@ -1,24 +1,29 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
const sx = {
position: 'relative',
minH: 8,
py: 0.5,
alignItems: 'center',
transitionProperty: 'opacity',
transitionDuration: '0.1s',
w: 'full',
h: 'full',
'&[data-should-dim="true"]': {
opacity: 0.5,
},
} satisfies SystemStyleObject;
type InputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
export const InputFieldWrapper = memo(({ shouldDim, children }: InputFieldWrapperProps) => {
return (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
w="full"
h="full"
>
<Flex sx={sx} data-should-dim={shouldDim}>
{children}
</Flex>
);

View File

@@ -1,59 +0,0 @@
import { Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const InvocationInputFieldCheck = memo(({ nodeId, fieldName, children }: Props) => {
const { t } = useTranslation();
const templates = useStore($templates);
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const instance = node.data.inputs[fieldName];
const template = templates[node.data.type];
const fieldTemplate = template?.inputs[fieldName];
return {
name: instance?.label || fieldTemplate?.title || fieldName,
hasInstance: Boolean(instance),
hasTemplate: Boolean(fieldTemplate),
};
}),
[fieldName, nodeId, templates]
);
const { hasInstance, hasTemplate, name } = useAppSelector(selector);
if (!hasTemplate || !hasInstance) {
return (
<Flex position="relative" minH={8} py={0.5} alignItems="center" w="full" h="full">
<FormControl
isInvalid={true}
alignItems="stretch"
justifyContent="center"
flexDir="column"
gap={2}
h="full"
w="full"
>
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</Flex>
);
}
return children;
});
InvocationInputFieldCheck.displayName = 'InvocationInputFieldCheck';

View File

@@ -1,82 +0,0 @@
import { Flex, FormControl, FormLabel, Tooltip } from '@invoke-ai/ui-library';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useFieldOutputTemplate } from 'features/nodes/hooks/useFieldOutputTemplate';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FieldHandle from './FieldHandle';
import FieldTooltipContent from './FieldTooltipContent';
interface Props {
nodeId: string;
fieldName: string;
}
const OutputField = ({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const fieldTemplate = useFieldOutputTemplate(nodeId, fieldName);
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
useConnectionState({ nodeId, fieldName, kind: 'outputs' });
if (!fieldTemplate) {
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<FormControl alignItems="stretch" justifyContent="space-between" gap={2} h="full" w="full">
<FormLabel display="flex" alignItems="center" h="full" color="error.300" mb={0} px={1} gap={2}>
{t('nodes.unknownOutput', {
name: fieldName,
})}
</FormLabel>
</FormControl>
</OutputFieldWrapper>
);
}
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<Tooltip
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="outputs" />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
shouldWrapChildren
>
<FormControl isDisabled={isConnected} pe={2}>
<FormLabel mb={0}>{fieldTemplate?.title}</FormLabel>
</FormControl>
</Tooltip>
<FieldHandle
fieldTemplate={fieldTemplate}
handleType="source"
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
/>
</OutputFieldWrapper>
);
};
export default memo(OutputField);
type OutputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
const OutputFieldWrapper = memo(({ shouldDim, children }: OutputFieldWrapperProps) => (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
justifyContent="flex-end"
>
{children}
</Flex>
));
OutputFieldWrapper.displayName = 'OutputFieldWrapper';

View File

@@ -0,0 +1,21 @@
import { OutputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldUnknownPlaceholder';
import { useFieldOutputTemplateExists } from 'features/nodes/hooks/useFieldOutputTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const OutputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
const hasTemplate = useFieldOutputTemplateExists(nodeId, fieldName);
if (!hasTemplate) {
return <OutputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
}
return children;
});
OutputFieldGate.displayName = 'OutputFieldGate';

View File

@@ -0,0 +1,46 @@
import { FormControl, FormLabel, Tooltip } from '@invoke-ai/ui-library';
import { FieldHandle } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldHandle';
import { OutputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldWrapper';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useFieldOutputTemplate } from 'features/nodes/hooks/useFieldOutputTemplate';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import FieldTooltipContent from './FieldTooltipContent';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const OutputFieldNodesEditorView = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useFieldOutputTemplate(nodeId, fieldName);
const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } =
useConnectionState(nodeId, fieldName, 'outputs');
return (
<OutputFieldWrapper shouldDim={shouldDim}>
<Tooltip
label={<FieldTooltipContent nodeId={nodeId} fieldName={fieldName} kind="outputs" />}
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
placement="top"
shouldWrapChildren
>
<FormControl isDisabled={isConnected}>
<FormLabel mb={0}>{fieldTemplate?.title}</FormLabel>
</FormControl>
</Tooltip>
<FieldHandle
handleType="source"
fieldTemplate={fieldTemplate}
isConnectionInProgress={isConnectionInProgress}
isConnectionStartField={isConnectionStartField}
validationResult={validationResult}
/>
</OutputFieldWrapper>
);
});
OutputFieldNodesEditorView.displayName = 'OutputFieldNodesEditorView';

View File

@@ -0,0 +1,27 @@
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
import { OutputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldWrapper';
import { useFieldOutputName } from 'features/nodes/hooks/useFieldOutputName';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
export const OutputFieldUnknownPlaceholder = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useFieldOutputName(nodeId, fieldName);
return (
<OutputFieldWrapper shouldDim={false}>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="space-between" gap={2} h="full" w="full">
<FormLabel display="flex" alignItems="center" h="full" color="error.300" mb={0} px={1} gap={2}>
{t('nodes.unknownOutput', { name })}
</FormLabel>
</FormControl>
</OutputFieldWrapper>
);
});
OutputFieldUnknownPlaceholder.displayName = 'OutputFieldUnknownPlaceholder';

View File

@@ -0,0 +1,29 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
const sx = {
position: 'relative',
minH: 8,
py: 0.5,
alignItems: 'center',
transitionProperty: 'opacity',
transitionDuration: '0.1s',
justifyContent: 'flex-end',
'&[data-should-dim="true"]': {
opacity: 0.5,
},
} satisfies SystemStyleObject;
type OutputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
export const OutputFieldWrapper = memo(({ shouldDim, children }: OutputFieldWrapperProps) => (
<Flex sx={sx} data-should-dim={shouldDim}>
{children}
</Flex>
));
OutputFieldWrapper.displayName = 'OutputFieldWrapper';

View File

@@ -12,9 +12,9 @@ import { memo, useCallback, useRef } from 'react';
import type { ImperativePanelGroupHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import InspectorPanel from './inspector/InspectorPanel';
import { WorkflowViewMode } from './viewMode/WorkflowViewMode';
import WorkflowPanel from './workflow/WorkflowPanel';
import WorkflowNodeInspectorPanel from './inspector/WorkflowNodeInspectorPanel';
import { FieldsSimpleView } from './viewMode/WorkflowSimpleView';
import WorkflowFieldsLinearViewPanel from './workflow/WorkflowPanel';
import { WorkflowListMenu } from './WorkflowListMenu/WorkflowListMenu';
import { WorkflowListMenuTrigger } from './WorkflowListMenu/WorkflowListMenuTrigger';
@@ -25,7 +25,7 @@ const overlayScrollbarsStyles: CSSProperties = {
width: '100%',
};
const NodeEditorPanelGroup = () => {
const WorkflowsLeftPanel = () => {
const mode = useAppSelector(selectWorkflowMode);
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null);
const workflowListMenu = useWorkflowListMenu();
@@ -50,9 +50,9 @@ const NodeEditorPanelGroup = () => {
</OverlayScrollbarsComponent>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
{mode === 'view' && <WorkflowViewMode />}
{mode === 'edit' && (
{mode === 'view' && <FieldsSimpleView />}
{mode === 'edit' && (
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<PanelGroup
ref={panelGroupRef}
id="workflow-panel-group"
@@ -61,19 +61,19 @@ const NodeEditorPanelGroup = () => {
style={panelGroupStyles}
>
<Panel id="workflow" collapsible minSize={25}>
<WorkflowPanel />
<WorkflowFieldsLinearViewPanel />
</Panel>
<ResizeHandle onDoubleClick={handleDoubleClickHandle} />
<Panel id="inspector" collapsible minSize={25}>
<InspectorPanel />
<WorkflowNodeInspectorPanel />
</Panel>
</PanelGroup>
)}
</OverlayScrollbarsComponent>
</OverlayScrollbarsComponent>
)}
</Box>
</Flex>
</Flex>
);
};
export default memo(NodeEditorPanelGroup);
export default memo(WorkflowsLeftPanel);

View File

@@ -7,7 +7,7 @@ import InspectorDetailsTab from './InspectorDetailsTab';
import InspectorOutputsTab from './InspectorOutputsTab';
import InspectorTemplateTab from './InspectorTemplateTab';
const InspectorPanel = () => {
const WorkflowNodeInspectorPanel = () => {
const { t } = useTranslation();
return (
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
@@ -38,4 +38,4 @@ const InspectorPanel = () => {
);
};
export default memo(InspectorPanel);
export default memo(WorkflowNodeInspectorPanel);

View File

@@ -1,52 +0,0 @@
import { Flex, FormLabel, IconButton, Spacer } from '@invoke-ai/ui-library';
import { FieldNotesIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/FieldNotesIconButton';
import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
import { t } from 'i18next';
import { memo } from 'react';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
type Props = {
nodeId: string;
fieldName: string;
};
const WorkflowFieldInternal = ({ nodeId, fieldName }: Props) => {
const label = useFieldLabel(nodeId, fieldName);
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, 'inputs');
const { isValueChanged, onReset } = useFieldOriginalValue(nodeId, fieldName);
return (
<Flex position="relative" w="full" gap="2" flexDir="column">
<Flex alignItems="center" gap={1}>
<FormLabel fontSize="sm">{label || fieldTemplateTitle}</FormLabel>
<Spacer />
{isValueChanged && (
<IconButton
aria-label={t('nodes.resetToDefaultValue')}
tooltip={t('nodes.resetToDefaultValue')}
variant="ghost"
size="xs"
onClick={onReset}
icon={<PiArrowCounterClockwiseBold />}
/>
)}
<FieldNotesIconButton nodeId={nodeId} fieldName={fieldName} readOnly />
</Flex>
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
</Flex>
);
};
const WorkflowField = ({ nodeId, fieldName }: Props) => {
return (
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<WorkflowFieldInternal nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
);
};
export default memo(WorkflowField);

View File

@@ -0,0 +1,52 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldViewSimple } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewSimple';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { t } from 'i18next';
import { memo } from 'react';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import { EmptyState } from './EmptyState';
const selectExposedFields = createMemoizedSelector(selectWorkflowSlice, (workflow) => workflow.exposedFields);
export const FieldsSimpleView = memo(() => {
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} w="full" h="full">
<FieldSimpleViewContent />
</Flex>
</ScrollableContent>
</Box>
);
});
FieldsSimpleView.displayName = 'FieldsSimpleView';
const FieldSimpleViewContent = memo(() => {
const { isLoading } = useGetOpenAPISchemaQuery();
const exposedFields = useAppSelector(selectExposedFields);
if (isLoading) {
return <IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />;
}
if (exposedFields.length === 0) {
return <EmptyState />;
}
return (
<>
{exposedFields.map(({ nodeId, fieldName }) => (
<InputFieldGate key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldViewSimple nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
</>
);
});
FieldSimpleViewContent.displayName = 'FieldSimpleViewContent';

View File

@@ -1,40 +0,0 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { t } from 'i18next';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
import { EmptyState } from './EmptyState';
import WorkflowField from './WorkflowField';
const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => {
return {
fields: workflow.exposedFields,
name: workflow.name,
};
});
export const WorkflowViewMode = () => {
const { isLoading } = useGetOpenAPISchemaQuery();
const { fields } = useAppSelector(selector);
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} w="full" h="full">
{isLoading ? (
<IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />
) : fields.length ? (
fields.map(({ nodeId, fieldName }) => (
<WorkflowField key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName} />
))
) : (
<EmptyState />
)}
</Flex>
</ScrollableContent>
</Box>
);
};

View File

@@ -1,7 +1,7 @@
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
import { Box, Flex } from '@invoke-ai/ui-library';
import { Flex } from '@invoke-ai/ui-library';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
@@ -10,7 +10,8 @@ import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { deepClone } from 'common/util/deepClone';
import { singleWorkflowFieldDndSource } from 'features/dnd/dnd';
import { triggerPostMoveFlash } from 'features/dnd/util';
import LinearViewFieldInternal from 'features/nodes/components/flow/nodes/Invocation/fields/LinearViewField';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldViewLinear } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewLinear';
import { selectWorkflowSlice, workflowExposedFieldsReordered } from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { isEqual } from 'lodash-es';
@@ -23,13 +24,11 @@ const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => workf
const WorkflowLinearTab = () => {
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} py={2} gap={2} h="full" w="full">
<FieldListContent />
</Flex>
</ScrollableContent>
</Box>
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} py={2} gap={2} h="full" w="full">
<FieldListContent />
</Flex>
</ScrollableContent>
);
};
@@ -142,11 +141,10 @@ const FieldListInnerContent = memo(({ fields }: { fields: FieldIdentifier[] }) =
return (
<>
{fields.map((fieldIdentifier) => (
<LinearViewFieldInternal
key={`${fieldIdentifier.nodeId}.${fieldIdentifier.fieldName}`}
fieldIdentifier={fieldIdentifier}
/>
{fields.map(({ nodeId, fieldName }) => (
<InputFieldGate key={`${nodeId}.${fieldName}`} nodeId={nodeId} fieldName={fieldName}>
<InputFieldViewLinear nodeId={nodeId} fieldName={fieldName} />
</InputFieldGate>
))}
</>
);

View File

@@ -6,7 +6,7 @@ import WorkflowGeneralTab from './WorkflowGeneralTab';
import WorkflowJSONTab from './WorkflowJSONTab';
import WorkflowLinearTab from './WorkflowLinearTab';
const WorkflowPanel = () => {
const WorkflowFieldsLinearViewPanel = () => {
const { t } = useTranslation();
return (
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
@@ -33,4 +33,4 @@ const WorkflowPanel = () => {
);
};
export default memo(WorkflowPanel);
export default memo(WorkflowFieldsLinearViewPanel);

View File

@@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext } from 'react';
import { assert } from 'tsafe';
type ViewType = 'user-linear' | 'editor-linear' | 'editor-nodes';
type ViewType = 'linear-user' | 'linear-editor' | 'nodes-editor';
const ViewContext = createContext<ViewType | null>(null);

View File

@@ -6,29 +6,22 @@ import { selectNodesSlice } from 'features/nodes/store/selectors';
import { makeConnectionErrorSelector } from 'features/nodes/store/util/makeConnectionErrorSelector';
import { useMemo } from 'react';
type UseConnectionStateProps = {
nodeId: string;
fieldName: string;
kind: 'inputs' | 'outputs';
};
export const useConnectionState = ({ nodeId, fieldName, kind }: UseConnectionStateProps) => {
export const useConnectionState = (nodeId: string, fieldName: string, kind: 'inputs' | 'outputs') => {
const pendingConnection = useStore($pendingConnection);
const templates = useStore($templates);
const edgePendingUpdate = useStore($edgePendingUpdate);
const selectIsConnected = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) =>
Boolean(
nodes.edges.filter((edge) => {
return (
(kind === 'inputs' ? edge.target : edge.source) === nodeId &&
(kind === 'inputs' ? edge.targetHandle : edge.sourceHandle) === fieldName
);
}).length
)
),
createSelector(selectNodesSlice, (nodes) => {
const firstConnectedEdge = nodes.edges.find((edge) => {
return (
(kind === 'inputs' ? edge.target : edge.source) === nodeId &&
(kind === 'inputs' ? edge.targetHandle : edge.sourceHandle) === fieldName
);
});
return firstConnectedEdge !== undefined;
}),
[fieldName, kind, nodeId]
);

View File

@@ -0,0 +1,23 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import { useFieldValue } from 'features/nodes/hooks/useFieldValue';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { isEqual } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useFieldDefaultValue = (nodeId: string, fieldName: string) => {
const dispatch = useAppDispatch();
const value = useFieldValue(nodeId, fieldName);
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const isValueChanged = useMemo(() => {
return !isEqual(value, fieldTemplate.default);
}, [value, fieldTemplate.default]);
const resetToDefaultValue = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: fieldTemplate.default }));
}, [dispatch, fieldName, fieldTemplate.default, nodeId]);
return { defaultValue: fieldTemplate.default, isValueChanged, resetToDefaultValue };
};

View File

@@ -6,9 +6,9 @@ import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { isEqual } from 'lodash-es';
import { useCallback, useMemo } from 'react';
export const useFieldOriginalValue = (nodeId: string, fieldName: string) => {
export const useFieldInitialLinearViewValue = (nodeId: string, fieldName: string) => {
const dispatch = useAppDispatch();
const selectOriginalExposedFieldValues = useMemo(
const selectInitialLinearViewValue = useMemo(
() =>
createSelector(
selectWorkflowSlice,
@@ -17,12 +17,12 @@ export const useFieldOriginalValue = (nodeId: string, fieldName: string) => {
),
[nodeId, fieldName]
);
const originalValue = useAppSelector(selectOriginalExposedFieldValues);
const initialLinearViewValue = useAppSelector(selectInitialLinearViewValue);
const value = useFieldValue(nodeId, fieldName);
const isValueChanged = useMemo(() => !isEqual(value, originalValue), [value, originalValue]);
const onReset = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: originalValue }));
}, [dispatch, fieldName, nodeId, originalValue]);
const isValueChanged = useMemo(() => !isEqual(value, initialLinearViewValue), [value, initialLinearViewValue]);
const resetToInitialLinearViewValue = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: initialLinearViewValue }));
}, [dispatch, fieldName, nodeId, initialLinearViewValue]);
return { originalValue, isValueChanged, onReset };
return { initialLinearViewValue, isValueChanged, resetToInitialLinearViewValue };
};

View File

@@ -0,0 +1,20 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useFieldInputInstanceExists = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const instance = node.data.inputs[fieldName];
return Boolean(instance);
}),
[fieldName, nodeId]
);
const exists = useAppSelector(selector);
return exists;
};

View File

@@ -0,0 +1,27 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useFieldInputName = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const instance = node.data.inputs[fieldName];
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.inputs[fieldName];
const name = instance?.label || fieldTemplate?.title || fieldName;
return name;
}),
[fieldName, nodeId, templates]
);
const name = useAppSelector(selector);
return name;
};

View File

@@ -7,7 +7,7 @@ export const useFieldInputTemplate = (nodeId: string, fieldName: string): FieldI
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Field template for field ${fieldName} not found`);
assert(_fieldTemplate, `Template for input field ${fieldName} not found.`);
return _fieldTemplate;
}, [fieldName, template.inputs]);
return fieldTemplate;

View File

@@ -0,0 +1,25 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useFieldInputTemplateExists = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.inputs[fieldName];
return Boolean(fieldTemplate);
}),
[fieldName, nodeId, templates]
);
const exists = useAppSelector(selector);
return exists;
};

View File

@@ -0,0 +1,17 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { useMemo } from 'react';
export const useFieldIsExposed = (nodeId: string, fieldName: string) => {
const selectIsExposed = useMemo(
() =>
createSelector(selectWorkflowSlice, (workflow) => {
return Boolean(workflow.exposedFields.find((f) => f.nodeId === nodeId && f.fieldName === fieldName));
}),
[fieldName, nodeId]
);
const isExposed = useAppSelector(selectIsExposed);
return isExposed;
};

View File

@@ -22,7 +22,7 @@ import { useMemo } from 'react';
export const useFieldIsInvalid = (nodeId: string, fieldName: string) => {
const template = useFieldInputTemplate(nodeId, fieldName);
const connectionState = useConnectionState({ nodeId, fieldName, kind: 'inputs' });
const connectionState = useConnectionState(nodeId, fieldName, 'inputs');
const selectIsInvalid = useMemo(() => {
return createSelector(selectNodesSlice, (nodes) => {

View File

@@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useFieldLinearViewConfig = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
}
return node?.data.inputs[fieldName]?.linearViewConfig;
}),
[fieldName, nodeId]
);
const linearViewConfig = useAppSelector(selector);
return linearViewConfig;
};

View File

@@ -0,0 +1,26 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useFieldOutputName = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.outputs[fieldName];
const name = fieldTemplate?.title || fieldName;
return name;
}),
[fieldName, nodeId, templates]
);
const name = useAppSelector(selector);
return name;
};

View File

@@ -1,9 +1,14 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldOutputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useFieldOutputTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate | null => {
export const useFieldOutputTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate => {
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => template.outputs[fieldName] ?? null, [fieldName, template.outputs]);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.outputs[fieldName];
assert(_fieldTemplate, `Template for output field ${fieldName} not found`);
return _fieldTemplate;
}, [fieldName, template.outputs]);
return fieldTemplate;
};

View File

@@ -0,0 +1,25 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useFieldOutputTemplateExists = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.outputs[fieldName];
return Boolean(fieldTemplate);
}),
[fieldName, nodeId, templates]
);
const exists = useAppSelector(selector);
return exists;
};

View File

@@ -14,6 +14,7 @@ import type {
ControlLoRAModelFieldValue,
ControlNetModelFieldValue,
EnumFieldValue,
FieldInputInstance,
FieldValue,
FloatFieldValue,
FloatGeneratorFieldValue,
@@ -424,6 +425,21 @@ export const nodesSlice = createSlice({
}
field.notes = val;
},
fieldLinearViewConfigChanged: (
state,
action: PayloadAction<{
nodeId: string;
fieldName: string;
linearViewConfig?: FieldInputInstance['linearViewConfig'];
}>
) => {
const { nodeId, fieldName, linearViewConfig } = action.payload;
const field = getField(nodeId, fieldName, state);
if (!field) {
return;
}
field.linearViewConfig = linearViewConfig;
},
notesNodeValueChanged: (state, action: PayloadAction<{ nodeId: string; value: string }>) => {
const { nodeId, value } = action.payload;
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
@@ -492,6 +508,7 @@ export const {
fieldIntegerGeneratorValueChanged,
fieldStringGeneratorValueChanged,
fieldNotesChanged,
fieldLinearViewConfigChanged,
nodeEditorReset,
nodeIsIntermediateChanged,
nodeIsOpenChanged,

View File

@@ -35,10 +35,14 @@ import { zBoardField, zColorField, zImageField, zModelIdentifierField, zSchedule
// #region Base schemas & misc
const zFieldInput = z.enum(['connection', 'direct', 'any', 'batch']);
const zFieldUIComponent = z.enum(['none', 'textarea', 'slider']);
const zFieldInputInstanceLinearViewConfigBase = z.object({
notesDisplay: z.enum(['none', 'helper-text', 'icon-with-popover']).nullish(),
});
const zFieldInputInstanceBase = z.object({
name: z.string().trim().min(1),
label: z.string().nullish(),
notes: z.string().nullish(),
linearViewConfig: zFieldInputInstanceLinearViewConfigBase.nullish(),
});
const zFieldTemplateBase = z.object({
name: z.string().min(1),

View File

@@ -5,7 +5,7 @@ import { CanvasRightPanel } from 'features/controlLayers/components/CanvasRightP
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { ImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer';
import NodeEditorPanelGroup from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import WorkflowsLeftPanel from 'features/nodes/components/sidePanel/NodeEditorPanelGroup';
import QueueControls from 'features/queue/components/QueueControls';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import FloatingGalleryButton from 'features/ui/components/FloatingGalleryButton';
@@ -13,7 +13,7 @@ import FloatingParametersPanelButtons from 'features/ui/components/FloatingParam
import ParametersPanelTextToImage from 'features/ui/components/ParametersPanels/ParametersPanelTextToImage';
import ModelManagerTab from 'features/ui/components/tabs/ModelManagerTab';
import QueueTab from 'features/ui/components/tabs/QueueTab';
import { WorkflowsTabContent } from 'features/ui/components/tabs/WorkflowsTabContent';
import { WorkflowsMainPanel } from 'features/ui/components/tabs/WorkflowsTabContent';
import { VerticalNavBar } from 'features/ui/components/VerticalNavBar';
import type { UsePanelOptions } from 'features/ui/hooks/usePanel';
import { usePanel } from 'features/ui/hooks/usePanel';
@@ -165,7 +165,6 @@ const RightPanelContent = memo(() => {
if (tab === 'canvas') {
return <CanvasRightPanel />;
}
if (tab === 'upscaling' || tab === 'workflows') {
return <GalleryPanelContent />;
}
@@ -183,9 +182,8 @@ const LeftPanelContent = memo(() => {
if (tab === 'upscaling') {
return <ParametersPanelUpscale />;
}
if (tab === 'workflows') {
return <NodeEditorPanelGroup />;
return <WorkflowsLeftPanel />;
}
return null;
@@ -194,6 +192,7 @@ LeftPanelContent.displayName = 'LeftPanelContent';
const MainPanelContent = memo(() => {
const tab = useAppSelector(selectActiveTab);
if (tab === 'canvas') {
return <CanvasMainPanelContent />;
}
@@ -201,7 +200,7 @@ const MainPanelContent = memo(() => {
return <ImageViewer />;
}
if (tab === 'workflows') {
return <WorkflowsTabContent />;
return <WorkflowsMainPanel />;
}
if (tab === 'models') {
return <ModelManagerTab />;

View File

@@ -5,7 +5,7 @@ import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
import { memo } from 'react';
import { ReactFlowProvider } from 'reactflow';
export const WorkflowsTabContent = memo(() => {
export const WorkflowsMainPanel = memo(() => {
const mode = useAppSelector(selectWorkflowMode);
if (mode === 'edit') {
@@ -19,4 +19,4 @@ export const WorkflowsTabContent = memo(() => {
return <ImageViewer />;
});
WorkflowsTabContent.displayName = 'WorkflowsTabContent';
WorkflowsMainPanel.displayName = 'WorkflowsMainPanel';