mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): rework add node select
- `space` and `/` open floating add node select - improved filter logic (partial word matches)
This commit is contained in:
parent
a9fdc77edd
commit
4be4fc6731
@ -9,6 +9,7 @@ import 'reactflow/dist/style.css';
|
|||||||
import NodeEditorPanelGroup from './sidePanel/NodeEditorPanelGroup';
|
import NodeEditorPanelGroup from './sidePanel/NodeEditorPanelGroup';
|
||||||
import { Flow } from './flow/Flow';
|
import { Flow } from './flow/Flow';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
|
||||||
|
|
||||||
const NodeEditor = () => {
|
const NodeEditor = () => {
|
||||||
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
|
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
|
||||||
@ -57,9 +58,10 @@ const NodeEditor = () => {
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: { duration: 0.2 },
|
transition: { duration: 0.2 },
|
||||||
}}
|
}}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ position: 'relative', width: '100%', height: '100%' }}
|
||||||
>
|
>
|
||||||
<Flow />
|
<Flow />
|
||||||
|
<AddNodePopover />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverContent,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import { useAppToaster } from 'app/components/Toaster';
|
||||||
|
import { stateSelector } from 'app/store/store';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
||||||
|
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
|
||||||
|
import { useBuildNodeData } from 'features/nodes/hooks/useBuildNodeData';
|
||||||
|
import {
|
||||||
|
addNodePopoverClosed,
|
||||||
|
addNodePopoverOpened,
|
||||||
|
nodeAdded,
|
||||||
|
} from 'features/nodes/store/nodesSlice';
|
||||||
|
import { map } from 'lodash-es';
|
||||||
|
import { memo, useCallback, useRef } from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { HotkeyCallback } from 'react-hotkeys-hook/dist/types';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
import { AnyInvocationType } from 'services/events/types';
|
||||||
|
import { AddNodePopoverSelectItem } from './AddNodePopoverSelectItem';
|
||||||
|
|
||||||
|
type NodeTemplate = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter = (value: string, item: NodeTemplate) => {
|
||||||
|
const regex = new RegExp(
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '')
|
||||||
|
.split(' ')
|
||||||
|
.join('.*'),
|
||||||
|
'gi'
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
regex.test(item.label) ||
|
||||||
|
regex.test(item.description) ||
|
||||||
|
item.tags.some((tag) => regex.test(tag))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selector = createSelector(
|
||||||
|
[stateSelector],
|
||||||
|
({ nodes }) => {
|
||||||
|
const data: NodeTemplate[] = map(nodes.nodeTemplates, (template) => {
|
||||||
|
return {
|
||||||
|
label: template.title,
|
||||||
|
value: template.type,
|
||||||
|
description: template.description,
|
||||||
|
tags: template.tags,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
label: 'Progress Image',
|
||||||
|
value: 'current_image',
|
||||||
|
description: 'Displays the current image in the Node Editor',
|
||||||
|
tags: ['progress'],
|
||||||
|
});
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
label: 'Notes',
|
||||||
|
value: 'notes',
|
||||||
|
description: 'Add notes about your workflow',
|
||||||
|
tags: ['notes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
data.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
|
||||||
|
return { data };
|
||||||
|
},
|
||||||
|
defaultSelectorOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const AddNodePopover = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const buildInvocation = useBuildNodeData();
|
||||||
|
const toaster = useAppToaster();
|
||||||
|
const { data } = useAppSelector(selector);
|
||||||
|
const isOpen = useAppSelector((state) => state.nodes.isAddNodePopoverOpen);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addNode = useCallback(
|
||||||
|
(nodeType: AnyInvocationType) => {
|
||||||
|
const invocation = buildInvocation(nodeType);
|
||||||
|
|
||||||
|
if (!invocation) {
|
||||||
|
toaster({
|
||||||
|
status: 'error',
|
||||||
|
title: `Unknown Invocation type ${nodeType}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(nodeAdded(invocation));
|
||||||
|
},
|
||||||
|
[dispatch, buildInvocation, toaster]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(v: string | null) => {
|
||||||
|
if (!v) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addNode(v as AnyInvocationType);
|
||||||
|
},
|
||||||
|
[addNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
dispatch(addNodePopoverClosed());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const onOpen = useCallback(() => {
|
||||||
|
dispatch(addNodePopoverOpened());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleHotkeyOpen: HotkeyCallback = useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[onOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHotkeyClose: HotkeyCallback = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useHotkeys(['space', '/'], handleHotkeyOpen);
|
||||||
|
useHotkeys(['escape'], handleHotkeyClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
initialFocusRef={inputRef}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
placement="bottom"
|
||||||
|
openDelay={0}
|
||||||
|
closeDelay={0}
|
||||||
|
closeOnBlur={true}
|
||||||
|
returnFocusOnClose={true}
|
||||||
|
>
|
||||||
|
<PopoverAnchor>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '15%',
|
||||||
|
insetInlineStart: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
top: -1,
|
||||||
|
shadow: 'dark-lg',
|
||||||
|
borderColor: 'accent.300',
|
||||||
|
borderWidth: '2px',
|
||||||
|
borderStyle: 'solid',
|
||||||
|
_dark: { borderColor: 'accent.400' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverBody sx={{ p: 0 }}>
|
||||||
|
<IAIMantineSearchableSelect
|
||||||
|
inputRef={inputRef}
|
||||||
|
selectOnBlur={false}
|
||||||
|
placeholder="Search for nodes"
|
||||||
|
value={null}
|
||||||
|
data={data}
|
||||||
|
maxDropdownHeight={400}
|
||||||
|
nothingFound="No matching nodes"
|
||||||
|
itemComponent={AddNodePopoverSelectItem}
|
||||||
|
filter={filter}
|
||||||
|
onChange={handleChange}
|
||||||
|
hoverOnSearchChange={true}
|
||||||
|
onDropdownClose={onClose}
|
||||||
|
sx={{
|
||||||
|
width: '32rem',
|
||||||
|
input: {
|
||||||
|
padding: '0.5rem',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(AddNodePopover);
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Text } from '@chakra-ui/react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
|
||||||
|
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddNodePopoverSelectItem = forwardRef<HTMLDivElement, ItemProps>(
|
||||||
|
({ label, description, ...others }: ItemProps, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} {...others}>
|
||||||
|
<div>
|
||||||
|
<Text fontWeight={600}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
sx={{ color: 'base.600', _dark: { color: 'base.500' } }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AddNodePopoverSelectItem.displayName = 'AddNodePopoverSelectItem';
|
@ -5,14 +5,14 @@ import { memo, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
FaExpand,
|
FaExpand,
|
||||||
FaInfo,
|
// FaInfo,
|
||||||
FaMapMarkerAlt,
|
FaMapMarkerAlt,
|
||||||
FaMinus,
|
FaMinus,
|
||||||
FaPlus,
|
FaPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { useReactFlow } from 'reactflow';
|
import { useReactFlow } from 'reactflow';
|
||||||
import {
|
import {
|
||||||
shouldShowFieldTypeLegendChanged,
|
// shouldShowFieldTypeLegendChanged,
|
||||||
shouldShowMinimapPanelChanged,
|
shouldShowMinimapPanelChanged,
|
||||||
} from 'features/nodes/store/nodesSlice';
|
} from 'features/nodes/store/nodesSlice';
|
||||||
|
|
||||||
@ -20,9 +20,9 @@ const ViewportControls = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const shouldShowFieldTypeLegend = useAppSelector(
|
// const shouldShowFieldTypeLegend = useAppSelector(
|
||||||
(state) => state.nodes.shouldShowFieldTypeLegend
|
// (state) => state.nodes.shouldShowFieldTypeLegend
|
||||||
);
|
// );
|
||||||
const shouldShowMinimapPanel = useAppSelector(
|
const shouldShowMinimapPanel = useAppSelector(
|
||||||
(state) => state.nodes.shouldShowMinimapPanel
|
(state) => state.nodes.shouldShowMinimapPanel
|
||||||
);
|
);
|
||||||
@ -39,9 +39,9 @@ const ViewportControls = () => {
|
|||||||
fitView();
|
fitView();
|
||||||
}, [fitView]);
|
}, [fitView]);
|
||||||
|
|
||||||
const handleClickedToggleFieldTypeLegend = useCallback(() => {
|
// const handleClickedToggleFieldTypeLegend = useCallback(() => {
|
||||||
dispatch(shouldShowFieldTypeLegendChanged(!shouldShowFieldTypeLegend));
|
// dispatch(shouldShowFieldTypeLegendChanged(!shouldShowFieldTypeLegend));
|
||||||
}, [shouldShowFieldTypeLegend, dispatch]);
|
// }, [shouldShowFieldTypeLegend, dispatch]);
|
||||||
|
|
||||||
const handleClickedToggleMiniMapPanel = useCallback(() => {
|
const handleClickedToggleMiniMapPanel = useCallback(() => {
|
||||||
dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel));
|
dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel));
|
||||||
@ -70,7 +70,7 @@ const ViewportControls = () => {
|
|||||||
icon={<FaExpand />}
|
icon={<FaExpand />}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
{/* <Tooltip
|
||||||
label={
|
label={
|
||||||
shouldShowFieldTypeLegend
|
shouldShowFieldTypeLegend
|
||||||
? t('nodes.hideLegendNodes')
|
? t('nodes.hideLegendNodes')
|
||||||
@ -83,7 +83,7 @@ const ViewportControls = () => {
|
|||||||
onClick={handleClickedToggleFieldTypeLegend}
|
onClick={handleClickedToggleFieldTypeLegend}
|
||||||
icon={<FaInfo />}
|
icon={<FaInfo />}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip> */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
shouldShowMinimapPanel
|
shouldShowMinimapPanel
|
||||||
|
@ -1,155 +0,0 @@
|
|||||||
import { Flex, Text } from '@chakra-ui/react';
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
|
||||||
import { useAppToaster } from 'app/components/Toaster';
|
|
||||||
import { stateSelector } from 'app/store/store';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
|
||||||
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
|
|
||||||
import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect';
|
|
||||||
import { useBuildNodeData } from 'features/nodes/hooks/useBuildNodeData';
|
|
||||||
import { nodeAdded } from 'features/nodes/store/nodesSlice';
|
|
||||||
import { map } from 'lodash-es';
|
|
||||||
import { forwardRef, useCallback } from 'react';
|
|
||||||
import 'reactflow/dist/style.css';
|
|
||||||
import { AnyInvocationType } from 'services/events/types';
|
|
||||||
|
|
||||||
type NodeTemplate = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
};
|
|
||||||
const filter = (value: string, item: NodeTemplate) => {
|
|
||||||
const regex = new RegExp(
|
|
||||||
value
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
// strip out regex special characters to avoid errors
|
|
||||||
.replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '')
|
|
||||||
.split(' ')
|
|
||||||
.join('.*'),
|
|
||||||
'g'
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
regex.test(item.label.toLowerCase()) ||
|
|
||||||
regex.test(item.description.toLowerCase()) ||
|
|
||||||
item.tags.some((tag) => regex.test(tag))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selector = createSelector(
|
|
||||||
[stateSelector],
|
|
||||||
({ nodes }) => {
|
|
||||||
const data: NodeTemplate[] = map(nodes.nodeTemplates, (template) => {
|
|
||||||
return {
|
|
||||||
label: template.title,
|
|
||||||
value: template.type,
|
|
||||||
description: template.description,
|
|
||||||
tags: template.tags,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
data.push({
|
|
||||||
label: 'Progress Image',
|
|
||||||
value: 'current_image',
|
|
||||||
description: 'Displays the current image in the Node Editor',
|
|
||||||
tags: ['progress'],
|
|
||||||
});
|
|
||||||
|
|
||||||
data.push({
|
|
||||||
label: 'Notes',
|
|
||||||
value: 'notes',
|
|
||||||
description: 'Add notes about your workflow',
|
|
||||||
tags: ['notes'],
|
|
||||||
});
|
|
||||||
|
|
||||||
data.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
|
|
||||||
return { data };
|
|
||||||
},
|
|
||||||
defaultSelectorOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
const AddNodeMenu = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { data } = useAppSelector(selector);
|
|
||||||
|
|
||||||
const buildInvocation = useBuildNodeData();
|
|
||||||
|
|
||||||
const toaster = useAppToaster();
|
|
||||||
|
|
||||||
const addNode = useCallback(
|
|
||||||
(nodeType: AnyInvocationType) => {
|
|
||||||
const invocation = buildInvocation(nodeType);
|
|
||||||
|
|
||||||
if (!invocation) {
|
|
||||||
toaster({
|
|
||||||
status: 'error',
|
|
||||||
title: `Unknown Invocation type ${nodeType}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(nodeAdded(invocation));
|
|
||||||
},
|
|
||||||
[dispatch, buildInvocation, toaster]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!v) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addNode(v as AnyInvocationType);
|
|
||||||
},
|
|
||||||
[addNode]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex sx={{ gap: 2, alignItems: 'center' }}>
|
|
||||||
<IAIMantineSearchableSelect
|
|
||||||
selectOnBlur={false}
|
|
||||||
placeholder="Add Node"
|
|
||||||
value={null}
|
|
||||||
data={data}
|
|
||||||
maxDropdownHeight={400}
|
|
||||||
nothingFound="No matching nodes"
|
|
||||||
itemComponent={SelectItem}
|
|
||||||
filter={filter}
|
|
||||||
onChange={handleChange}
|
|
||||||
hoverOnSearchChange={true}
|
|
||||||
sx={{
|
|
||||||
width: '24rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectItem = forwardRef<HTMLDivElement, ItemProps>(
|
|
||||||
({ label, description, ...others }: ItemProps, ref) => {
|
|
||||||
return (
|
|
||||||
<div ref={ref} {...others}>
|
|
||||||
<div>
|
|
||||||
<Text fontWeight={600}>{label}</Text>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
sx={{ color: 'base.600', _dark: { color: 'base.500' } }}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
SelectItem.displayName = 'SelectItem';
|
|
||||||
|
|
||||||
export default AddNodeMenu;
|
|
@ -1,11 +1,27 @@
|
|||||||
import { memo } from 'react';
|
import { useAppDispatch } from 'app/store/storeHooks';
|
||||||
|
import IAIIconButton from 'common/components/IAIIconButton';
|
||||||
|
import { addNodePopoverOpened } from 'features/nodes/store/nodesSlice';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { FaPlus } from 'react-icons/fa';
|
||||||
import { Panel } from 'reactflow';
|
import { Panel } from 'reactflow';
|
||||||
import AddNodeMenu from './AddNodeMenu';
|
|
||||||
|
|
||||||
const TopLeftPanel = () => (
|
const TopLeftPanel = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleOpenAddNodePopover = useCallback(() => {
|
||||||
|
dispatch(addNodePopoverOpened());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
<Panel position="top-left">
|
<Panel position="top-left">
|
||||||
<AddNodeMenu />
|
<IAIIconButton
|
||||||
|
aria-label="Add Node"
|
||||||
|
tooltip="Add Node"
|
||||||
|
onClick={handleOpenAddNodePopover}
|
||||||
|
icon={<FaPlus />}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default memo(TopLeftPanel);
|
export default memo(TopLeftPanel);
|
||||||
|
@ -1,208 +0,0 @@
|
|||||||
import { Box, Flex } from '@chakra-ui/layout';
|
|
||||||
import { Tooltip } from '@chakra-ui/tooltip';
|
|
||||||
import { useAppToaster } from 'app/components/Toaster';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
|
||||||
import IAIInput from 'common/components/IAIInput';
|
|
||||||
import { useBuildNodeData } from 'features/nodes/hooks/useBuildNodeData';
|
|
||||||
import { InvocationTemplate } from 'features/nodes/types/types';
|
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
import { map } from 'lodash-es';
|
|
||||||
import {
|
|
||||||
ChangeEvent,
|
|
||||||
FocusEvent,
|
|
||||||
KeyboardEvent,
|
|
||||||
ReactNode,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { AnyInvocationType } from 'services/events/types';
|
|
||||||
import { nodeAdded } from '../../store/nodesSlice';
|
|
||||||
|
|
||||||
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 nodeTemplates = useAppSelector((state) =>
|
|
||||||
map(state.nodes.nodeTemplates)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filteredNodes, setFilteredNodes] = useState<
|
|
||||||
Fuse.FuseResult<InvocationTemplate>[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const buildInvocation = useBuildNodeData();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const toaster = useAppToaster();
|
|
||||||
|
|
||||||
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(nodeTemplates, 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) {
|
|
||||||
toaster({
|
|
||||||
status: 'error',
|
|
||||||
title: `Unknown Invocation type ${nodeType}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(nodeAdded(invocation));
|
|
||||||
},
|
|
||||||
[dispatch, buildInvocation, toaster]
|
|
||||||
);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
nodeTemplates.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) % nodeTemplates.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'ArrowUp') {
|
|
||||||
setShowNodeList(true);
|
|
||||||
if (searchText.length > 0) {
|
|
||||||
nextIndex =
|
|
||||||
(focusedIndex + filteredNodes.length - 1) % filteredNodes.length;
|
|
||||||
} else {
|
|
||||||
nextIndex =
|
|
||||||
(focusedIndex + nodeTemplates.length - 1) % nodeTemplates.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// # TODO Handle Blur
|
|
||||||
// if (key === 'Escape') {
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (key === 'Enter') {
|
|
||||||
let selectedNodeType: AnyInvocationType | undefined;
|
|
||||||
|
|
||||||
if (searchText.length > 0) {
|
|
||||||
selectedNodeType = filteredNodes[focusedIndex]?.item.type;
|
|
||||||
} else {
|
|
||||||
selectedNodeType = nodeTemplates[focusedIndex]?.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedNodeType) {
|
|
||||||
addNode(selectedNodeType);
|
|
||||||
}
|
|
||||||
setShowNodeList(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusedIndex(nextIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchInputBlurHandler = (e: FocusEvent<HTMLDivElement>) => {
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setShowNodeList(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex
|
|
||||||
flexDirection="column"
|
|
||||||
tabIndex={1}
|
|
||||||
onKeyDown={searchKeyHandler}
|
|
||||||
onFocus={() => setShowNodeList(true)}
|
|
||||||
onBlur={searchInputBlurHandler}
|
|
||||||
ref={nodeSearchRef}
|
|
||||||
>
|
|
||||||
<IAIInput value={searchText} onChange={findNode} />
|
|
||||||
{showNodeList && renderNodeList()}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(NodeSearch);
|
|
@ -78,6 +78,7 @@ export const initialNodesState: NodesState = {
|
|||||||
shouldAnimateEdges: true,
|
shouldAnimateEdges: true,
|
||||||
shouldSnapToGrid: false,
|
shouldSnapToGrid: false,
|
||||||
shouldColorEdges: true,
|
shouldColorEdges: true,
|
||||||
|
isAddNodePopoverOpen: false,
|
||||||
nodeOpacity: 1,
|
nodeOpacity: 1,
|
||||||
selectedNodes: [],
|
selectedNodes: [],
|
||||||
selectedEdges: [],
|
selectedEdges: [],
|
||||||
@ -699,6 +700,15 @@ const nodesSlice = createSlice({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
addNodePopoverOpened: (state) => {
|
||||||
|
state.isAddNodePopoverOpen = true;
|
||||||
|
},
|
||||||
|
addNodePopoverClosed: (state) => {
|
||||||
|
state.isAddNodePopoverOpen = false;
|
||||||
|
},
|
||||||
|
addNodePopoverToggled: (state) => {
|
||||||
|
state.isAddNodePopoverOpen = !state.isAddNodePopoverOpen;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(receivedOpenAPISchema.pending, (state) => {
|
builder.addCase(receivedOpenAPISchema.pending, (state) => {
|
||||||
@ -812,6 +822,9 @@ export const {
|
|||||||
selectionCopied,
|
selectionCopied,
|
||||||
selectionPasted,
|
selectionPasted,
|
||||||
selectedAll,
|
selectedAll,
|
||||||
|
addNodePopoverOpened,
|
||||||
|
addNodePopoverClosed,
|
||||||
|
addNodePopoverToggled,
|
||||||
} = nodesSlice.actions;
|
} = nodesSlice.actions;
|
||||||
|
|
||||||
export default nodesSlice.reducer;
|
export default nodesSlice.reducer;
|
||||||
|
@ -33,4 +33,5 @@ export type NodesState = {
|
|||||||
mouseOverField: FieldIdentifier | null;
|
mouseOverField: FieldIdentifier | null;
|
||||||
nodesToCopy: Node<NodeData>[];
|
nodesToCopy: Node<NodeData>[];
|
||||||
edgesToCopy: Edge<InvocationEdgeExtra>[];
|
edgesToCopy: Edge<InvocationEdgeExtra>[];
|
||||||
|
isAddNodePopoverOpen: boolean;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user