mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
[Nodes UI] More Work (#3248)
- Style the Minimap - Made the Node UI Legend Responsive - Set Min Width for nodes on Spawn so resize doesn't snap. - Initial Implementation of Node Search - Added FuseJS to handle the node filtering
This commit is contained in:
commit
f0e4a2124a
@ -54,6 +54,7 @@
|
||||
"dateformat": "^5.0.3",
|
||||
"formik": "^2.2.9",
|
||||
"framer-motion": "^9.0.4",
|
||||
"fuse.js": "^6.6.2",
|
||||
"i18next": "^22.4.10",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-http-backend": "^2.1.1",
|
||||
|
@ -60,3 +60,5 @@ export const IN_PROGRESS_IMAGE_TYPES: Array<{
|
||||
{ key: 'Fast', value: 'latents' },
|
||||
{ key: 'Accurate', value: 'full-res' },
|
||||
];
|
||||
|
||||
export const NODE_MIN_WIDTH = 250;
|
||||
|
@ -42,8 +42,6 @@ const FieldHandle = (props: FieldHandleProps) => {
|
||||
const { nodeId, field, isValidConnection, handleType, styles } = props;
|
||||
const { name, title, type, description } = field;
|
||||
|
||||
console.log(props);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={type}
|
||||
|
@ -1,20 +1,24 @@
|
||||
import 'reactflow/dist/style.css';
|
||||
import { Tooltip, Badge, HStack } from '@chakra-ui/react';
|
||||
import { Tooltip, Badge, Flex } from '@chakra-ui/react';
|
||||
import { map } from 'lodash';
|
||||
import { FIELDS } from '../types/constants';
|
||||
import { memo } from 'react';
|
||||
|
||||
const FieldTypeLegend = () => {
|
||||
return (
|
||||
<HStack>
|
||||
<Flex gap={2} flexDirection={{ base: 'column', xl: 'row' }}>
|
||||
{map(FIELDS, ({ title, description, color }, key) => (
|
||||
<Tooltip key={key} label={description}>
|
||||
<Badge colorScheme={color} sx={{ userSelect: 'none' }}>
|
||||
<Badge
|
||||
colorScheme={color}
|
||||
sx={{ userSelect: 'none' }}
|
||||
textAlign="center"
|
||||
>
|
||||
{title}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import {
|
||||
Background,
|
||||
MiniMap,
|
||||
OnConnect,
|
||||
OnEdgesChange,
|
||||
OnNodesChange,
|
||||
@ -23,6 +22,8 @@ import TopLeftPanel from './panels/TopLeftPanel';
|
||||
import TopRightPanel from './panels/TopRightPanel';
|
||||
import TopCenterPanel from './panels/TopCenterPanel';
|
||||
import BottomLeftPanel from './panels/BottomLeftPanel.tsx';
|
||||
import MinimapPanel from './panels/MinimapPanel';
|
||||
import NodeSearch from './search/NodeSearch';
|
||||
|
||||
const nodeTypes = { invocation: InvocationComponent };
|
||||
|
||||
@ -59,12 +60,9 @@ export const Flow = () => {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onConnectEnd: OnConnectEnd = useCallback(
|
||||
(event) => {
|
||||
dispatch(connectionEnded());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const onConnectEnd: OnConnectEnd = useCallback(() => {
|
||||
dispatch(connectionEnded());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
@ -80,12 +78,13 @@ export const Flow = () => {
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
>
|
||||
<TopLeftPanel />
|
||||
<NodeSearch />
|
||||
{/* <TopLeftPanel /> */}
|
||||
<TopCenterPanel />
|
||||
<TopRightPanel />
|
||||
<BottomLeftPanel />
|
||||
<Background />
|
||||
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
||||
<MinimapPanel />
|
||||
</ReactFlow>
|
||||
);
|
||||
};
|
||||
|
@ -119,7 +119,9 @@ const IAINodeInputs = (props: IAINodeInputsProps) => {
|
||||
);
|
||||
|
||||
if (index < inputSockets.length) {
|
||||
IAINodeInputsToRender.push(<Divider />);
|
||||
IAINodeInputsToRender.push(
|
||||
<Divider key={`${inputSocket.id}.divider`} />
|
||||
);
|
||||
}
|
||||
|
||||
IAINodeInputsToRender.push(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { NODE_MIN_WIDTH } from 'app/constants';
|
||||
import { memo } from 'react';
|
||||
import { NodeResizeControl, NodeResizerProps } from 'reactflow';
|
||||
|
||||
@ -14,7 +15,7 @@ const IAINodeResizer = (props: NodeResizerProps) => {
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
minWidth={350}
|
||||
minWidth={NODE_MIN_WIDTH}
|
||||
{...rest}
|
||||
></NodeResizeControl>
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import { RootState } from 'app/store';
|
||||
import { AnyInvocationType } from 'services/events/types';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { useAppSelector } from 'app/storeHooks';
|
||||
import { NODE_MIN_WIDTH } from 'app/constants';
|
||||
|
||||
type InvocationComponentWrapperProps = PropsWithChildren & {
|
||||
selected: boolean;
|
||||
@ -28,6 +29,7 @@ const InvocationComponentWrapper = (props: InvocationComponentWrapperProps) => {
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 'md',
|
||||
minWidth: NODE_MIN_WIDTH,
|
||||
boxShadow: props.selected
|
||||
? `${nodeSelectedOutline}, ${nodeShadow}`
|
||||
: `${nodeShadow}`,
|
||||
|
@ -0,0 +1,34 @@
|
||||
import { RootState } from 'app/store';
|
||||
import { useAppSelector } from 'app/storeHooks';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { MiniMap } from 'reactflow';
|
||||
|
||||
const MinimapStyle: CSSProperties = {
|
||||
background: 'var(--invokeai-colors-base-500)',
|
||||
};
|
||||
|
||||
const MinimapPanel = () => {
|
||||
const currentTheme = useAppSelector(
|
||||
(state: RootState) => state.ui.currentTheme
|
||||
);
|
||||
|
||||
return (
|
||||
<MiniMap
|
||||
nodeStrokeWidth={3}
|
||||
pannable
|
||||
zoomable
|
||||
nodeBorderRadius={30}
|
||||
style={MinimapStyle}
|
||||
nodeColor={
|
||||
currentTheme === 'light'
|
||||
? 'var(--invokeai-colors-accent-700)'
|
||||
: currentTheme === 'green'
|
||||
? 'var(--invokeai-colors-accent-600)'
|
||||
: 'var(--invokeai-colors-accent-700)'
|
||||
}
|
||||
maskColor="var(--invokeai-colors-base-700)"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MinimapPanel);
|
@ -0,0 +1,211 @@
|
||||
import { Box, Flex } from '@chakra-ui/layout';
|
||||
import { RootState } from 'app/store';
|
||||
import { useAppDispatch, useAppSelector } from 'app/storeHooks';
|
||||
import IAIInput from 'common/components/IAIInput';
|
||||
import { Panel } from 'reactflow';
|
||||
import { map } from 'lodash';
|
||||
import {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
KeyboardEvent,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Tooltip } from '@chakra-ui/tooltip';
|
||||
import { AnyInvocationType } from 'services/events/types';
|
||||
import { useBuildInvocation } from 'features/nodes/hooks/useBuildInvocation';
|
||||
import { makeToast } from 'features/system/hooks/useToastWatcher';
|
||||
import { addToast } from 'features/system/store/systemSlice';
|
||||
import { nodeAdded } from '../../store/nodesSlice';
|
||||
import Fuse from 'fuse.js';
|
||||
import { InvocationTemplate } from 'features/nodes/types/types';
|
||||
|
||||
interface NodeListItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
type: AnyInvocationType;
|
||||
isSelected: boolean;
|
||||
addNode: (nodeType: AnyInvocationType) => void;
|
||||
}
|
||||
|
||||
const NodeListItem = (props: NodeListItemProps) => {
|
||||
const { title, description, type, isSelected, addNode } = props;
|
||||
return (
|
||||
<Tooltip label={description} placement="end" hasArrow>
|
||||
<Box
|
||||
px={4}
|
||||
onClick={() => addNode(type)}
|
||||
background={isSelected ? 'base.600' : 'none'}
|
||||
_hover={{
|
||||
background: 'base.600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
NodeListItem.displayName = 'NodeListItem';
|
||||
|
||||
const NodeSearch = () => {
|
||||
const invocationTemplates = useAppSelector(
|
||||
(state: RootState) => state.nodes.invocationTemplates
|
||||
);
|
||||
|
||||
const nodes = map(invocationTemplates);
|
||||
const [filteredNodes, setFilteredNodes] = useState<
|
||||
Fuse.FuseResult<InvocationTemplate>[]
|
||||
>([]);
|
||||
|
||||
const buildInvocation = useBuildInvocation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [showNodeList, setShowNodeList] = useState<boolean>(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
||||
const nodeSearchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fuseOptions = {
|
||||
findAllMatches: true,
|
||||
threshold: 0,
|
||||
ignoreLocation: true,
|
||||
keys: ['title', 'type', 'tags'],
|
||||
};
|
||||
|
||||
const fuse = new Fuse(nodes, fuseOptions);
|
||||
|
||||
const findNode = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
setFilteredNodes(fuse.search(e.target.value));
|
||||
setShowNodeList(true);
|
||||
};
|
||||
|
||||
const addNode = useCallback(
|
||||
(nodeType: AnyInvocationType) => {
|
||||
const invocation = buildInvocation(nodeType);
|
||||
|
||||
if (!invocation) {
|
||||
const toast = makeToast({
|
||||
status: 'error',
|
||||
title: `Unknown Invocation type ${nodeType}`,
|
||||
});
|
||||
dispatch(addToast(toast));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(nodeAdded(invocation));
|
||||
},
|
||||
[dispatch, buildInvocation]
|
||||
);
|
||||
|
||||
const renderNodeList = () => {
|
||||
const nodeListToRender: ReactNode[] = [];
|
||||
|
||||
if (searchText.length > 0) {
|
||||
filteredNodes.forEach(({ item }, index) => {
|
||||
const { title, description, type } = item;
|
||||
if (title.toLowerCase().includes(searchText)) {
|
||||
nodeListToRender.push(
|
||||
<NodeListItem
|
||||
key={index}
|
||||
title={title}
|
||||
description={description}
|
||||
type={type}
|
||||
isSelected={focusedIndex === index}
|
||||
addNode={addNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
nodes.forEach(({ title, description, type }, index) => {
|
||||
nodeListToRender.push(
|
||||
<NodeListItem
|
||||
key={index}
|
||||
title={title}
|
||||
description={description}
|
||||
type={type}
|
||||
isSelected={focusedIndex === index}
|
||||
addNode={addNode}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" background="base.900" borderRadius={6}>
|
||||
{nodeListToRender}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const searchKeyHandler = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const { key } = e;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
setShowNodeList(true);
|
||||
if (searchText.length > 0) {
|
||||
nextIndex = (focusedIndex + 1) % filteredNodes.length;
|
||||
} else {
|
||||
nextIndex = (focusedIndex + 1) % nodes.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setShowNodeList(true);
|
||||
if (searchText.length > 0) {
|
||||
nextIndex =
|
||||
(focusedIndex + filteredNodes.length - 1) % filteredNodes.length;
|
||||
} else {
|
||||
nextIndex = (focusedIndex + nodes.length - 1) % nodes.length;
|
||||
}
|
||||
}
|
||||
|
||||
// # TODO Handle Blur
|
||||
// if (key === 'Escape') {
|
||||
// }
|
||||
|
||||
if (key === 'Enter') {
|
||||
let selectedNodeType: AnyInvocationType;
|
||||
|
||||
if (searchText.length > 0) {
|
||||
selectedNodeType = filteredNodes[focusedIndex].item.type;
|
||||
} else {
|
||||
selectedNodeType = nodes[focusedIndex].type;
|
||||
}
|
||||
|
||||
addNode(selectedNodeType);
|
||||
setShowNodeList(false);
|
||||
}
|
||||
|
||||
setFocusedIndex(nextIndex);
|
||||
};
|
||||
|
||||
const searchInputBlurHandler = (e: FocusEvent<HTMLDivElement>) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setShowNodeList(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
tabIndex={1}
|
||||
onKeyDown={searchKeyHandler}
|
||||
onFocus={() => setShowNodeList(true)}
|
||||
onBlur={searchInputBlurHandler}
|
||||
ref={nodeSearchRef}
|
||||
>
|
||||
<IAIInput value={searchText} onChange={findNode} />
|
||||
{showNodeList && renderNodeList()}
|
||||
</Flex>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NodeSearch);
|
@ -3454,6 +3454,11 @@ functions-have-names@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||
|
||||
fuse.js@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
||||
|
||||
get-amd-module-type@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-3.0.2.tgz#46550cee2b8e1fa4c3f2c8a5753c36990aa49ab0"
|
||||
|
Loading…
Reference in New Issue
Block a user