mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): field context menu, add/remove from linear ui
This commit is contained in:
parent
64a6aa0293
commit
84cf8bdc08
@ -18,6 +18,7 @@ const SelectionOverlay = ({ isSelected, isHovered }: Props) => {
|
|||||||
opacity: isSelected ? 1 : 0.7,
|
opacity: isSelected ? 1 : 0.7,
|
||||||
transitionProperty: 'common',
|
transitionProperty: 'common',
|
||||||
transitionDuration: '0.1s',
|
transitionDuration: '0.1s',
|
||||||
|
pointerEvents: 'none',
|
||||||
shadow: isSelected
|
shadow: isSelected
|
||||||
? isHovered
|
? isHovered
|
||||||
? 'hoverSelected.light'
|
? 'hoverSelected.light'
|
||||||
|
@ -79,6 +79,7 @@ const NodeTitle = ({ nodeId, title }: Props) => {
|
|||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
sx={{
|
sx={{
|
||||||
p: 0,
|
p: 0,
|
||||||
|
fontWeight: 'initial',
|
||||||
_focusVisible: {
|
_focusVisible: {
|
||||||
p: 0,
|
p: 0,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
|
@ -1,26 +1,111 @@
|
|||||||
import { MenuItem, MenuList } from '@chakra-ui/react';
|
import { MenuGroup, MenuItem, MenuList } from '@chakra-ui/react';
|
||||||
import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import { stateSelector } from 'app/store/store';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||||
import {
|
import {
|
||||||
InputFieldTemplate,
|
IAIContextMenu,
|
||||||
InputFieldValue,
|
IAIContextMenuProps,
|
||||||
} from 'features/nodes/types/types';
|
} from 'common/components/IAIContextMenu';
|
||||||
import { MouseEvent, useCallback } from 'react';
|
import {
|
||||||
|
useFieldInputKind,
|
||||||
|
useFieldLabel,
|
||||||
|
useFieldTemplateTitle,
|
||||||
|
} from 'features/nodes/hooks/useNodeData';
|
||||||
|
import {
|
||||||
|
workflowExposedFieldAdded,
|
||||||
|
workflowExposedFieldRemoved,
|
||||||
|
} from 'features/nodes/store/nodesSlice';
|
||||||
|
import { MouseEvent, ReactNode, memo, useCallback, useMemo } from 'react';
|
||||||
|
import { FaMinus, FaPlus } from 'react-icons/fa';
|
||||||
import { menuListMotionProps } from 'theme/components/menu';
|
import { menuListMotionProps } from 'theme/components/menu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
field: InputFieldValue;
|
fieldName: string;
|
||||||
fieldTemplate: InputFieldTemplate;
|
kind: 'input' | 'output';
|
||||||
children: ContextMenuProps<HTMLDivElement>['children'];
|
children: IAIContextMenuProps<HTMLDivElement>['children'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const FieldContextMenu = (props: Props) => {
|
const FieldContextMenu = ({ nodeId, fieldName, kind, children }: Props) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const label = useFieldLabel(nodeId, fieldName);
|
||||||
|
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
|
||||||
|
|
||||||
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
const skipEvent = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
stateSelector,
|
||||||
|
({ nodes }) => {
|
||||||
|
const isExposed = Boolean(
|
||||||
|
nodes.workflow.exposedFields.find(
|
||||||
|
(f) => f.nodeId === nodeId && f.fieldName === fieldName
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isExposed };
|
||||||
|
},
|
||||||
|
defaultSelectorOptions
|
||||||
|
),
|
||||||
|
[fieldName, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = useFieldInputKind(nodeId, fieldName);
|
||||||
|
const mayExpose = useMemo(
|
||||||
|
() => ['any', 'direct'].includes(input ?? '__UNKNOWN_INPUT__'),
|
||||||
|
[input]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isExposed } = useAppSelector(selector);
|
||||||
|
|
||||||
|
const handleExposeField = useCallback(() => {
|
||||||
|
dispatch(workflowExposedFieldAdded({ nodeId, fieldName }));
|
||||||
|
}, [dispatch, fieldName, nodeId]);
|
||||||
|
|
||||||
|
const handleUnexposeField = useCallback(() => {
|
||||||
|
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||||
|
}, [dispatch, fieldName, nodeId]);
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const menuItems: ReactNode[] = [];
|
||||||
|
if (mayExpose && !isExposed) {
|
||||||
|
menuItems.push(
|
||||||
|
<MenuItem
|
||||||
|
key={`${nodeId}.${fieldName}.expose-field`}
|
||||||
|
icon={<FaPlus />}
|
||||||
|
onClick={handleExposeField}
|
||||||
|
>
|
||||||
|
Add to Linear View
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mayExpose && isExposed) {
|
||||||
|
menuItems.push(
|
||||||
|
<MenuItem
|
||||||
|
key={`${nodeId}.${fieldName}.unexpose-field`}
|
||||||
|
icon={<FaMinus />}
|
||||||
|
onClick={handleUnexposeField}
|
||||||
|
>
|
||||||
|
Remove from Linear View
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return menuItems;
|
||||||
|
}, [
|
||||||
|
fieldName,
|
||||||
|
handleExposeField,
|
||||||
|
handleUnexposeField,
|
||||||
|
isExposed,
|
||||||
|
mayExpose,
|
||||||
|
nodeId,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu<HTMLDivElement>
|
<IAIContextMenu<HTMLDivElement>
|
||||||
menuProps={{
|
menuProps={{
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
isLazy: true,
|
isLazy: true,
|
||||||
@ -29,19 +114,23 @@ const FieldContextMenu = (props: Props) => {
|
|||||||
bg: 'transparent',
|
bg: 'transparent',
|
||||||
_hover: { bg: 'transparent' },
|
_hover: { bg: 'transparent' },
|
||||||
}}
|
}}
|
||||||
renderMenu={() => (
|
renderMenu={() =>
|
||||||
<MenuList
|
!menuItems.length ? null : (
|
||||||
sx={{ visibility: 'visible !important' }}
|
<MenuList
|
||||||
motionProps={menuListMotionProps}
|
sx={{ visibility: 'visible !important' }}
|
||||||
onContextMenu={skipEvent}
|
motionProps={menuListMotionProps}
|
||||||
>
|
onContextMenu={skipEvent}
|
||||||
<MenuItem>Test</MenuItem>
|
>
|
||||||
</MenuList>
|
<MenuGroup title={label || fieldTemplateTitle || 'Unknown Field'}>
|
||||||
)}
|
{menuItems}
|
||||||
|
</MenuGroup>
|
||||||
|
</MenuList>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{props.children}
|
{children}
|
||||||
</ContextMenu>
|
</IAIContextMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FieldContextMenu;
|
export default memo(FieldContextMenu);
|
||||||
|
@ -3,63 +3,42 @@ import {
|
|||||||
EditableInput,
|
EditableInput,
|
||||||
EditablePreview,
|
EditablePreview,
|
||||||
Flex,
|
Flex,
|
||||||
|
forwardRef,
|
||||||
useEditableControls,
|
useEditableControls,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useAppDispatch } from 'app/store/storeHooks';
|
import { useAppDispatch } from 'app/store/storeHooks';
|
||||||
import IAIDraggable from 'common/components/IAIDraggable';
|
|
||||||
import { NodeFieldDraggableData } from 'features/dnd/types';
|
|
||||||
import {
|
import {
|
||||||
useFieldData,
|
useFieldLabel,
|
||||||
useFieldTemplate,
|
useFieldTemplateTitle,
|
||||||
} from 'features/nodes/hooks/useNodeData';
|
} from 'features/nodes/hooks/useNodeData';
|
||||||
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
|
||||||
import {
|
import { MouseEvent, memo, useCallback, useEffect, useState } from 'react';
|
||||||
MouseEvent,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
isDraggable?: boolean;
|
|
||||||
kind: 'input' | 'output';
|
kind: 'input' | 'output';
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldTitle = (props: Props) => {
|
const FieldTitle = forwardRef((props: Props, ref) => {
|
||||||
const { nodeId, fieldName, isDraggable = false, kind } = props;
|
const { nodeId, fieldName, kind } = props;
|
||||||
const fieldTemplate = useFieldTemplate(nodeId, fieldName, kind);
|
const label = useFieldLabel(nodeId, fieldName);
|
||||||
const field = useFieldData(nodeId, fieldName);
|
const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [localTitle, setLocalTitle] = useState(
|
const [localTitle, setLocalTitle] = useState(
|
||||||
field?.label || fieldTemplate?.title || 'Unknown Field'
|
label || fieldTemplateTitle || 'Unknown Field'
|
||||||
);
|
|
||||||
|
|
||||||
const draggableData: NodeFieldDraggableData | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
field &&
|
|
||||||
fieldTemplate?.fieldKind === 'input' &&
|
|
||||||
fieldTemplate?.input !== 'connection' &&
|
|
||||||
isDraggable
|
|
||||||
? {
|
|
||||||
id: `${nodeId}-${fieldName}`,
|
|
||||||
payloadType: 'NODE_FIELD',
|
|
||||||
payload: { nodeId, field, fieldTemplate },
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
[field, fieldName, fieldTemplate, isDraggable, nodeId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (newTitle: string) => {
|
async (newTitle: string) => {
|
||||||
|
if (newTitle === label || newTitle === fieldTemplateTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalTitle(newTitle || fieldTemplateTitle || 'Unknown Field');
|
||||||
dispatch(fieldLabelChanged({ nodeId, fieldName, label: newTitle }));
|
dispatch(fieldLabelChanged({ nodeId, fieldName, label: newTitle }));
|
||||||
setLocalTitle(newTitle || fieldTemplate?.title || 'Unknown Field');
|
|
||||||
},
|
},
|
||||||
[dispatch, nodeId, fieldName, fieldTemplate?.title]
|
[label, fieldTemplateTitle, dispatch, nodeId, fieldName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback((newTitle: string) => {
|
const handleChange = useCallback((newTitle: string) => {
|
||||||
@ -68,27 +47,33 @@ const FieldTitle = (props: Props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Another component may change the title; sync local title with global state
|
// Another component may change the title; sync local title with global state
|
||||||
setLocalTitle(field?.label || fieldTemplate?.title || 'Unknown Field');
|
setLocalTitle(label || fieldTemplateTitle || 'Unknown Field');
|
||||||
}, [field?.label, fieldTemplate?.title]);
|
}, [label, fieldTemplateTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
|
ref={ref}
|
||||||
className="nopan"
|
className="nopan"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
h: 'full',
|
alignItems: 'center',
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
h: 'full',
|
||||||
|
w: 'full',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
value={localTitle}
|
value={localTitle}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
as={Flex}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
h: 'full',
|
||||||
|
w: 'full',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditablePreview
|
<EditablePreview
|
||||||
@ -101,6 +86,11 @@ const FieldTitle = (props: Props) => {
|
|||||||
<EditableInput
|
<EditableInput
|
||||||
sx={{
|
sx={{
|
||||||
p: 0,
|
p: 0,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'base.900',
|
||||||
|
_dark: {
|
||||||
|
color: 'base.100',
|
||||||
|
},
|
||||||
_focusVisible: {
|
_focusVisible: {
|
||||||
p: 0,
|
p: 0,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
@ -108,27 +98,24 @@ const FieldTitle = (props: Props) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EditableControls draggableData={draggableData} />
|
<EditableControls />
|
||||||
</Editable>
|
</Editable>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default memo(FieldTitle);
|
export default memo(FieldTitle);
|
||||||
|
|
||||||
type EditableControlsProps = {
|
const EditableControls = memo(() => {
|
||||||
draggableData?: NodeFieldDraggableData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EditableControls = memo((props: EditableControlsProps) => {
|
|
||||||
const { isEditing, getEditButtonProps } = useEditableControls();
|
const { isEditing, getEditButtonProps } = useEditableControls();
|
||||||
const handleDoubleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: MouseEvent<HTMLDivElement>) => {
|
(e: MouseEvent<HTMLDivElement>) => {
|
||||||
const { onClick } = getEditButtonProps();
|
const { onClick } = getEditButtonProps();
|
||||||
if (!onClick) {
|
if (!onClick) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClick(e);
|
onClick(e);
|
||||||
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
[getEditButtonProps]
|
[getEditButtonProps]
|
||||||
);
|
);
|
||||||
@ -137,19 +124,9 @@ const EditableControls = memo((props: EditableControlsProps) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.draggableData) {
|
|
||||||
return (
|
|
||||||
<IAIDraggable
|
|
||||||
data={props.draggableData}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
cursor={props.draggableData ? 'grab' : 'text'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
onDoubleClick={handleDoubleClick}
|
onClick={handleClick}
|
||||||
position="absolute"
|
position="absolute"
|
||||||
w="full"
|
w="full"
|
||||||
h="full"
|
h="full"
|
||||||
|
@ -23,7 +23,8 @@ const FieldTooltipContent = ({ nodeId, fieldName, kind }: Props) => {
|
|||||||
const isInputTemplate = isInputFieldTemplate(fieldTemplate);
|
const isInputTemplate = isInputFieldTemplate(fieldTemplate);
|
||||||
const fieldTitle = useMemo(() => {
|
const fieldTitle = useMemo(() => {
|
||||||
if (isInputFieldValue(field)) {
|
if (isInputFieldValue(field)) {
|
||||||
if (field.label && fieldTemplate) {
|
console.log(field, fieldTemplate);
|
||||||
|
if (field.label && fieldTemplate?.title) {
|
||||||
return `${field.label} (${fieldTemplate.title})`;
|
return `${field.label} (${fieldTemplate.title})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,13 +3,16 @@ import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
|
|||||||
import {
|
import {
|
||||||
useDoesInputHaveValue,
|
useDoesInputHaveValue,
|
||||||
useFieldTemplate,
|
useFieldTemplate,
|
||||||
|
useIsMouseOverField,
|
||||||
} from 'features/nodes/hooks/useNodeData';
|
} from 'features/nodes/hooks/useNodeData';
|
||||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||||
import { PropsWithChildren, memo, useMemo } from 'react';
|
import { PropsWithChildren, memo, useMemo } from 'react';
|
||||||
|
import FieldContextMenu from './FieldContextMenu';
|
||||||
import FieldHandle from './FieldHandle';
|
import FieldHandle from './FieldHandle';
|
||||||
import FieldTitle from './FieldTitle';
|
import FieldTitle from './FieldTitle';
|
||||||
import FieldTooltipContent from './FieldTooltipContent';
|
import FieldTooltipContent from './FieldTooltipContent';
|
||||||
import InputFieldRenderer from './InputFieldRenderer';
|
import InputFieldRenderer from './InputFieldRenderer';
|
||||||
|
import SelectionOverlay from 'common/components/SelectionOverlay';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@ -48,7 +51,11 @@ const InputField = ({ nodeId, fieldName }: Props) => {
|
|||||||
|
|
||||||
if (fieldTemplate?.fieldKind !== 'input') {
|
if (fieldTemplate?.fieldKind !== 'input') {
|
||||||
return (
|
return (
|
||||||
<InputFieldWrapper shouldDim={shouldDim}>
|
<InputFieldWrapper
|
||||||
|
nodeId={nodeId}
|
||||||
|
fieldName={fieldName}
|
||||||
|
shouldDim={shouldDim}
|
||||||
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
sx={{ color: 'error.400', textAlign: 'left', fontSize: 'sm' }}
|
sx={{ color: 'error.400', textAlign: 'left', fontSize: 'sm' }}
|
||||||
>
|
>
|
||||||
@ -59,40 +66,48 @@ const InputField = ({ nodeId, fieldName }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputFieldWrapper shouldDim={shouldDim}>
|
<InputFieldWrapper
|
||||||
|
nodeId={nodeId}
|
||||||
|
fieldName={fieldName}
|
||||||
|
shouldDim={shouldDim}
|
||||||
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
as={Flex}
|
as={Flex}
|
||||||
isInvalid={isMissingInput}
|
isInvalid={isMissingInput}
|
||||||
isDisabled={isConnected}
|
isDisabled={isConnected}
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: 'center',
|
alignItems: 'stretch',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
ps: 2,
|
ps: 2,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
h: 'full',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip
|
<FieldContextMenu nodeId={nodeId} fieldName={fieldName} kind="input">
|
||||||
label={
|
{(ref) => (
|
||||||
<FieldTooltipContent
|
<Tooltip
|
||||||
nodeId={nodeId}
|
label={
|
||||||
fieldName={fieldName}
|
<FieldTooltipContent
|
||||||
kind="input"
|
nodeId={nodeId}
|
||||||
/>
|
fieldName={fieldName}
|
||||||
}
|
kind="input"
|
||||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
/>
|
||||||
placement="top"
|
}
|
||||||
shouldWrapChildren
|
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||||
hasArrow
|
placement="top"
|
||||||
>
|
hasArrow
|
||||||
<FormLabel sx={{ mb: 0 }}>
|
>
|
||||||
<FieldTitle
|
<FormLabel sx={{ mb: 0 }}>
|
||||||
nodeId={nodeId}
|
<FieldTitle
|
||||||
fieldName={fieldName}
|
ref={ref}
|
||||||
kind="input"
|
nodeId={nodeId}
|
||||||
isDraggable
|
fieldName={fieldName}
|
||||||
/>
|
kind="input"
|
||||||
</FormLabel>
|
/>
|
||||||
</Tooltip>
|
</FormLabel>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FieldContextMenu>
|
||||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@ -113,27 +128,37 @@ export default InputField;
|
|||||||
|
|
||||||
type InputFieldWrapperProps = PropsWithChildren<{
|
type InputFieldWrapperProps = PropsWithChildren<{
|
||||||
shouldDim: boolean;
|
shouldDim: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
fieldName: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const InputFieldWrapper = memo(
|
const InputFieldWrapper = memo(
|
||||||
({ shouldDim, children }: InputFieldWrapperProps) => (
|
({ shouldDim, nodeId, fieldName, children }: InputFieldWrapperProps) => {
|
||||||
<Flex
|
const { isMouseOverField, handleMouseOver, handleMouseOut } =
|
||||||
className="nopan"
|
useIsMouseOverField(nodeId, fieldName);
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
return (
|
||||||
minH: 8,
|
<Flex
|
||||||
py: 0.5,
|
onMouseOver={handleMouseOver}
|
||||||
alignItems: 'center',
|
onMouseOut={handleMouseOut}
|
||||||
opacity: shouldDim ? 0.5 : 1,
|
className="nopan"
|
||||||
transitionProperty: 'opacity',
|
sx={{
|
||||||
transitionDuration: '0.1s',
|
position: 'relative',
|
||||||
w: 'full',
|
minH: 8,
|
||||||
h: 'full',
|
py: 0.5,
|
||||||
}}
|
alignItems: 'center',
|
||||||
>
|
opacity: shouldDim ? 0.5 : 1,
|
||||||
{children}
|
transitionProperty: 'opacity',
|
||||||
</Flex>
|
transitionDuration: '0.1s',
|
||||||
)
|
w: 'full',
|
||||||
|
h: 'full',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectionOverlay isSelected={false} isHovered={isMouseOverField} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
InputFieldWrapper.displayName = 'InputFieldWrapper';
|
InputFieldWrapper.displayName = 'InputFieldWrapper';
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { Flex, FormControl, FormLabel, Tooltip } from '@chakra-ui/react';
|
import { Flex, FormControl, FormLabel, Icon, Tooltip } from '@chakra-ui/react';
|
||||||
|
import { useAppDispatch } from 'app/store/storeHooks';
|
||||||
|
import IAIIconButton from 'common/components/IAIIconButton';
|
||||||
|
import SelectionOverlay from 'common/components/SelectionOverlay';
|
||||||
|
import { useIsMouseOverField } from 'features/nodes/hooks/useNodeData';
|
||||||
|
import { workflowExposedFieldRemoved } from 'features/nodes/store/nodesSlice';
|
||||||
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
|
||||||
import { memo } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
|
import { FaInfoCircle, FaTrash } from 'react-icons/fa';
|
||||||
import FieldTitle from './FieldTitle';
|
import FieldTitle from './FieldTitle';
|
||||||
import FieldTooltipContent from './FieldTooltipContent';
|
import FieldTooltipContent from './FieldTooltipContent';
|
||||||
import InputFieldRenderer from './InputFieldRenderer';
|
import InputFieldRenderer from './InputFieldRenderer';
|
||||||
@ -11,8 +17,18 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { isMouseOverField, handleMouseOut, handleMouseOver } =
|
||||||
|
useIsMouseOverField(nodeId, fieldName);
|
||||||
|
|
||||||
|
const handleRemoveField = useCallback(() => {
|
||||||
|
dispatch(workflowExposedFieldRemoved({ nodeId, fieldName }));
|
||||||
|
}, [dispatch, fieldName, nodeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
|
onMouseOver={handleMouseOver}
|
||||||
|
onMouseOut={handleMouseOut}
|
||||||
layerStyle="second"
|
layerStyle="second"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@ -22,31 +38,43 @@ const LinearViewField = ({ nodeId, fieldName }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormControl as={Flex} sx={{ flexDir: 'column', gap: 1, flexShrink: 1 }}>
|
<FormControl as={Flex} sx={{ flexDir: 'column', gap: 1, flexShrink: 1 }}>
|
||||||
<Tooltip
|
<FormLabel
|
||||||
label={
|
sx={{
|
||||||
<FieldTooltipContent
|
display: 'flex',
|
||||||
nodeId={nodeId}
|
alignItems: 'center',
|
||||||
fieldName={fieldName}
|
justifyContent: 'space-between',
|
||||||
kind="input"
|
mb: 0,
|
||||||
/>
|
}}
|
||||||
}
|
|
||||||
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
|
||||||
placement="top"
|
|
||||||
shouldWrapChildren
|
|
||||||
hasArrow
|
|
||||||
>
|
>
|
||||||
<FormLabel
|
<FieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
|
||||||
sx={{
|
<Tooltip
|
||||||
display: 'flex',
|
label={
|
||||||
justifyContent: 'space-between',
|
<FieldTooltipContent
|
||||||
mb: 0,
|
nodeId={nodeId}
|
||||||
}}
|
fieldName={fieldName}
|
||||||
|
kind="input"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
openDelay={HANDLE_TOOLTIP_OPEN_DELAY}
|
||||||
|
placement="top"
|
||||||
|
hasArrow
|
||||||
>
|
>
|
||||||
<FieldTitle nodeId={nodeId} fieldName={fieldName} kind="input" />
|
<Flex h="full" alignItems="center">
|
||||||
</FormLabel>
|
<Icon as={FaInfoCircle} />
|
||||||
</Tooltip>
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
<IAIIconButton
|
||||||
|
aria-label="Remove from Linear View"
|
||||||
|
tooltip="Remove from Linear View"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveField}
|
||||||
|
icon={<FaTrash />}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
<InputFieldRenderer nodeId={nodeId} fieldName={fieldName} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<SelectionOverlay isSelected={false} isHovered={isMouseOverField} />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Flex } from '@chakra-ui/react';
|
import { Flex, Text } from '@chakra-ui/react';
|
||||||
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
import { skipToken } from '@reduxjs/toolkit/dist/query';
|
||||||
import { useAppDispatch } from 'app/store/storeHooks';
|
import { useAppDispatch } from 'app/store/storeHooks';
|
||||||
import IAIDndImage from 'common/components/IAIDndImage';
|
import IAIDndImage from 'common/components/IAIDndImage';
|
||||||
@ -81,6 +81,9 @@ const ImageInputFieldComponent = (
|
|||||||
draggableData={draggableData}
|
draggableData={draggableData}
|
||||||
postUploadAction={postUploadAction}
|
postUploadAction={postUploadAction}
|
||||||
useThumbailFallback
|
useThumbailFallback
|
||||||
|
uploadElement={<UploadElement />}
|
||||||
|
dropLabel={<DropLabel />}
|
||||||
|
minSize={8}
|
||||||
>
|
>
|
||||||
<IAIDndImageIcon
|
<IAIDndImageIcon
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
@ -93,3 +96,14 @@ const ImageInputFieldComponent = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default memo(ImageInputFieldComponent);
|
export default memo(ImageInputFieldComponent);
|
||||||
|
|
||||||
|
const UploadElement = () => (
|
||||||
|
<Text fontSize={16} fontWeight={600}>
|
||||||
|
Drop or Upload
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
const DropLabel = () => (
|
||||||
|
<Text fontSize={16} fontWeight={600}>
|
||||||
|
Drop
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
@ -3,9 +3,7 @@ import { createSelector } from '@reduxjs/toolkit';
|
|||||||
import { stateSelector } from 'app/store/store';
|
import { stateSelector } from 'app/store/store';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||||
import IAIDroppable from 'common/components/IAIDroppable';
|
|
||||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||||
import { AddFieldToLinearViewDropData } from 'features/dnd/types';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import LinearViewField from '../../fields/LinearViewField';
|
import LinearViewField from '../../fields/LinearViewField';
|
||||||
import ScrollableContent from '../ScrollableContent';
|
import ScrollableContent from '../ScrollableContent';
|
||||||
@ -20,11 +18,6 @@ const selector = createSelector(
|
|||||||
defaultSelectorOptions
|
defaultSelectorOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
const droppableData: AddFieldToLinearViewDropData = {
|
|
||||||
id: 'add-field-to-linear-view',
|
|
||||||
actionType: 'ADD_FIELD_TO_LINEAR',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LinearTabContent = () => {
|
const LinearTabContent = () => {
|
||||||
const { fields } = useAppSelector(selector);
|
const { fields } = useAppSelector(selector);
|
||||||
|
|
||||||
@ -42,6 +35,7 @@ const LinearTabContent = () => {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
flexDir: 'column',
|
flexDir: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
|
p: 1,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
h: 'full',
|
h: 'full',
|
||||||
w: 'full',
|
w: 'full',
|
||||||
@ -50,7 +44,7 @@ const LinearTabContent = () => {
|
|||||||
{fields.length ? (
|
{fields.length ? (
|
||||||
fields.map(({ nodeId, fieldName }) => (
|
fields.map(({ nodeId, fieldName }) => (
|
||||||
<LinearViewField
|
<LinearViewField
|
||||||
key={`${nodeId}-${fieldName}`}
|
key={`${nodeId}.${fieldName}`}
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
/>
|
/>
|
||||||
@ -63,7 +57,6 @@ const LinearTabContent = () => {
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</ScrollableContent>
|
</ScrollableContent>
|
||||||
<IAIDroppable data={droppableData} dropLabel="Add Field to Linear View" />
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import { stateSelector } from 'app/store/store';
|
import { stateSelector } from 'app/store/store';
|
||||||
import { useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||||
import { map, some } from 'lodash-es';
|
import { map, some } from 'lodash-es';
|
||||||
import { useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { mouseOverFieldChanged } from '../store/nodesSlice';
|
||||||
import { FOOTER_FIELDS, IMAGE_FIELDS } from '../types/constants';
|
import { FOOTER_FIELDS, IMAGE_FIELDS } from '../types/constants';
|
||||||
import { isInvocationNode } from '../types/types';
|
import { isInvocationNode } from '../types/types';
|
||||||
|
|
||||||
@ -51,6 +52,28 @@ export const useNodeData = (nodeId: string) => {
|
|||||||
return nodeData;
|
return nodeData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useFieldLabel = (nodeId: string, fieldName: string) => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
stateSelector,
|
||||||
|
({ nodes }) => {
|
||||||
|
const node = nodes.nodes.find((node) => node.id === nodeId);
|
||||||
|
if (!isInvocationNode(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return node?.data.inputs[fieldName]?.label;
|
||||||
|
},
|
||||||
|
defaultSelectorOptions
|
||||||
|
),
|
||||||
|
[fieldName, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = useAppSelector(selector);
|
||||||
|
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFieldData = (nodeId: string, fieldName: string) => {
|
export const useFieldData = (nodeId: string, fieldName: string) => {
|
||||||
const selector = useMemo(
|
const selector = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -73,6 +96,30 @@ export const useFieldData = (nodeId: string, fieldName: string) => {
|
|||||||
return fieldData;
|
return fieldData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useFieldInputKind = (nodeId: string, fieldName: string) => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
stateSelector,
|
||||||
|
({ nodes }) => {
|
||||||
|
const node = nodes.nodes.find((node) => node.id === nodeId);
|
||||||
|
if (!isInvocationNode(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
|
||||||
|
const fieldTemplate = nodeTemplate?.inputs[fieldName];
|
||||||
|
return fieldTemplate?.input;
|
||||||
|
},
|
||||||
|
defaultSelectorOptions
|
||||||
|
),
|
||||||
|
[fieldName, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldType = useAppSelector(selector);
|
||||||
|
|
||||||
|
return fieldType;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFieldType = (
|
export const useFieldType = (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
@ -236,6 +283,33 @@ export const useNodeTemplateTitle = (nodeId: string) => {
|
|||||||
return title;
|
return title;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useFieldTemplateTitle = (
|
||||||
|
nodeId: string,
|
||||||
|
fieldName: string,
|
||||||
|
kind: 'input' | 'output'
|
||||||
|
) => {
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
stateSelector,
|
||||||
|
({ nodes }) => {
|
||||||
|
const node = nodes.nodes.find((node) => node.id === nodeId);
|
||||||
|
if (!isInvocationNode(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
|
||||||
|
return nodeTemplate?.[KIND_MAP[kind]][fieldName]?.title;
|
||||||
|
},
|
||||||
|
defaultSelectorOptions
|
||||||
|
),
|
||||||
|
[fieldName, kind, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldTemplate = useAppSelector(selector);
|
||||||
|
|
||||||
|
return fieldTemplate;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFieldTemplate = (
|
export const useFieldTemplate = (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
@ -284,3 +358,30 @@ export const useDoesInputHaveValue = (nodeId: string, fieldName: string) => {
|
|||||||
|
|
||||||
return doesFieldHaveValue;
|
return doesFieldHaveValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useIsMouseOverField = (nodeId: string, fieldName: string) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const selector = useMemo(
|
||||||
|
() =>
|
||||||
|
createSelector(
|
||||||
|
stateSelector,
|
||||||
|
({ nodes }) =>
|
||||||
|
nodes.mouseOverField?.nodeId === nodeId &&
|
||||||
|
nodes.mouseOverField?.fieldName === fieldName,
|
||||||
|
defaultSelectorOptions
|
||||||
|
),
|
||||||
|
[fieldName, nodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMouseOverField = useAppSelector(selector);
|
||||||
|
|
||||||
|
const handleMouseOver = useCallback(() => {
|
||||||
|
dispatch(mouseOverFieldChanged({ nodeId, fieldName }));
|
||||||
|
}, [dispatch, fieldName, nodeId]);
|
||||||
|
|
||||||
|
const handleMouseOut = useCallback(() => {
|
||||||
|
dispatch(mouseOverFieldChanged(null));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return { isMouseOverField, handleMouseOver, handleMouseOut };
|
||||||
|
};
|
||||||
|
@ -81,6 +81,7 @@ export const initialNodesState: NodesState = {
|
|||||||
},
|
},
|
||||||
nodeExecutionStates: {},
|
nodeExecutionStates: {},
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
mouseOverField: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
|
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
|
||||||
@ -594,6 +595,12 @@ const nodesSlice = createSlice({
|
|||||||
viewportChanged: (state, action: PayloadAction<Viewport>) => {
|
viewportChanged: (state, action: PayloadAction<Viewport>) => {
|
||||||
state.viewport = action.payload;
|
state.viewport = action.payload;
|
||||||
},
|
},
|
||||||
|
mouseOverFieldChanged: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<FieldIdentifier | null>
|
||||||
|
) => {
|
||||||
|
state.mouseOverField = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(receivedOpenAPISchema.pending, (state) => {
|
builder.addCase(receivedOpenAPISchema.pending, (state) => {
|
||||||
@ -701,6 +708,7 @@ export const {
|
|||||||
workflowExposedFieldRemoved,
|
workflowExposedFieldRemoved,
|
||||||
fieldLabelChanged,
|
fieldLabelChanged,
|
||||||
viewportChanged,
|
viewportChanged,
|
||||||
|
mouseOverFieldChanged,
|
||||||
} = nodesSlice.actions;
|
} = nodesSlice.actions;
|
||||||
|
|
||||||
export default nodesSlice.reducer;
|
export default nodesSlice.reducer;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow';
|
import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow';
|
||||||
import {
|
import {
|
||||||
|
FieldIdentifier,
|
||||||
FieldType,
|
FieldType,
|
||||||
InvocationEdgeExtra,
|
InvocationEdgeExtra,
|
||||||
InvocationTemplate,
|
InvocationTemplate,
|
||||||
@ -29,4 +30,5 @@ export type NodesState = {
|
|||||||
nodeExecutionStates: Record<string, NodeExecutionState>;
|
nodeExecutionStates: Record<string, NodeExecutionState>;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
|
mouseOverField: FieldIdentifier | null;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user