diff --git a/web/src/assets/images/common/close_grey.svg b/web/src/assets/images/common/close_grey.svg new file mode 100644 index 00000000..6797b67f --- /dev/null +++ b/web/src/assets/images/common/close_grey.svg @@ -0,0 +1,15 @@ + + + 关闭 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/logout.svg b/web/src/assets/images/logout.svg deleted file mode 100644 index eedaccc4..00000000 --- a/web/src/assets/images/logout.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - 退出 - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/src/assets/images/logout_grey.svg b/web/src/assets/images/logout_grey.svg new file mode 100644 index 00000000..b9b566c3 --- /dev/null +++ b/web/src/assets/images/logout_grey.svg @@ -0,0 +1,19 @@ + + + 退出 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/logout_hover.svg b/web/src/assets/images/logout_hover.svg deleted file mode 100644 index d77ab292..00000000 --- a/web/src/assets/images/logout_hover.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - 退出 - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/src/components/CodeMirrorEditor/index.tsx b/web/src/components/CodeMirrorEditor/index.tsx index ec2a6780..23729dcc 100644 --- a/web/src/components/CodeMirrorEditor/index.tsx +++ b/web/src/components/CodeMirrorEditor/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-04 17:20:52 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 17:20:52 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-16 11:46:39 */ import { useEffect, useRef, useMemo } from 'react'; import { EditorView, basicSetup } from 'codemirror'; @@ -35,7 +35,7 @@ interface CodeMirrorEditorProps { height?: string; size?: 'default' | 'small'; placeholder?: string; - variant?: 'outlined' | 'borderless'; + variant?: 'outlined' | 'borderless' | 'filled'; } /** @@ -156,7 +156,7 @@ const CodeMirrorEditor = ({
); }; diff --git a/web/src/components/PageTabs/index.module.css b/web/src/components/PageTabs/index.module.css index 5e071b38..c33dcd61 100644 --- a/web/src/components/PageTabs/index.module.css +++ b/web/src/components/PageTabs/index.module.css @@ -1,5 +1,6 @@ .page-tabs:global(.ant-segmented) { padding: 4px; + margin-left: 4px; } .page-tabs:global(.ant-segmented .ant-segmented-item-label) { line-height: 24px; diff --git a/web/src/components/RbSlider/index.tsx b/web/src/components/RbSlider/index.tsx index c37cdc47..70b9fa84 100644 --- a/web/src/components/RbSlider/index.tsx +++ b/web/src/components/RbSlider/index.tsx @@ -44,6 +44,7 @@ const RbSlider: FC = ({ className = '', prefix, inputClassName, + disabled, ...rest }) => { const [curValue, setCurValue] = useState(0) @@ -83,6 +84,7 @@ const RbSlider: FC = ({ max={max} step={step} value={curValue} + disabled={disabled} onChange={handleSliderChange} classNames={size === 'small' ? { rail: 'rb:w-[calc(100%-6px)]!' @@ -96,6 +98,7 @@ const RbSlider: FC = ({ max={max} step={step as number} value={curValue} + disabled={disabled} onChange={handleInputChange} prefix={prefix} className={`${inputClassName || '' } rb:w-20!`} diff --git a/web/src/components/SiderMenu/index.tsx b/web/src/components/SiderMenu/index.tsx index c85f3c9f..0d482c79 100644 --- a/web/src/components/SiderMenu/index.tsx +++ b/web/src/components/SiderMenu/index.tsx @@ -337,7 +337,7 @@ const Menu: FC<{ onClick={goToSpace} className="rb-border-t rb:pt-5! rb:pb-2.5! rb:absolute rb:bottom-2.5 rb:right-5 rb:left-5 rb:text-[13px] rb:text-[#5B6167] rb:hover:text-[#212332] rb:leading-4.5 rb:font-regular rb:text-center rb:mt-2.25 rb:cursor-pointer" > -
+
{collapsed ? null : t('common.returnToSpace')} } diff --git a/web/src/components/Tag/index.tsx b/web/src/components/Tag/index.tsx index 71a20ae9..f07f5cad 100644 --- a/web/src/components/Tag/index.tsx +++ b/web/src/components/Tag/index.tsx @@ -18,7 +18,7 @@ import { type FC, type ReactNode } from 'react' /** Props interface for Tag component */ export interface TagProps { /** Color theme for the tag */ - color?: 'processing' | 'error' | 'success' | 'warning' | 'default', + color?: 'processing' | 'error' | 'success' | 'warning' | 'default' | 'purple' | 'dark', /** Tag content */ children: ReactNode; /** Additional CSS classes */ @@ -32,6 +32,8 @@ const colors = { success: 'rb:text-[#369F21] rb:border-[rgba(54,159,33,0.25)] rb:bg-[rgba(54,159,33,0.06)]', warning: 'rb:text-[#FF5D34] rb:border-[rgba(255,93,52,0.30)] rb:bg-[rgba(255,93,52,0.08)]', default: 'rb:text-[#5B6167] rb:border-[rgba(91,97,103,0.30)] rb:bg-[rgba(91,97,103,0.08)]', + purple: 'rb:text-[#9C6FFF] rb:border-[rgba(156,111,255,0.25)] rb:bg-[rgba(156,111,255,0.06)]', + dark: 'rb:text-[#171719] rb:border-[rgba(23,23,25,0.25)] rb:bg-[rgba(23,23,25,0.06)]' } /** Custom tag component with color themes */ diff --git a/web/src/styles/index.css b/web/src/styles/index.css index 66051085..84b5ec01 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -353,6 +353,26 @@ body { background-color: transparent; border: none; } +.cm-editor-filled { + background: #F6F6F6; + border-radius: 8px; +} +.cm-editor-filled .ͼ1 .cm-lineNumbers .cm-gutterElement { + border-radius: 8px 0 0 8px; +} +.cm-editor-filled .ͼ4 .cm-line { + border-radius: 0 8px 8px 0; +} +.cm-editor-filled .ͼ2 .cm-activeLineGutter, +.cm-editor-filled .ͼ2 .cm-activeLine { + background: transparent; +} +.cm-editor-filled .ͼ1 .cm-placeholder { + color: rgba(23, 23, 25, 0.25); +} +.cm-editor-filled .ͼ1 .cm-lineNumbers .cm-gutterElement { + color: #212332; +} ::-webkit-scrollbar { width: 6px; height: 8px; diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx index a3a9bd7b..112a1e3d 100644 --- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx +++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx @@ -253,7 +253,7 @@ const ConfigHeader: FC = ({ :
{t('common.return')}
diff --git a/web/src/views/ApplicationManagement/MySharing.tsx b/web/src/views/ApplicationManagement/MySharing.tsx index e24cce0b..8fb5b2c0 100644 --- a/web/src/views/ApplicationManagement/MySharing.tsx +++ b/web/src/views/ApplicationManagement/MySharing.tsx @@ -2,27 +2,33 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:34:12 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 14:39:18 + * @Last Modified time: 2026-04-16 11:19:20 */ import React, { useState, useEffect, useMemo, type MouseEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, App, Flex, Collapse } from 'antd'; +import { App, Flex, Row, Col, Space } from 'antd'; import clsx from 'clsx'; import type { MySharedOutItem } from './types'; import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application' import BodyWrapper from '@/components/Empty/BodyWrapper' +import RbCard from '@/components/RbCard/Card' +import RbDescriptions from '@/components/RbDescriptions' +import Tag from '@/components/Tag' const MySharing: React.FC = () => { const { t } = useTranslation(); const { modal } = App.useApp(); + const [loading, setLoading] = useState(false) const [data, setData] = useState([]) useEffect(() => { getList() }, []) const getList = () => { + setLoading(true) mySharedOutList() .then(res => setData(res as MySharedOutItem[])) + .finally(() => setLoading(false)) } /** Group items by target_workspace_id */ @@ -80,89 +86,114 @@ const MySharing: React.FC = () => { window.open(url); } + const [selectedWorkspace, setSelectedWorkspace] = useState(null) + const [appList, setAppList] = useState([]) + + useEffect(() => { + if (grouped.length === 0) { + setSelectedWorkspace(null) + setAppList([]) + return + } + const current = grouped.find(g => g.workspace.target_workspace_id === selectedWorkspace) + if (current) { + setAppList(current.items) + } else { + setSelectedWorkspace(grouped[0].workspace.target_workspace_id) + setAppList(grouped[0].items) + } + }, [grouped, selectedWorkspace]) + + const handleSelectWorkspace = async (target_workspace_id: string) => { + if (target_workspace_id === selectedWorkspace) return + setSelectedWorkspace(target_workspace_id); + const filterWorkspace = grouped.find(item => item.workspace.target_workspace_id === target_workspace_id); + + setAppList(filterWorkspace?.items || []) + }; + return ( - - - {grouped.map(({ workspace, items }) => ( - + + + + {grouped.map(({ workspace, items }) => ( + handleSelectWorkspace(workspace.target_workspace_id)} + > {workspace.target_workspace_icon - ? {workspace.target_workspace_icon} - :
- {workspace.target_workspace_name[0]} -
+ ? {workspace.target_workspace_icon} + :
+ {workspace.target_workspace_name[0]} +
}
- {workspace.target_workspace_name} -
{t('application.appCount', { count: items.length })}
+ {workspace.target_workspace_name} +
{t('application.appCount', { count: items.length })}
- ), - extra: ( - - ), - children: ( -
- {items.map(item => ( -
handleEdit(item)}> -
handleCancelOne(item, e)} - /> - -
- {item.source_app_name[0]} -
-
{item.source_app_name}
-
- - - {t('application.type')} - - {t(`application.${item.source_app_type}`)} - - - - {t('application.version')} - {item.source_app_version} - - - {t('application.permission')} - - {t(`application.${item.permission}`)} - - - - {t('application.souceStatus')} - {item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')} - - -
- ))} -
- ), - }]} - /> - ))} - - - ); + >
+
+ ))} +
+ + +
+ {appList.map(item => ( + {item.source_app_name.trim()[0]}} + subTitle={ + {t(`application.${item.source_app_type}`)} + {item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')} + } + extra={
handleCancelOne(item, e)} + >
} + bodyClassName="rb:py-6! rb:px-4!" + className="rb:cursor-pointer" + onClick={() => handleEdit(item)} + > + {t(`application.${item.permission}`)} + }, + ]} + /> +
+ ))} +
+ +
+
+ ) }; export default MySharing; diff --git a/web/src/views/Home/components/TopCardList.tsx b/web/src/views/Home/components/TopCardList.tsx index 6052192f..e54073b7 100644 --- a/web/src/views/Home/components/TopCardList.tsx +++ b/web/src/views/Home/components/TopCardList.tsx @@ -61,6 +61,7 @@ const TopCardList: FC<{data?: DashboardData}> = ({ data }) => { = 0, + 'rb:text-[#FFFFFF]': item.key === 'total_memory' })}> {data?.[`${item.key}_change` as keyof DashboardData] && typeof data?.[item.key as keyof DashboardData] === 'number' ? (100 * data?.[`${item.key}_change` as keyof DashboardData]).toFixed(2) diff --git a/web/src/views/Ontology/components/PageHeader.tsx b/web/src/views/Ontology/components/PageHeader.tsx deleted file mode 100644 index a4a75928..00000000 --- a/web/src/views/Ontology/components/PageHeader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * @Author: ZhaoYing - * @Date: 2026-02-03 14:10:24 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-06 11:25:59 - */ -import { type FC, type ReactNode } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Layout, Button } from 'antd'; -import { useTranslation } from 'react-i18next'; -import logoutIcon from '@/assets/images/logout_hover.svg' - -const { Header } = Layout; - -/** - * Props for PageHeader component - */ -interface ConfigHeaderProps { - /** Page title/name */ - name?: string | ReactNode; - /** Subtitle content displayed below the title */ - subTitle?: ReactNode | string; - /** Extra content displayed on the right side */ - extra?: ReactNode; -} - -/** - * Page header component for ontology pages - * Displays title, subtitle, back button and extra actions - * @param props - Component props - */ -const PageHeader: FC = ({ - name, - subTitle, - extra -}) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - - /** - * Navigate back to previous page - */ - const goBack = () => { - navigate(-1) - } - return ( -
-
-
- {name} -
-
{subTitle}
-
- -
- - {extra} -
-
- ); -}; - -export default PageHeader; \ No newline at end of file diff --git a/web/src/views/Ontology/pages/Detail.tsx b/web/src/views/Ontology/pages/Detail.tsx index b6ee5953..7b9da496 100644 --- a/web/src/views/Ontology/pages/Detail.tsx +++ b/web/src/views/Ontology/pages/Detail.tsx @@ -122,7 +122,7 @@ const Detail: FC = () => { )} navigate(-1)}>
{t('common.return')}
diff --git a/web/src/views/Workflow/components/Editor/Jinja2Editor.tsx b/web/src/views/Workflow/components/Editor/Jinja2Editor.tsx index 9a21a3b2..a4f20f9f 100644 --- a/web/src/views/Workflow/components/Editor/Jinja2Editor.tsx +++ b/web/src/views/Workflow/components/Editor/Jinja2Editor.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-04-02 15:15:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 14:48:00 + * @Last Modified time: 2026-04-16 11:34:41 */ import { type FC, useEffect, useMemo } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; @@ -81,7 +81,7 @@ export interface Jinja2EditorProps { value?: string; onChange?: (value: string) => void; options?: Suggestion[]; - variant?: 'outlined' | 'borderless'; + variant?: 'outlined' | 'borderless' | 'filled'; height?: number; size?: 'default' | 'small'; className?: string; diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index 52a4f23e..119c17db 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:29:36 + * @Last Modified time: 2026-04-16 12:04:37 */ import { type FC, useState, useMemo } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; @@ -25,7 +25,7 @@ export interface LexicalEditorProps { value?: string; onChange?: (value: string) => void; options?: Suggestion[]; - variant?: 'outlined' | 'borderless'; + variant?: 'outlined' | 'borderless' | 'filled'; height?: number; fontSize?: number; lineHeight?: number; @@ -60,6 +60,7 @@ const Editor: FC =({ }) => { console.log('Editor value', value) const [_count, setCount] = useState(0); + const [focused, setFocused] = useState(false); if (language === 'jinja2') { return ( @@ -90,7 +91,7 @@ const Editor: FC =({ // Calculate minimum height based on type and size const minheight = useMemo(() => { if (type === 'input') { - return `${height ? height : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 26 : 30}px` + return `${height ? height : size === 'small' && ['borderless', 'filled'].includes(variant) ? 18 : size === 'small' ? 26 : 30}px` } return `${height ? height : size === 'small' ? 60 : 120}px` }, [type, size, height, variant]) @@ -103,7 +104,7 @@ const Editor: FC =({ // Calculate line height based on size prop const lineHeight = useMemo(() => { return `${height ? height - 10 : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 16 : 20}px` - }, [size]) + }, [size, height, variant]) // Calculate placeholder minimum height const placeHolderMinheight = useMemo(() => { @@ -112,20 +113,24 @@ const Editor: FC =({ return ( -
+
type === 'input' && setFocused(true)} + onBlur={() => type === 'input' && setFocused(false)} /> } placeholder={ @@ -133,12 +138,13 @@ const Editor: FC =({ style={{ minHeight: placeHolderMinheight, position: 'absolute', - top: variant === 'borderless' ? '2px' : '6px', - left: variant === 'borderless' ? '0' : '11px', - color: '#A8A9AA', + top: variant === 'outlined' ? '6px' : type === 'input' ? '6px' : '2px', + left: variant === 'outlined' ? '11px' : type === 'input' ? '8px' : '0', + color: 'rgba(23,23,25,0.25)', fontSize: fontSize, lineHeight: placeHolderMinheight, pointerEvents: 'none', + borderRadius: '8px', }} > {placeholder} 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/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index f0a58517..02241fed 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:24:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-25 15:23:45 + * @Last Modified time: 2026-04-16 12:06:16 */ import { useMemo, type FC } from 'react' import clsx from 'clsx' @@ -343,7 +343,7 @@ const CaseList: FC = ({ return (
- @@ -377,7 +377,7 @@ const CaseList: FC = ({ {!hideRightField && ( -
+
{leftFieldType === 'array[file]' ? <>TODO : leftFieldType === 'number' @@ -415,7 +415,7 @@ const CaseList: FC = ({ {['boolean', 'array[boolean]'].includes(leftFieldType as string) ? - : + : } ) diff --git a/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx b/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx index 2a976bf0..bd62c490 100644 --- a/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx +++ b/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx @@ -94,7 +94,7 @@ const CodeExecution: FC = ({ options }) => { { label: 'JAVASCRIPT', value: 'javascript' } ]} popupMatchSelectWidth={false} - className={`rb:font-medium! rb:w-25! rb:h-4! rb:p-0! ${styles.editor}`} + className={`rb:font-medium! rb:w-25! rb:h-4! rb:py-0! rb:px-2! ${styles.editor}`} onChange={handleChangeLanguage} variant="borderless" /> diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index 3e9f3261..6ca0fb05 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -178,7 +178,7 @@ const ConditionList: FC = ({ className="rb:mb-2!" >
- @@ -218,7 +218,7 @@ const ConditionList: FC = ({ {!hideRightField && ( -
+
{leftFieldType === 'number' ? ( diff --git a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx index 5d1138f0..ce37743b 100644 --- a/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CycleVarsList/index.tsx @@ -155,7 +155,7 @@ const CycleVarsList: FC = ({ ? : ( diff --git a/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx index 95c2e113..05c42490 100644 --- a/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx +++ b/web/src/views/Workflow/components/Properties/ListOperator/FilterConditions/index.tsx @@ -101,24 +101,20 @@ const FilterConditions: FC = ({ align="start" className="rb:mb-2!" > -
+
{variableType === 'array[file]' && - - - - handleKeyFieldChange(index, value)} + className="rb:w-full! select rb:mb-1!" + variant="borderless" + /> + } - + ({ value: vo, label: t(`application.${vo}`) } ))} - variant="borderless" - className="rb:w-full!" + variant="filled" /> : { if (vo.dataType === keyFieldType) return [vo]; const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType); @@ -167,7 +162,7 @@ const FilterConditions: FC = ({
remove(field.name)} >
diff --git a/web/src/views/Workflow/components/Properties/MappingList/index.tsx b/web/src/views/Workflow/components/Properties/MappingList/index.tsx index f46d6114..1f609445 100644 --- a/web/src/views/Workflow/components/Properties/MappingList/index.tsx +++ b/web/src/views/Workflow/components/Properties/MappingList/index.tsx @@ -58,7 +58,7 @@ const MappingList: FC = ({ label, name, options, extra, valueK placeholder={t('common.pleaseSelect')} options={options} size="small" - className="rb:w-51!" + className="rb:flex-1!" />
= ({ 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, })} diff --git a/web/src/views/Workflow/components/Properties/properties.module.css b/web/src/views/Workflow/components/Properties/properties.module.css index 66da00bf..963f39e5 100644 --- a/web/src/views/Workflow/components/Properties/properties.module.css +++ b/web/src/views/Workflow/components/Properties/properties.module.css @@ -23,6 +23,11 @@ } .properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless) { height: 28px; + border: 1px solid #F6F6F6; + border-radius: 8px; +} +.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless.ant-select-focused) { + border: 1px solid #171719; } .properties :global(.ant-table-wrapper .ant-table-thead>tr>th), .properties :global(.ant-table-wrapper .ant-table-thead>tr>td),