diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index ffda25c2a6..87d8e4f127 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -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%' }} > + )} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx new file mode 100644 index 0000000000..05aa60c1ba --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx @@ -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(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 ( + + + + + + + + + + + ); +}; + +export default memo(AddNodePopover); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopoverSelectItem.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopoverSelectItem.tsx new file mode 100644 index 0000000000..95b033f95c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopoverSelectItem.tsx @@ -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( + ({ label, description, ...others }: ItemProps, ref) => { + return ( +
+
+ {label} + + {description} + +
+
+ ); + } +); + +AddNodePopoverSelectItem.displayName = 'AddNodePopoverSelectItem'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 8e4f2487be..260655723e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -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={} /> - { onClick={handleClickedToggleFieldTypeLegend} icon={} /> - + */} { - 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 ( - - - - ); -}; - -interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { - value: string; - label: string; - description: string; -} - -const SelectItem = forwardRef( - ({ label, description, ...others }: ItemProps, ref) => { - return ( -
-
- {label} - - {description} - -
-
- ); - } -); - -SelectItem.displayName = 'SelectItem'; - -export default AddNodeMenu; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx index e53a8a391c..5f00604afa 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopLeftPanel/TopLeftPanel.tsx @@ -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 = () => ( - - - -); +const TopLeftPanel = () => { + const dispatch = useAppDispatch(); + + const handleOpenAddNodePopover = useCallback(() => { + dispatch(addNodePopoverOpened()); + }, [dispatch]); + + return ( + + } + /> + + ); +}; export default memo(TopLeftPanel); diff --git a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx deleted file mode 100644 index 1b9dc38cb6..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/search/NodeSearch.tsx +++ /dev/null @@ -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 ( - - addNode(type)} - background={isSelected ? 'base.600' : 'none'} - _hover={{ - background: 'base.600', - cursor: 'pointer', - }} - > - {title} - - - ); -}; - -NodeListItem.displayName = 'NodeListItem'; - -const NodeSearch = () => { - const nodeTemplates = useAppSelector((state) => - map(state.nodes.nodeTemplates) - ); - - const [filteredNodes, setFilteredNodes] = useState< - Fuse.FuseResult[] - >([]); - - const buildInvocation = useBuildNodeData(); - const dispatch = useAppDispatch(); - const toaster = useAppToaster(); - - const [searchText, setSearchText] = useState(''); - const [showNodeList, setShowNodeList] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(-1); - const nodeSearchRef = useRef(null); - - const fuseOptions = { - findAllMatches: true, - threshold: 0, - ignoreLocation: true, - keys: ['title', 'type', 'tags'], - }; - - const fuse = new Fuse(nodeTemplates, fuseOptions); - - const findNode = (e: ChangeEvent) => { - 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( - - ); - } - }); - } else { - nodeTemplates.forEach(({ title, description, type }, index) => { - nodeListToRender.push( - - ); - }); - } - - return ( - - {nodeListToRender} - - ); - }; - - const searchKeyHandler = (e: KeyboardEvent) => { - 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) => { - if (!e.currentTarget.contains(e.relatedTarget)) setShowNodeList(false); - }; - - return ( - setShowNodeList(true)} - onBlur={searchInputBlurHandler} - ref={nodeSearchRef} - > - - {showNodeList && renderNodeList()} - - ); -}; - -export default memo(NodeSearch); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 2e39b7cfc1..a8ce447c09 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -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; diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 1a26f959fd..bcc878d69e 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -33,4 +33,5 @@ export type NodesState = { mouseOverField: FieldIdentifier | null; nodesToCopy: Node[]; edgesToCopy: Edge[]; + isAddNodePopoverOpen: boolean; };