From 5a17b7fd0d750ba7ea6bfde93ee704f95caefdd5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 17 Apr 2026 11:51:21 +0800 Subject: [PATCH] feat(web): variable select support key operate --- .../Editor/plugin/AutocompletePlugin.tsx | 375 ++++++++++-------- .../plugin/Jinja2AutocompletePlugin.tsx | 342 ++++++++++------ .../components/Properties/VariableSelect.tsx | 143 +++++-- 3 files changed, 542 insertions(+), 318 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 6d3b7a4f..a5ea9771 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -5,6 +5,7 @@ * @Last Modified time: 2026-04-13 14:00:07 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; +import { createPortal } from 'react-dom'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { Space, Flex } from 'antd'; @@ -35,61 +36,62 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { const [selectedIndex, setSelectedIndex] = useState(0); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 }); const [expandedParent, setExpandedParent] = useState(null); - const [childPanelTop, setChildPanelTop] = useState(0); + const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); + const [activePanel, setActivePanel] = useState<'main' | 'child'>('main'); + const [childActiveIndex, setChildActiveIndex] = useState(-1); const popupRef = useRef(null); const itemRefs = useRef>(new Map()); + const childItemRefs = useRef>(new Map()); - // Adjust popup position after render based on actual height + // Adjust popup position after render based on actual size useLayoutEffect(() => { if (!popupRef.current || !showSuggestions) return; - const { top, anchorBottom } = popupPosition; + const { top, left, anchorBottom } = popupPosition; const popupHeight = popupRef.current.offsetHeight; + const popupWidth = popupRef.current.offsetWidth; const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; const MARGIN = 10; let finalTop: number; if (top - popupHeight - MARGIN >= 0) { - // Enough space above: show above cursor finalTop = top - popupHeight - MARGIN; } else { - // Not enough space above: show below cursor finalTop = anchorBottom + MARGIN; if (finalTop + popupHeight > viewportHeight - MARGIN) { finalTop = viewportHeight - popupHeight - MARGIN; } } - if (finalTop !== top) { - setPopupPosition(prev => ({ ...prev, top: finalTop })); + let finalLeft = left; + if (finalLeft + popupWidth > viewportWidth - MARGIN) { + finalLeft = viewportWidth - popupWidth - MARGIN; + } + if (finalLeft < MARGIN) finalLeft = MARGIN; + + if (finalTop !== top || finalLeft !== left) { + setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft })); } }, [showSuggestions, popupPosition.anchorBottom]); - const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40) + const CHILD_PANEL_HEIGHT = 280; - const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => { - const relativeTop = elRect.top - popupRect.top; - const absoluteBottom = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT; - const overflow = absoluteBottom - (window.innerHeight - 10); - return overflow > 0 ? relativeTop - overflow : relativeTop; + const calcChildPanelPos = (key: string) => { + const el = itemRefs.current.get(key); + if (!el || !popupRef.current) return; + const elRect = el.getBoundingClientRect(); + const popupRect = popupRef.current.getBoundingClientRect(); + const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height); + const top = Math.max(10, popupRect.bottom - actualChildHeight); + setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 }); }; - const scrollSelectedIntoView = () => { - if (!popupRef.current) return; - - const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); - if (!selectedElement) return; - - const container = popupRef.current; - const element = selectedElement as HTMLElement; - - const containerRect = container.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - - if (elementRect.bottom > containerRect.bottom) { - container.scrollTop += elementRect.bottom - containerRect.bottom; - } else if (elementRect.top < containerRect.top) { - container.scrollTop -= containerRect.top - elementRect.top; - } + const resetState = () => { + setShowSuggestions(false); + setExpandedParent(null); + setChildPanelPos({ top: 0, right: 0 }); + setActivePanel('main'); + setChildActiveIndex(-1); }; // Listen to editor updates and show suggestions when '/' is typed @@ -105,11 +107,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { const anchorNode = selection.anchor.getNode(); const anchorOffset = selection.anchor.offset; - - // Get the text content of the current node const nodeText = anchorNode.getTextContent(); - - // Check if we have a '/' at the current position or after line break const textBeforeCursor = nodeText.substring(0, anchorOffset); const shouldShow = textBeforeCursor.endsWith('/') || (textBeforeCursor === '/' && anchorOffset === 1); @@ -118,10 +116,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); - setChildPanelTop(0); + setChildPanelPos({ top: 0, right: 0 }); + setActivePanel('main'); + setChildActiveIndex(-1); } - // Calculate popup position to keep it within viewport bounds if (shouldShow) { const domSelection = window.getSelection(); if (domSelection && domSelection.rangeCount > 0) { @@ -149,9 +148,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { return editor.registerCommand( CLOSE_AUTOCOMPLETE_COMMAND, () => { - setShowSuggestions(false); - setExpandedParent(null); - setChildPanelTop(0); + resetState(); return true; }, COMMAND_PRIORITY_HIGH @@ -161,9 +158,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { // Insert selected suggestion into editor const insertMention = (suggestion: Suggestion) => { editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); - setShowSuggestions(false); - setExpandedParent(null); - setChildPanelTop(0); + resetState(); }; // Group suggestions by node ID @@ -177,13 +172,28 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { return groups; }, {}); - // Flat list for keyboard navigation - const flatOptions = Object.values(groupedSuggestions).flat().flatMap(option => { - if (option.key === expandedParent?.key && option.children?.length) { - return [option, ...option.children]; + // Flat list of main-panel items for keyboard navigation + const flatOptions = Object.values(groupedSuggestions).flat(); + + // Sync child panel position when keyboard navigates to a parent with children + useEffect(() => { + if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return; + const s = flatOptions[selectedIndex]; + if (s.children?.length) { + calcChildPanelPos(s.key); + setExpandedParent(s); + } else { + setExpandedParent(null); } - return [option]; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedIndex]); + + // Scroll child active item into view + useEffect(() => { + if (!expandedParent?.children?.length || childActiveIndex < 0) return; + const child = expandedParent.children[childActiveIndex]; + if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' }); + }, [childActiveIndex, expandedParent]); // Handle Enter key to select suggestion useEffect(() => { @@ -192,7 +202,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { return editor.registerCommand( KEY_ENTER_COMMAND, (event) => { - if (showSuggestions && flatOptions.length > 0) { + if (!showSuggestions) return false; + if (activePanel === 'child' && expandedParent?.children?.length) { + const child = expandedParent.children[childActiveIndex]; + if (child && !child.disabled) { + event?.preventDefault(); + insertMention(child); + return true; + } + } else if (flatOptions.length > 0) { const selectedOption = flatOptions[selectedIndex]; if (selectedOption && !selectedOption.disabled) { event?.preventDefault(); @@ -204,57 +222,56 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { }, COMMAND_PRIORITY_HIGH ); - }, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]); + }, [showSuggestions, selectedIndex, flatOptions, insertMention, editor, activePanel, childActiveIndex, expandedParent]); - // Handle keyboard navigation (Arrow Up/Down, Escape) + // Handle keyboard navigation (Arrow Up/Down/Left/Right, Escape) useEffect(() => { if (!showSuggestions) return; - // Navigate down through suggestions, skip disabled items const unregisterArrowDown = editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => { - if (showSuggestions && flatOptions.length > 0) { - event?.preventDefault(); + if (!showSuggestions) return false; + event?.preventDefault(); + if (activePanel === 'child' && expandedParent?.children) { + setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1)); + } else { setSelectedIndex(prev => { - let nextIndex = prev + 1; - while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) { - nextIndex++; - } - const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex; - setTimeout(() => scrollSelectedIntoView(), 0); + let next = prev + 1; + // skip items that are disabled AND have no children + while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++; + const newIndex = next >= flatOptions.length ? prev : next; + setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0); return newIndex; }); - return true; } - return false; + return true; }, COMMAND_PRIORITY_HIGH ); - // Navigate up through suggestions, skip disabled items const unregisterArrowUp = editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => { - if (showSuggestions && flatOptions.length > 0) { - event?.preventDefault(); + if (!showSuggestions) return false; + event?.preventDefault(); + if (activePanel === 'child' && expandedParent?.children) { + setChildActiveIndex(i => Math.max(i - 1, 0)); + } else { setSelectedIndex(prev => { - let prevIndex = prev - 1; - while (prevIndex >= 0 && flatOptions[prevIndex].disabled) { - prevIndex--; - } - const newIndex = prevIndex < 0 ? prev : prevIndex; - setTimeout(() => scrollSelectedIntoView(), 0); + let prevIdx = prev - 1; + // skip items that are disabled AND have no children + while (prevIdx >= 0 && flatOptions[prevIdx].disabled && !flatOptions[prevIdx].children?.length) prevIdx--; + const newIndex = prevIdx < 0 ? prev : prevIdx; + setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0); return newIndex; }); - return true; } - return false; + return true; }, COMMAND_PRIORITY_HIGH ); - // Close suggestions on Escape key const unregisterEscape = editor.registerCommand( KEY_ESCAPE_COMMAND, (event) => { @@ -273,99 +290,122 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { unregisterArrowUp(); unregisterEscape(); }; - }, [showSuggestions, selectedIndex, flatOptions, editor]); + }, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]); + + // Handle ArrowLeft/Right for panel switching via native keydown (lexical doesn't expose these commands) + useEffect(() => { + if (!showSuggestions) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + const current = flatOptions[selectedIndex]; + if (activePanel === 'main' && current?.children?.length) { + e.preventDefault(); + setActivePanel('child'); + setChildActiveIndex(0); + } + } else if (e.key === 'ArrowRight') { + if (activePanel === 'child') { + e.preventDefault(); + setActivePanel('main'); + setChildActiveIndex(-1); + } + } + }; + document.addEventListener('keydown', handler, true); + return () => document.removeEventListener('keydown', handler, true); + }, [showSuggestions, activePanel, selectedIndex, flatOptions]); if (!showSuggestions) return null; + if (Object.entries(groupedSuggestions).length === 0) return null; - if (Object.entries(groupedSuggestions).length === 0) { - return null - } return ( -
e.preventDefault()} - className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" - style={{ - top: popupPosition.top, - left: popupPosition.left, - }} - > -
- - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { - const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - return ( -
- {nodeName !== 'undefined' && -
- {nodeName} -
- } - - {nodeOptions.map((option) => { - const globalIndex = flatOptions.indexOf(option); - const isExpanded = expandedParent?.key === option.key; - const hasChildren = !!option.children?.length; - return ( - { if (el) itemRefs.current.set(option.key, el); }} - data-selected={selectedIndex === globalIndex} - className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { - 'rb:bg-[#F6F6F6]': selectedIndex === globalIndex || isExpanded, - 'rb:cursor-not-allowed rb:opacity-65': option.disabled, - 'rb:cursor-pointer': !option.disabled, - })} - align="center" - justify="space-between" - onClick={() => { - if (option.disabled) return; - insertMention(option); - }} - onMouseEnter={() => { - setSelectedIndex(globalIndex); - if (hasChildren) { - const el = itemRefs.current.get(option.key); - if (el && popupRef.current) { - const elRect = el.getBoundingClientRect(); - const popupRect = popupRef.current.getBoundingClientRect(); - setChildPanelTop(calcChildPanelTop(elRect, popupRect)); + <> +
e.preventDefault()} + className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + style={{ + top: popupPosition.top, + left: popupPosition.left, + }} + > +
+ + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {nodeName !== 'undefined' && +
+ {nodeName} +
+ } + + {nodeOptions.map((option) => { + const globalIndex = flatOptions.indexOf(option); + const isExpanded = expandedParent?.key === option.key; + const hasChildren = !!option.children?.length; + const isActive = activePanel === 'main' && selectedIndex === globalIndex; + return ( + { if (el) itemRefs.current.set(option.key, el); }} + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': isActive || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': option.disabled, + 'rb:cursor-pointer': !option.disabled, + })} + align="center" + justify="space-between" + onClick={() => { + if (option.disabled && !hasChildren) return; + if (!option.disabled) insertMention(option); + if (hasChildren) { + calcChildPanelPos(option.key); + setExpandedParent(option); } - setExpandedParent(option); - } else { - setExpandedParent(null); + }} + onMouseEnter={() => { + setSelectedIndex(globalIndex); + setActivePanel('main'); + setChildActiveIndex(-1); + if (hasChildren) { + calcChildPanelPos(option.key); + setExpandedParent(option); + } else { + setExpandedParent(null); + } + }} + > + {option.label && +
+ {`{x}`} {option.label} +
} - }} - > - {option.label && -
- {`{x}`} {option.label} -
- } - - {option.dataType && {option.dataType}} - {hasChildren &&
} -
-
- ); - })} -
-
- ); - })} -
+ + {option.dataType && {option.dataType}} + {hasChildren &&
} +
+ + ); + })} + +
+ ); + })} + +
- {/* Child variables panel - floats to the left */} - {expandedParent?.children?.length && ( + + {/* Child variables panel - fixed positioned via portal to avoid clipping */} + {expandedParent?.children?.length && createPortal(
setExpandedParent(expandedParent)} + onMouseDown={(e) => e.preventDefault()} + className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + style={{ top: childPanelPos.top, right: childPanelPos.right }} + onMouseEnter={() => setActivePanel('child')} + onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }} >
@@ -373,21 +413,21 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { {expandedParent.dataType}
- {expandedParent.children.map((child) => { - const childIndex = flatOptions.indexOf(child); + {expandedParent.children.map((child, ci) => { + const isChildActive = activePanel === 'child' && ci === childActiveIndex; return ( { if (el) childItemRefs.current.set(child.key, el); }} className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { - 'rb:bg-[#F6F6F6]': selectedIndex === childIndex, + 'rb:bg-[#F6F6F6]': isChildActive, 'rb:cursor-not-allowed rb:opacity-65': child.disabled, 'rb:cursor-pointer': !child.disabled, })} align="center" justify="space-between" onClick={() => !child.disabled && insertMention(child)} - onMouseEnter={() => setSelectedIndex(childIndex)} + onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }} > {`{x}`} {child.label} @@ -396,9 +436,10 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ); })} -
+
, + document.body )} -
+ ); } -export default AutocompletePlugin \ No newline at end of file +export default AutocompletePlugin diff --git a/web/src/views/Workflow/components/Editor/plugin/Jinja2AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/Jinja2AutocompletePlugin.tsx index 97754820..2b4651e4 100644 --- a/web/src/views/Workflow/components/Editor/plugin/Jinja2AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/Jinja2AutocompletePlugin.tsx @@ -5,6 +5,7 @@ * @Last Modified time: 2026-04-07 14:50:14 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; +import { createPortal } from 'react-dom'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $isTextNode, @@ -12,6 +13,7 @@ import { KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, } from 'lexical'; import { Space, Flex } from 'antd'; +import clsx from 'clsx'; import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import type { Suggestion } from './AutocompletePlugin'; @@ -22,17 +24,22 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => const [selectedIndex, setSelectedIndex] = useState(0); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 }); const [expandedParent, setExpandedParent] = useState(null); - const [childPanelTop, setChildPanelTop] = useState(0); + const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); + const [activePanel, setActivePanel] = useState<'main' | 'child'>('main'); + const [childActiveIndex, setChildActiveIndex] = useState(-1); const popupRef = useRef(null); const itemRefs = useRef>(new Map()); + const childItemRefs = useRef>(new Map()); const CHILD_PANEL_HEIGHT = 280; useLayoutEffect(() => { if (!popupRef.current || !showSuggestions) return; - const { top, anchorBottom } = popupPosition; + const { top, left, anchorBottom } = popupPosition; const popupHeight = popupRef.current.offsetHeight; + const popupWidth = popupRef.current.offsetWidth; const MARGIN = 10; + let finalTop: number; if (top - popupHeight - MARGIN >= 0) { finalTop = top - popupHeight - MARGIN; @@ -41,51 +48,57 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => if (finalTop + popupHeight > window.innerHeight - MARGIN) finalTop = window.innerHeight - popupHeight - MARGIN; } - if (finalTop !== top) setPopupPosition(prev => ({ ...prev, top: finalTop })); + + let finalLeft = left; + if (finalLeft + popupWidth > window.innerWidth - MARGIN) + finalLeft = window.innerWidth - popupWidth - MARGIN; + if (finalLeft < MARGIN) finalLeft = MARGIN; + + if (finalTop !== top || finalLeft !== left) + setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft })); }, [showSuggestions, popupPosition.anchorBottom]); - const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => { - const relativeTop = elRect.top - popupRect.top; - const overflow = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT - (window.innerHeight - 10); - return overflow > 0 ? relativeTop - overflow : relativeTop; + const calcChildPanelPos = (key: string) => { + const el = itemRefs.current.get(key); + if (!el || !popupRef.current) return; + const elRect = el.getBoundingClientRect(); + const popupRect = popupRef.current.getBoundingClientRect(); + const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height); + const top = Math.max(10, popupRect.bottom - actualChildHeight); + setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 }); }; - const scrollSelectedIntoView = () => { - if (!popupRef.current) return; - const selectedElement = popupRef.current.querySelector('[data-selected="true"]'); - if (!selectedElement) return; - const container = popupRef.current; - const element = selectedElement as HTMLElement; - const containerRect = container.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - if (elementRect.bottom > containerRect.bottom) { - container.scrollTop += elementRect.bottom - containerRect.bottom; - } else if (elementRect.top < containerRect.top) { - container.scrollTop -= containerRect.top - elementRect.top; - } + const resetState = () => { + setShowSuggestions(false); + setExpandedParent(null); + setChildPanelPos({ top: 0, right: 0 }); + setActivePanel('main'); + setChildActiveIndex(-1); }; useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { const selection = $getSelection(); - if (!selection || !$isRangeSelection(selection)) { - setShowSuggestions(false); - return; - } + if (!selection || !$isRangeSelection(selection)) { setShowSuggestions(false); return; } const anchorNode = selection.anchor.getNode(); const anchorOffset = selection.anchor.offset; const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset); const shouldShow = textBeforeCursor.endsWith('/'); setShowSuggestions(shouldShow); - if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; } - + if (!shouldShow) { + setSelectedIndex(0); + setExpandedParent(null); + setChildPanelPos({ top: 0, right: 0 }); + setActivePanel('main'); + setChildActiveIndex(-1); + return; + } const domSelection = window.getSelection(); if (domSelection && domSelection.rangeCount > 0) { const rect = domSelection.getRangeAt(0).getBoundingClientRect(); - const popupWidth = 280; let left = rect.left; - if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10; + if (left + 280 > window.innerWidth) left = window.innerWidth - 280 - 10; if (left < 10) left = 10; setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom }); } @@ -96,7 +109,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => useEffect(() => { return editor.registerCommand( CLOSE_AUTOCOMPLETE_COMMAND, - () => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; }, + () => { resetState(); return true; }, COMMAND_PRIORITY_HIGH, ); }, [editor]); @@ -119,9 +132,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => } }); document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } })); - setShowSuggestions(false); - setExpandedParent(null); - setChildPanelTop(0); + resetState(); }; const groupedSuggestions = options.reduce((groups: Record, s) => { @@ -131,152 +142,227 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => return groups; }, {}); - const allOptions = Object.values(groupedSuggestions).flat().flatMap(o => - o.key === expandedParent?.key && o.children?.length ? [o, ...o.children] : [o] - ); + // Flat list of main-panel items for keyboard navigation + const flatOptions = Object.values(groupedSuggestions).flat(); + + // Sync child panel position when keyboard navigates to a parent with children + useEffect(() => { + if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return; + const s = flatOptions[selectedIndex]; + if (s.children?.length) { + calcChildPanelPos(s.key); + setExpandedParent(s); + } else { + setExpandedParent(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedIndex]); + + // Scroll child active item into view + useEffect(() => { + if (!expandedParent?.children?.length || childActiveIndex < 0) return; + const child = expandedParent.children[childActiveIndex]; + if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' }); + }, [childActiveIndex, expandedParent]); useEffect(() => { if (!showSuggestions) return; return editor.registerCommand( KEY_ENTER_COMMAND, (event) => { - const opt = allOptions[selectedIndex]; - if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; } + if (!showSuggestions) return false; + if (activePanel === 'child' && expandedParent?.children?.length) { + const child = expandedParent.children[childActiveIndex]; + if (child && !child.disabled) { event?.preventDefault(); insertMention(child); return true; } + } else if (flatOptions.length > 0) { + const opt = flatOptions[selectedIndex]; + if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; } + } return false; }, COMMAND_PRIORITY_HIGH, ); - }, [showSuggestions, selectedIndex, allOptions]); + }, [showSuggestions, selectedIndex, flatOptions, activePanel, childActiveIndex, expandedParent]); useEffect(() => { if (!showSuggestions) return; const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => { + if (!showSuggestions) return false; e?.preventDefault(); - setSelectedIndex(prev => { - let next = prev + 1; - while (next < allOptions.length && allOptions[next].disabled) next++; - setTimeout(scrollSelectedIntoView, 0); - return next >= allOptions.length ? prev : next; - }); + if (activePanel === 'child' && expandedParent?.children) { + setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1)); + } else { + setSelectedIndex(prev => { + let next = prev + 1; + while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++; + const newIndex = next >= flatOptions.length ? prev : next; + setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0); + return newIndex; + }); + } return true; }, COMMAND_PRIORITY_HIGH); + const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => { + if (!showSuggestions) return false; e?.preventDefault(); - setSelectedIndex(prev => { - let p = prev - 1; - while (p >= 0 && allOptions[p].disabled) p--; - setTimeout(scrollSelectedIntoView, 0); - return p < 0 ? prev : p; - }); + if (activePanel === 'child' && expandedParent?.children) { + setChildActiveIndex(i => Math.max(i - 1, 0)); + } else { + setSelectedIndex(prev => { + let p = prev - 1; + while (p >= 0 && flatOptions[p].disabled && !flatOptions[p].children?.length) p--; + const newIndex = p < 0 ? prev : p; + setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0); + return newIndex; + }); + } return true; }, COMMAND_PRIORITY_HIGH); + const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => { e?.preventDefault(); setShowSuggestions(false); return true; }, COMMAND_PRIORITY_HIGH); + return () => { down(); up(); esc(); }; - }, [showSuggestions, selectedIndex, allOptions, editor]); + }, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]); + + // ArrowLeft/Right for panel switching + useEffect(() => { + if (!showSuggestions) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + const current = flatOptions[selectedIndex]; + if (activePanel === 'main' && current?.children?.length) { + e.preventDefault(); + setActivePanel('child'); + setChildActiveIndex(0); + } + } else if (e.key === 'ArrowRight') { + if (activePanel === 'child') { + e.preventDefault(); + setActivePanel('main'); + setChildActiveIndex(-1); + } + } + }; + document.addEventListener('keydown', handler, true); + return () => document.removeEventListener('keydown', handler, true); + }, [showSuggestions, activePanel, selectedIndex, flatOptions]); if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null; return ( -
e.preventDefault()} - className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" - style={{ top: popupPosition.top, left: popupPosition.left }} - > -
- - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => ( -
- - {nodeOptions[0]?.nodeData?.icon &&
} - {nodeOptions[0]?.nodeData?.name || nodeId} - - {nodeOptions.map((option) => { - const globalIndex = allOptions.indexOf(option); - const hasChildren = !!option.children?.length; - const isExpanded = expandedParent?.key === option.key; + <> +
e.preventDefault()} + className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + style={{ top: popupPosition.top, left: popupPosition.left }} + > +
+ + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; return ( - { if (el) itemRefs.current.set(option.key, el); }} - data-selected={selectedIndex === globalIndex} - className="rb:pl-6! rb:pr-3! rb:py-2!" - align="center" - justify="space-between" - style={{ - cursor: option.disabled ? 'not-allowed' : 'pointer', - background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', - opacity: option.disabled ? 0.5 : 1, - }} - onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }} - onMouseEnter={() => { - setSelectedIndex(globalIndex); - if (hasChildren) { - const el = itemRefs.current.get(option.key); - if (el && popupRef.current) { - setChildPanelTop(calcChildPanelTop(el.getBoundingClientRect(), popupRef.current.getBoundingClientRect())); - } - setExpandedParent(option); - } else { - setExpandedParent(null); - } - }} - > - - {option.isContext ? '📄' : '{x}'} - {option.label} - - - {option.dataType && {option.dataType}} - {hasChildren && ›} - - +
+ {nodeName !== 'undefined' && +
+ {nodeName} +
+ } + + {nodeOptions.map((option) => { + const globalIndex = flatOptions.indexOf(option); + const hasChildren = !!option.children?.length; + const isExpanded = expandedParent?.key === option.key; + const isActive = activePanel === 'main' && selectedIndex === globalIndex; + return ( + { if (el) itemRefs.current.set(option.key, el); }} + className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', { + 'rb:bg-[#F6F6F6]': isActive || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': option.disabled && !hasChildren, + 'rb:cursor-pointer': !option.disabled || hasChildren, + })} + align="center" + justify="space-between" + onClick={() => { + if (option.disabled && !hasChildren) return; + if (!option.disabled) insertMention(option); + if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); } + }} + onMouseEnter={() => { + setSelectedIndex(globalIndex); + setActivePanel('main'); + setChildActiveIndex(-1); + if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); } + else setExpandedParent(null); + }} + > + {option.label && +
+ {`{x}`} {option.label} +
+ } + + {option.dataType && {option.dataType}} + {hasChildren &&
} +
+
+ ); + })} +
+
); })} -
- ))} - + +
- {expandedParent?.children?.length && ( + + {expandedParent?.children?.length && createPortal(
setExpandedParent(expandedParent)} + onMouseDown={(e) => e.preventDefault()} + className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + style={{ top: childPanelPos.top, right: childPanelPos.right }} + onMouseEnter={() => setActivePanel('child')} + onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }} > -
- +
+ {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType}
- {expandedParent.children.map((child) => { - const childIndex = allOptions.indexOf(child); + {expandedParent.children.map((child, ci) => { + const isChildActive = activePanel === 'child' && ci === childActiveIndex; return ( { if (el) childItemRefs.current.set(child.key, el); }} + className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', { + 'rb:bg-[#F6F6F6]': isChildActive, + 'rb:cursor-not-allowed rb:opacity-65': child.disabled, + 'rb:cursor-pointer': !child.disabled, + })} align="center" justify="space-between" - style={{ - cursor: child.disabled ? 'not-allowed' : 'pointer', - background: selectedIndex === childIndex ? '#f0f8ff' : 'white', - opacity: child.disabled ? 0.5 : 1, - }} onClick={() => !child.disabled && insertMention(child)} - onMouseEnter={() => setSelectedIndex(childIndex)} + onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }} > - {child.label} - {child.dataType && {child.dataType}} + + {`{x}`} {child.label} + + {child.dataType && {child.dataType}} ); })} -
+
, + document.body )} -
+ ); }; diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index 203dd850..b749d3b2 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 11:25:40 + * @Last Modified time: 2026-04-16 13:57:30 */ import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { createPortal } from 'react-dom' @@ -41,14 +41,33 @@ const VariableSelect: FC = ({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [expandedParentKey, setExpandedParentKey] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + const [activePanel, setActivePanel] = useState<'main' | 'child'>('main'); + const [childActiveIndex, setChildActiveIndex] = useState(-1); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 }); const containerRef = useRef(null); const dropdownRef = useRef(null); const itemRefs = useRef>(new Map()); + const childItemRefs = useRef>(new Map()); + const activeKeyRef = useRef(null); const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40) + const calcChildPos = (key: string) => { + const el = itemRefs.current.get(key); + if (!el) return; + const rect = el.getBoundingClientRect(); + const dropdownEl = dropdownRef.current; + if (!dropdownEl) return; + const dropdownRect = dropdownEl.getBoundingClientRect(); + const dropdownBottom = dropdownRect.bottom; + const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, dropdownRect.height); + // Bottom-align child panel with main panel + const top = Math.max(10, dropdownBottom - actualChildHeight); + setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 }); + }; + // Calculate dropdown position (runs synchronously after DOM paint to avoid flicker) useLayoutEffect(() => { if (!open || !containerRef.current) return; @@ -69,7 +88,9 @@ const VariableSelect: FC = ({ ? triggerRect.bottom + MARGIN : Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN); setDropdownPos({ top, left, width }); - }, [open, search, Array.isArray(value) ? value.length : 0]); + // Re-calculate child panel position if expanded + if (expandedParentKey) calcChildPos(expandedParentKey); + }, [open, search, Array.isArray(value) ? value.length : 0, options.length, expandedParentKey]); const filteredOptions = filterBooleanType ? options.filter(o => o.dataType !== 'boolean') @@ -107,6 +128,12 @@ const VariableSelect: FC = ({ }, {}) : groupedSuggestions; + useEffect(() => { + if (!expandedParentKey) return; + calcChildPos(expandedParentKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dropdownPos, expandedParentKey]); + useEffect(() => { if (!open) return; const updatePos = () => { @@ -151,6 +178,87 @@ const VariableSelect: FC = ({ return () => document.removeEventListener('mousedown', handler); }, [open]); + // Flat list of all visible selectable items (main panel only, no children expanded inline) + const flatItems = Object.values(filteredGroups).flat(); + + useEffect(() => { + setActiveIndex(-1); + setActivePanel('main'); + setChildActiveIndex(-1); + }, [open, search]); + + useEffect(() => { + if (activeIndex < 0 || activeIndex >= flatItems.length) { + setExpandedParentKey(null); + return; + } + const s = flatItems[activeIndex]; + activeKeyRef.current = s.key; + itemRefs.current.get(s.key)?.scrollIntoView({ block: 'nearest' }); + if (s.children?.length) { + calcChildPos(s.key); + setExpandedParentKey(s.key); + } else { + setExpandedParentKey(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex]); + + useEffect(() => { + if (!expandedParent?.children?.length || childActiveIndex < 0) return; + const child = expandedParent.children[childActiveIndex]; + if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' }); + }, [childActiveIndex, expandedParent]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + const children = expandedParent?.children ?? []; + if (activePanel === 'child') { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setChildActiveIndex(i => Math.min(i + 1, children.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setChildActiveIndex(i => Math.max(i - 1, 0)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + setActivePanel('main'); + setChildActiveIndex(-1); + } else if (e.key === 'Enter' && childActiveIndex >= 0 && childActiveIndex < children.length) { + e.preventDefault(); + const child = children[childActiveIndex]; + if (!child.disabled) handleSelect(child); + } else if (e.key === 'Escape') { + setOpen(false); + } + } else { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(i + 1, flatItems.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(i - 1, 0)); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (expandedParent?.children?.length) { + setActivePanel('child'); + setChildActiveIndex(0); + } + } else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < flatItems.length) { + e.preventDefault(); + const s = flatItems[activeIndex]; + if (!s.disabled) handleSelect(s); + } else if (e.key === 'Escape') { + setOpen(false); + } + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, activeIndex, activePanel, childActiveIndex, flatItems, expandedParent]); + const handleSelect = (suggestion: Suggestion) => { if (multiple) { const key = `{{${suggestion.value}}}`; @@ -171,19 +279,6 @@ const VariableSelect: FC = ({ e.stopPropagation(); onChange?.(multiple ? [] : '', multiple ? [] : undefined); }; - - const updateChildPos = (key: string) => { - const el = itemRefs.current.get(key); - if (el) { - const rect = el.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top - 10; - const top = spaceBelow >= CHILD_PANEL_HEIGHT - ? rect.top - : Math.max(10, window.innerHeight - CHILD_PANEL_HEIGHT - 10); - setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 }); - } - }; - const sep = /; const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' || (selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false); @@ -197,7 +292,7 @@ const VariableSelect: FC = ({ 'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', { 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled', 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined', - 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]': variant === 'outlined' && open, + 'rb:border-[#171719]!': variant === 'outlined' && open, 'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless', 'rb:text-[12px]': size === 'small', 'rb:text-[14px]': size !== 'small', @@ -244,7 +339,7 @@ const VariableSelect: FC = ({ })}
) : ( - {placeholder} + {placeholder} ) ) : selectedSuggestion ? (
@@ -260,7 +355,7 @@ const VariableSelect: FC = ({
) : ( - {placeholder} + {placeholder} )} {allowClear && ( @@ -306,7 +401,7 @@ const VariableSelect: FC = ({ key={s.key} ref={(el) => { if (el) itemRefs.current.set(s.key, el); }} className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { - 'rb:bg-[#F6F6F6]': isSelected || isExpanded, + 'rb:bg-[#F6F6F6]': isSelected || isExpanded || flatItems.indexOf(s) === activeIndex, 'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-pointer': !s.disabled, })} @@ -315,14 +410,14 @@ const VariableSelect: FC = ({ onClick={() => { if (s.disabled) return; if (hasChildren) { - updateChildPos(s.key); + calcChildPos(s.key); setExpandedParentKey(prev => prev === s.key ? null : s.key); } handleSelect(s); }} onMouseEnter={() => { if (hasChildren) { - updateChildPos(s.key); + calcChildPos(s.key); setExpandedParentKey(s.key); } else { setExpandedParentKey(null); @@ -370,15 +465,17 @@ const VariableSelect: FC = ({ {expandedParent.dataType}
- {expandedParent.children.map(child => { + {expandedParent.children.map((child, ci) => { const isSelected = multiple ? selectedValues.includes(`{{${child.value}}}`) : `{{${child.value}}}` === value; + const isChildActive = activePanel === 'child' && ci === childActiveIndex; return ( { if (el) childItemRefs.current.set(child.key, el); }} className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { - 'rb:bg-[#F6F6F6]': isSelected, + 'rb:bg-[#F6F6F6]': isSelected || isChildActive, 'rb:cursor-not-allowed rb:opacity-65': child.disabled, 'rb:cursor-pointer': !child.disabled, })}