diff --git a/web/src/views/Workflow/components/Editor/commands/index.ts b/web/src/views/Workflow/components/Editor/commands/index.ts index 7f30c46a..839e13f1 100644 --- a/web/src/views/Workflow/components/Editor/commands/index.ts +++ b/web/src/views/Workflow/components/Editor/commands/index.ts @@ -1,13 +1,25 @@ +/* + * @Author: ZhaoYing + * @Date: 2025-12-23 12:29:46 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-03 10:12:48 + */ import { createCommand, type LexicalCommand } from 'lexical'; import type { Suggestion } from '../plugin/AutocompletePlugin'; - +// Payload interface for inserting variable command export interface InsertVariableCommandPayload { data: Suggestion; } +// Command to insert a variable into the editor export const INSERT_VARIABLE_COMMAND: LexicalCommand = createCommand('INSERT_VARIABLE_COMMAND'); +// Command to clear all editor content export const CLEAR_EDITOR_COMMAND: LexicalCommand = createCommand('CLEAR_EDITOR_COMMAND'); -export const FOCUS_EDITOR_COMMAND: LexicalCommand = createCommand('FOCUS_EDITOR_COMMAND'); \ No newline at end of file +// Command to focus the editor +export const FOCUS_EDITOR_COMMAND: LexicalCommand = createCommand('FOCUS_EDITOR_COMMAND'); + +// Command to close the autocomplete dropdown +export const CLOSE_AUTOCOMPLETE_COMMAND: LexicalCommand = createCommand('CLOSE_AUTOCOMPLETE_COMMAND'); \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index 5e376cc8..4707e20c 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -1,3 +1,9 @@ +/* + * @Author: ZhaoYing + * @Date: 2025-12-23 16:22:51 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-03 10:11:48 + */ import { type FC, useState, useEffect, useMemo } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -19,6 +25,7 @@ import LineNumberPlugin from './plugin/LineNumberPlugin'; import BlurPlugin from './plugin/BlurPlugin'; import { VariableNode } from './nodes/VariableNode' +// Props interface for Lexical Editor component export interface LexicalEditorProps { placeholder?: string; value?: string; @@ -34,6 +41,7 @@ export interface LexicalEditorProps { className?: string; } +// Default theme for editor const theme = { paragraph: 'editor-paragraph', text: { @@ -42,6 +50,7 @@ const theme = { }, }; +// Theme with Jinja2 syntax highlighting const jinja2Theme = { ...theme, code: 'jinja2-expression', @@ -51,7 +60,8 @@ const jinja2Theme = { }, }; -const Editor: FC =({ +// Main Lexical Editor component +const Editor: FC =(({ placeholder = "请输入内容...", value = "", onChange, @@ -67,6 +77,7 @@ const Editor: FC =({ const [enableJinja2, setEnableJinja2] = useState(false) const [enableLineNumbers, setEnableLineNumbers] = useState(false) + // Setup Jinja2 mode and inject styles when language changes useEffect(() => { const needsLineNumbers = language === 'jinja2'; setEnableJinja2(language === 'jinja2'); @@ -139,11 +150,12 @@ const Editor: FC =({ } }, [language]) + // Lexical editor configuration const initialConfig = { namespace: 'AutocompleteEditor', theme: enableJinja2 ? jinja2Theme : theme, nodes: enableJinja2 ? [ - // 当启用jinja2时,不使用VariableNode,使用普通文本 + // When Jinja2 is enabled, use plain text instead of VariableNode ] : [ // HeadingNode, // QuoteNode, @@ -157,18 +169,26 @@ const Editor: FC =({ console.error(error); }, }; + + // Calculate minimum height based on type and size const minheight = useMemo(() => { if (type === 'input') { return `${height ? height : size === 'small' ? 28 : 30}px` } return `${height ? height : size === 'small' ? 60 : 120}px` }, [type, size, height]) + + // Calculate font size based on size prop const fontSize = useMemo(() => { return `${size === 'small' ? 12 : 14}px` }, [size]) + + // Calculate line height based on size prop const lineHeight = useMemo(() => { return `${height ? height : size === 'small' ? 16 : 20}px` }, [size]) + + // Calculate placeholder minimum height const placeHolderMinheight = useMemo(() => { return `${height ? height : size === 'small' ? 16 : 30}px` }, [type, size, height]) @@ -179,6 +199,7 @@ const Editor: FC =({ =({ ) : ( + // Standard editor without line numbers =({ } ErrorBoundary={LexicalErrorBoundary} /> + {/* Editor plugins */} {language === 'jinja2' && } @@ -242,10 +265,10 @@ const Editor: FC =({ { setCount(count) }} onChange={onChange} /> - {enableJinja2 && } + ); -}; +}); -export default Editor; \ No newline at end of file +export default Editor; diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 8e2687f1..25ef511f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,10 +1,17 @@ +/* + * @Author: ZhaoYing + * @Date: 2025-12-23 16:22:51 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-03 10:12:33 + */ import { useEffect, useState, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; -import { INSERT_VARIABLE_COMMAND } from '../commands'; +import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' +// Suggestion item interface for autocomplete dropdown export interface Suggestion { key: string; label: string; @@ -13,16 +20,18 @@ export interface Suggestion { value: string; group?: string nodeData: NodeProperties; - isContext?: boolean; // 标记是否为context变量 - disabled?: boolean; // 标记是否禁用 + isContext?: boolean; // Flag for context variable + disabled?: boolean; // Flag for disabled state } +// Autocomplete plugin for variable suggestions triggered by '/' character const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => { const [editor] = useLexicalComposerContext(); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); + // Listen to editor updates and show suggestions when '/' is typed useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { @@ -49,6 +58,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> setSelectedIndex(0); } + // Calculate popup position to keep it within viewport bounds if (shouldShow) { const domSelection = window.getSelection(); if (domSelection && domSelection.rangeCount > 0) { @@ -84,9 +94,22 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> }); }, [editor]); + // Register command to close autocomplete popup + useEffect(() => { + return editor.registerCommand( + CLOSE_AUTOCOMPLETE_COMMAND, + () => { + setShowSuggestions(false); + return true; + }, + COMMAND_PRIORITY_HIGH + ); + }, [editor]); + + // Insert selected suggestion into editor const insertMention = (suggestion: Suggestion) => { if (enableJinja2) { - // 在jinja2模式下,插入{{variable}}格式的文本 + // In Jinja2 mode, insert {{variable}} format text editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { @@ -94,7 +117,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> const anchorOffset = selection.anchor.offset; const nodeText = anchorNode.getTextContent(); - // 移除触发字符'/' + // Remove trigger character '/' const textBefore = nodeText.substring(0, anchorOffset - 1); const textAfter = nodeText.substring(anchorOffset); const newText = textBefore + `{{${suggestion.value}}}` + textAfter; @@ -103,19 +126,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> anchorNode.setTextContent(newText); } - // 设置光标位置到插入文本之后 + // Set cursor position after inserted text const newOffset = textBefore.length + `{{${suggestion.value}}}`.length; selection.anchor.offset = newOffset; selection.focus.offset = newOffset; } }); } else { - // 普通模式下使用VariableNode + // In normal mode, use VariableNode editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); } setShowSuggestions(false); }; + // Group suggestions by node ID const groupedSuggestions = options.reduce((groups: Record, suggestion) => { const { nodeData } = suggestion const nodeId = nodeData.id as string; @@ -126,6 +150,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> return groups; }, {}); + // Handle Enter key to select suggestion useEffect(() => { if (!showSuggestions) return; @@ -148,11 +173,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> ); }, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]); + // Handle keyboard navigation (Arrow Up/Down, Escape) useEffect(() => { if (!showSuggestions) return; const allOptions = Object.values(groupedSuggestions).flat(); + // Navigate down through suggestions, skip disabled items const unregisterArrowDown = editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => { @@ -172,6 +199,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> COMMAND_PRIORITY_HIGH ); + // Navigate up through suggestions, skip disabled items const unregisterArrowUp = editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => { @@ -191,6 +219,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> COMMAND_PRIORITY_HIGH ); + // Close suggestions on Escape key const unregisterEscape = editor.registerCommand( KEY_ESCAPE_COMMAND, (event) => { @@ -239,7 +268,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; return (
+ {/* Divider between groups */} {groupIndex > 0 &&
} + {/* Group header with node name */}
{nodeName}
diff --git a/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx index 0fb6c48f..13eb48b6 100644 --- a/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/BlurPlugin.tsx @@ -1,39 +1,64 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-01-20 10:42:13 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-03 10:12:10 + */ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useEffect } from 'react'; import { $setSelection } from 'lexical'; +import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; -export default function BlurPlugin() { +// Plugin to handle blur events and close autocomplete when clicking outside +export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) { const [editor] = useLexicalComposerContext(); useEffect(() => { + // Close autocomplete when clicking outside the popup + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target?.closest('[data-autocomplete-popup="true"]')) { + return; + } + editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined); + }; + + document.addEventListener('mousedown', handleClickOutside); + return editor.registerRootListener((rootElement) => { if (rootElement) { const handleBlur = (e: FocusEvent) => { - // 检查是否点击了自动完成弹窗 - const target = e.target as HTMLElement; - console.log('target', target) - if (target?.closest('[data-autocomplete-popup="true"]')) { - return; + if (enableJinja2) { + // Check if autocomplete popup was clicked + const target = e.target as HTMLElement; + if (target?.closest('[data-autocomplete-popup="true"]')) { + return; + } + + // Check if blur was caused by paste operation + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget || relatedTarget === document.body) { + return; + } + + // Clear selection on blur + editor.update(() => { + $setSelection(null); + }); } - - // 检查是否是粘贴操作导致的焦点变化 - const relatedTarget = e.relatedTarget as HTMLElement; - if (!relatedTarget || relatedTarget === document.body) { - return; - } - - editor.update(() => { - $setSelection(null); - }); }; rootElement.addEventListener('blur', handleBlur); return () => { + document.removeEventListener('mousedown', handleClickOutside); rootElement.removeEventListener('blur', handleBlur); }; } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; }); - }, [editor]); + }, [editor, enableJinja2]); return null; } diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx index ead15759..74593913 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx @@ -49,7 +49,7 @@ const EditableTable: FC = ({ const getColumns = (remove: (index: number) => void): TableProps['columns'] => { const hasType = typeOptions.length > 0; const cellClassName="rb:p-1!" - const contentClassName ="rb:w-[108px]! rb:text-[12px]!" + const contentClassName ="rb:w-[108px]! rb:text-[12px]! rb:overflow-hidden!" return [ {