feat(ui): rework add node select

- `space` and `/` open floating add node select
- improved filter logic (partial word matches)
This commit is contained in:
psychedelicious 2023-08-19 17:46:16 +10:00
parent a9fdc77edd
commit 4be4fc6731
9 changed files with 284 additions and 381 deletions

View File

@ -9,6 +9,7 @@ import 'reactflow/dist/style.css';
import NodeEditorPanelGroup from './sidePanel/NodeEditorPanelGroup';
import { Flow } from './flow/Flow';
import { AnimatePresence, motion } from 'framer-motion';
import AddNodePopover from './flow/AddNodePopover/AddNodePopover';
const NodeEditor = () => {
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
@ -57,9 +58,10 @@ const NodeEditor = () => {
opacity: 0,
transition: { duration: 0.2 },
}}
style={{ width: '100%', height: '100%' }}
style={{ position: 'relative', width: '100%', height: '100%' }}
>
<Flow />
<AddNodePopover />
</motion.div>
)}
</AnimatePresence>

View File

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

View File

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

View File

@ -5,14 +5,14 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FaExpand,
FaInfo,
// FaInfo,
FaMapMarkerAlt,
FaMinus,
FaPlus,
} from 'react-icons/fa';
import { useReactFlow } from 'reactflow';
import {
shouldShowFieldTypeLegendChanged,
// shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
} from 'features/nodes/store/nodesSlice';
@ -20,9 +20,9 @@ const ViewportControls = () => {
const { t } = useTranslation();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const dispatch = useAppDispatch();
const shouldShowFieldTypeLegend = useAppSelector(
(state) => state.nodes.shouldShowFieldTypeLegend
);
// const shouldShowFieldTypeLegend = useAppSelector(
// (state) => state.nodes.shouldShowFieldTypeLegend
// );
const shouldShowMinimapPanel = useAppSelector(
(state) => state.nodes.shouldShowMinimapPanel
);
@ -39,9 +39,9 @@ const ViewportControls = () => {
fitView();
}, [fitView]);
const handleClickedToggleFieldTypeLegend = useCallback(() => {
dispatch(shouldShowFieldTypeLegendChanged(!shouldShowFieldTypeLegend));
}, [shouldShowFieldTypeLegend, dispatch]);
// const handleClickedToggleFieldTypeLegend = useCallback(() => {
// dispatch(shouldShowFieldTypeLegendChanged(!shouldShowFieldTypeLegend));
// }, [shouldShowFieldTypeLegend, dispatch]);
const handleClickedToggleMiniMapPanel = useCallback(() => {
dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel));
@ -70,7 +70,7 @@ const ViewportControls = () => {
icon={<FaExpand />}
/>
</Tooltip>
<Tooltip
{/* <Tooltip
label={
shouldShowFieldTypeLegend
? t('nodes.hideLegendNodes')
@ -83,7 +83,7 @@ const ViewportControls = () => {
onClick={handleClickedToggleFieldTypeLegend}
icon={<FaInfo />}
/>
</Tooltip>
</Tooltip> */}
<Tooltip
label={
shouldShowMinimapPanel

View File

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

View File

@ -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 AddNodeMenu from './AddNodeMenu';
const TopLeftPanel = () => (
<Panel position="top-left">
<AddNodeMenu />
</Panel>
);
const TopLeftPanel = () => {
const dispatch = useAppDispatch();
const handleOpenAddNodePopover = useCallback(() => {
dispatch(addNodePopoverOpened());
}, [dispatch]);
return (
<Panel position="top-left">
<IAIIconButton
aria-label="Add Node"
tooltip="Add Node"
onClick={handleOpenAddNodePopover}
icon={<FaPlus />}
/>
</Panel>
);
};
export default memo(TopLeftPanel);

View File

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

View File

@ -78,6 +78,7 @@ export const initialNodesState: NodesState = {
shouldAnimateEdges: true,
shouldSnapToGrid: false,
shouldColorEdges: true,
isAddNodePopoverOpen: false,
nodeOpacity: 1,
selectedNodes: [],
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) => {
builder.addCase(receivedOpenAPISchema.pending, (state) => {
@ -812,6 +822,9 @@ export const {
selectionCopied,
selectionPasted,
selectedAll,
addNodePopoverOpened,
addNodePopoverClosed,
addNodePopoverToggled,
} = nodesSlice.actions;
export default nodesSlice.reducer;

View File

@ -33,4 +33,5 @@ export type NodesState = {
mouseOverField: FieldIdentifier | null;
nodesToCopy: Node<NodeData>[];
edgesToCopy: Edge<InvocationEdgeExtra>[];
isAddNodePopoverOpen: boolean;
};