diff --git a/frontend/package.json b/frontend/package.json index abdeeed9..f15508e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,13 @@ "dependencies": { "@ant-design/icons": "^5.2.6", "@ant-design/pro-components": "^2.8.2", + "@codemirror/autocomplete": "^6.19.1", + "@codemirror/commands": "^6.10.0", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.11.3", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.6", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e9840e51..05d20041 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,27 @@ importers: '@ant-design/pro-components': specifier: ^2.8.2 version: 2.8.2(antd@5.27.5(date-fns@2.30.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(rc-field-form@2.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@codemirror/autocomplete': + specifier: ^6.19.1 + version: 6.19.1 + '@codemirror/commands': + specifier: ^6.10.0 + version: 6.10.0 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/language': + specifier: ^6.11.3 + version: 6.11.3 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.38.6 + version: 6.38.6 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -490,6 +511,30 @@ packages: peerDependencies: react: '>=16.12.0' + '@codemirror/autocomplete@6.19.1': + resolution: {integrity: sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==} + + '@codemirror/commands@6.10.0': + resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/language@6.11.3': + resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} + + '@codemirror/lint@6.9.2': + resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} + + '@codemirror/state@6.5.2': + resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.38.6': + resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} + '@ctrl/tinycolor@3.6.1': resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} @@ -824,6 +869,21 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@lezer/common@1.3.0': + resolution: {integrity: sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@monaco-editor/loader@1.4.0': resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} peerDependencies: @@ -2474,6 +2534,9 @@ packages: create-react-class@15.7.0: resolution: {integrity: sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4001,6 +4064,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -4197,6 +4263,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} @@ -4666,6 +4735,63 @@ snapshots: reactcss: 1.2.3(react@18.3.1) tinycolor2: 1.6.0 + '@codemirror/autocomplete@6.19.1': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + '@lezer/common': 1.3.0 + + '@codemirror/commands@6.10.0': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + '@lezer/common': 1.3.0 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.19.1 + '@codemirror/language': 6.11.3 + '@codemirror/lint': 6.9.2 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + '@lezer/common': 1.3.0 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.11.3': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + '@lezer/common': 1.3.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.2 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.2': + dependencies: + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + crelt: 1.0.6 + + '@codemirror/state@6.5.2': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.6 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.38.6': + dependencies: + '@codemirror/state': 6.5.2 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@ctrl/tinycolor@3.6.1': {} '@dnd-kit/accessibility@3.1.1(react@18.3.1)': @@ -4975,6 +5101,24 @@ snapshots: '@juggle/resize-observer@3.4.0': {} + '@lezer/common@1.3.0': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.3.0 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.3.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.2 + + '@lezer/lr@1.4.2': + dependencies: + '@lezer/common': 1.3.0 + + '@marijn/find-cluster-break@1.0.2': {} + '@monaco-editor/loader@1.4.0(monaco-editor@0.52.2)': dependencies: monaco-editor: 0.52.2 @@ -6857,6 +7001,8 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8508,6 +8654,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.3: {} + stylis@4.2.0: {} stylis@4.3.4: {} @@ -8692,6 +8840,8 @@ snapshots: void-elements@3.1.0: {} + w3c-keyname@2.2.8: {} + warning@4.0.3: dependencies: loose-envify: 1.4.0 diff --git a/frontend/src/components/CodeMirrorVariableInput/index.tsx b/frontend/src/components/CodeMirrorVariableInput/index.tsx new file mode 100644 index 00000000..42626fe2 --- /dev/null +++ b/frontend/src/components/CodeMirrorVariableInput/index.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useRef } from 'react'; +import { EditorView, keymap, placeholder as placeholderExtension, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'; +import { EditorState, Compartment, Range } from '@codemirror/state'; +import { autocompletion, CompletionContext } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { bracketMatching, indentOnInput, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { javascript } from '@codemirror/lang-javascript'; +import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types'; +import type { FormField } from './types'; +import { collectNodeVariables } from '@/utils/workflow/collectNodeVariables'; + +// 🎨 变量高亮装饰器(简洁方案:整体高亮) +const variableMark = Decoration.mark({ + class: 'cm-variable-highlight' +}); + +// 🔍 匹配 ${...} 变量的正则表达式 +const variableRegex = /\$\{[^}]*\}/g; + +// 🎨 变量高亮插件 +const variableHighlighter = ViewPlugin.fromClass(class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + const text = view.state.doc.toString(); + let match: RegExpExecArray | null; + + // 重置正则的 lastIndex + variableRegex.lastIndex = 0; + + while ((match = variableRegex.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + + // 整体高亮(避免空范围错误) + if (end > start) { + decorations.push(variableMark.range(start, end)); + } + } + + return Decoration.set(decorations); + } +}, { + decorations: v => v.decorations +}); + +export interface CodeMirrorVariableInputProps { + // 基本属性 + value: string | number | undefined; + onChange: (value: string | number | undefined) => void; + placeholder?: string; + disabled?: boolean; + + // 变量配置 + allNodes: FlowNode[]; + allEdges: FlowEdge[]; + currentNodeId: string; + formFields?: FormField[]; + + // 样式配置 + variant?: 'input' | 'textarea'; + rows?: number; + theme?: 'light' | 'dark'; + + // 高级配置 + /** 是否自动转换数字类型(默认 false)*/ + autoConvertNumber?: boolean; + /** 当用户正在编辑时的回调 */ + onEditingChange?: (isEditing: boolean) => void; +} + +/** + * CodeMirror 变量输入组件 + * 使用 CodeMirror 6 实现变量自动补全和语法高亮 + * + * 内部功能: + * - 自动处理空值转换('' -> undefined) + * - 可选的数字类型自动转换 + * - 变量表达式检测(包含 ${} 时保持字符串类型) + * - 编辑状态追踪 + */ +export const CodeMirrorVariableInput: React.FC = ({ + value, + onChange, + placeholder = '', + disabled = false, + allNodes, + allEdges, + currentNodeId, + formFields = [], + variant = 'input', + rows = 3, + theme = 'light', + autoConvertNumber = false, + onEditingChange +}) => { + const editorRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + const onEditingChangeRef = useRef(onEditingChange); + const editingTimeoutRef = useRef(null); + + // 同步 onChange 和 onEditingChange 引用 + useEffect(() => { + onChangeRef.current = onChange; + onEditingChangeRef.current = onEditingChange; + }, [onChange, onEditingChange]); + + // 智能值转换处理 + const handleValueChange = (newValue: string) => { + // 通知外部:用户开始编辑 + onEditingChangeRef.current?.(true); + + // 清除之前的定时器 + if (editingTimeoutRef.current) { + clearTimeout(editingTimeoutRef.current); + } + + // 300ms 后通知编辑结束 + editingTimeoutRef.current = window.setTimeout(() => { + onEditingChangeRef.current?.(false); + }, 300); + + // 值转换逻辑 + if (newValue === '') { + // 空值转换为 undefined + onChangeRef.current(undefined); + } else if (newValue.includes('${')) { + // 包含变量语法,保持字符串类型 + onChangeRef.current(newValue); + } else if (autoConvertNumber) { + // 自动转换数字(如果启用) + const numValue = Number(newValue); + onChangeRef.current(isNaN(numValue) ? newValue : numValue); + } else { + // 普通字符串 + onChangeRef.current(newValue); + } + }; + + // 收集所有可用变量 + const allVariables = React.useMemo(() => { + // 表单字段变量 + const formVariables = formFields.map(field => ({ + nodeId: 'form', + nodeName: '启动表单', + fieldName: field.name, + fieldType: field.type, + displayText: `\${form.${field.name}}`, + fullText: `form.${field.name}`, + })); + + // 节点变量 + const nodeVariables = collectNodeVariables(currentNodeId, allNodes, allEdges); + + return [...formVariables, ...nodeVariables]; + }, [formFields, currentNodeId, allNodes, allEdges]); + + // 使用 ref 存储变量,避免重新创建编辑器 + const allVariablesRef = useRef(allVariables); + + useEffect(() => { + allVariablesRef.current = allVariables; + }, [allVariables]); + + // 变量自动补全函数 + const variableCompletion = React.useCallback((context: CompletionContext) => { + const word = context.matchBefore(/\$\{[^}]*/); + + if (!word) return null; + + // 如果不是以 ${ 开头,不显示补全 + if (!word.text.startsWith('${')) return null; + + // 提取搜索文本(去掉 ${) + const searchText = word.text.slice(2).toLowerCase(); + + // 过滤变量(使用 ref 中的最新值) + const filteredVariables = allVariablesRef.current.filter(v => + v.fullText.toLowerCase().includes(searchText) || + v.nodeName.toLowerCase().includes(searchText) || + v.fieldName.toLowerCase().includes(searchText) + ); + + return { + from: word.from, + options: filteredVariables.map(v => ({ + label: v.displayText, + type: 'variable', + info: `${v.nodeName} - ${v.fieldName}`, + apply: v.displayText, + })), + }; + }, []); + + // 初始化 CodeMirror + useEffect(() => { + if (!editorRef.current) return; + + // 创建主题 Compartment(允许动态切换) + const themeCompartment = new Compartment(); + + // 基础扩展(替代 basicSetup) + const basicExtensions = [ + history(), + indentOnInput(), + bracketMatching(), + // ❌ 移除 closeBrackets() - 避免输入 ${ 时自动补全 } + // ❌ 移除 closeBracketsKeymap - 避免自动关闭括号 + syntaxHighlighting(defaultHighlightStyle), + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ]), + ]; + + // 创建 EditorState + const state = EditorState.create({ + doc: String(value || ''), + extensions: [ + ...basicExtensions, + // 🎨 变量高亮插件(必须在主题之前) + variableHighlighter, + // 根据 variant 设置高度 + EditorView.theme({ + '&': { + height: variant === 'input' ? '38px' : `${rows * 24}px`, + fontSize: '14px', + border: '1px solid hsl(var(--border))', + borderRadius: 'calc(var(--radius) - 2px)', + }, + '&.cm-focused': { + outline: '2px solid hsl(var(--ring))', + outlineOffset: '2px', + }, + '.cm-scroller': { + overflow: variant === 'input' ? 'hidden' : 'auto', + fontFamily: 'inherit', + }, + '.cm-content': { + padding: variant === 'input' ? '8px 12px' : '12px', + caretColor: 'hsl(var(--foreground))', + }, + '.cm-line': { + padding: 0, + }, + '&.cm-editor.cm-focused': { + outline: 'none', + }, + '.cm-placeholder': { + color: 'hsl(var(--muted-foreground))', + }, + // 🎨 变量高亮样式(简洁方案) + '.cm-variable-highlight': { + color: '#2563eb', // 蓝色高亮 + fontWeight: '600', + backgroundColor: '#dbeafe', // 浅蓝色背景 + padding: '1px 2px', + borderRadius: '2px', + } + }), + // 主题 + themeCompartment.of(theme === 'dark' ? oneDark : []), + // JavaScript 语法高亮(用于高亮变量语法) + javascript(), + // 自动补全 + autocompletion({ + override: [variableCompletion], + closeOnBlur: false, + }), + // placeholder + placeholder ? placeholderExtension(placeholder) : [], + // 值变化监听 + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const newValue = update.state.doc.toString(); + handleValueChange(newValue); + } + }), + // 禁用状态 + EditorView.editable.of(!disabled), + EditorState.readOnly.of(disabled), + // 单行输入模式 + ...(variant === 'input' ? [ + EditorState.transactionFilter.of((tr) => { + // 阻止换行 + if (tr.newDoc.lines > 1) { + return []; + } + return tr; + }) + ] : []), + ], + }); + + // 创建 EditorView + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + // 清理函数 + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [variant, rows, theme, disabled, placeholder, variableCompletion]); + + // 当外部 value 变化时,更新编辑器内容 + useEffect(() => { + if (!viewRef.current) return; + + const stringValue = String(value || ''); + const currentValue = viewRef.current.state.doc.toString(); + if (currentValue !== stringValue) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: currentValue.length, + insert: stringValue, + }, + }); + } + }, [value]); + + return ( +
+ ); +}; + +export default CodeMirrorVariableInput; + diff --git a/frontend/src/components/VariableInput/types.ts b/frontend/src/components/CodeMirrorVariableInput/types.ts similarity index 100% rename from frontend/src/components/VariableInput/types.ts rename to frontend/src/components/CodeMirrorVariableInput/types.ts diff --git a/frontend/src/components/SelectOrVariableInput/index.tsx b/frontend/src/components/SelectOrVariableInput/index.tsx index bbbad2c9..0a55d041 100644 --- a/frontend/src/components/SelectOrVariableInput/index.tsx +++ b/frontend/src/components/SelectOrVariableInput/index.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import VariableInput from '@/components/VariableInput'; +import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput'; import { Button } from '@/components/ui/button'; import { ArrowLeftRight } from 'lucide-react'; import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types'; -import type { FormField } from '@/components/VariableInput/types'; +import type { FormField } from '@/components/CodeMirrorVariableInput/types'; export interface SelectOrVariableInputProps { // 基本属性 @@ -44,7 +44,7 @@ export const SelectOrVariableInput: React.FC = ({ allNodes, allEdges, currentNodeId, - formFields + formFields, }) => { // 判断当前值是否为变量 const isVariableValue = (val: any): boolean => { @@ -62,9 +62,12 @@ export const SelectOrVariableInput: React.FC = ({ // 是否是手动切换(防止自动模式识别覆盖手动切换) const isManualToggleRef = useRef(false); - // 同步外部 value 到内部 internalValue(仅在非手动切换时) + // 是否正在编辑(防止用户输入时被外部值覆盖) + const isUserEditingRef = useRef(false); + + // 同步外部 value 到内部 internalValue(仅在非手动切换且非用户编辑时) useEffect(() => { - if (!isManualToggleRef.current) { + if (!isManualToggleRef.current && !isUserEditingRef.current) { setInternalValue(value); // 自动模式识别 @@ -127,25 +130,15 @@ export const SelectOrVariableInput: React.FC = ({ ) : ( - // 变量输入模式 - { - // 更新内部值 - setInternalValue(v || undefined); - - // 如果包含变量语法(${),认为是变量表达式 - if (v.includes('${')) { - // 变量表达式始终保持字符串类型 - onChange(v); - } else if (v === '') { - // 空值传递 undefined,符合表单 number 类型验证 - onChange(undefined as any); - } else { - // 普通文本:尝试转换为数字 - const numValue = Number(v); - onChange(isNaN(numValue) ? v : numValue); - } + setInternalValue(v); + onChange(v as any); + }} + onEditingChange={(isEditing) => { + isUserEditingRef.current = isEditing; }} allNodes={allNodes} allEdges={allEdges} @@ -153,6 +146,8 @@ export const SelectOrVariableInput: React.FC = ({ formFields={formFields} placeholder={placeholder} disabled={disabled} + variant="input" + autoConvertNumber={true} /> )}
diff --git a/frontend/src/components/VariableInput/HighlightLayer.tsx b/frontend/src/components/VariableInput/HighlightLayer.tsx deleted file mode 100644 index eae4a17e..00000000 --- a/frontend/src/components/VariableInput/HighlightLayer.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useMemo } from 'react'; - -interface HighlightLayerProps { - value: string; - className?: string; -} - -/** - * 变量高亮层 - * 将文本中的变量表达式高亮显示 - */ -const HighlightLayer: React.FC = ({ value, className }) => { - // 正则匹配 ${xxx.yyy} 格式的变量 - const VARIABLE_PATTERN = /\$\{([^}]+)\}/g; - - // 解析文本,分离普通文本和变量 - const highlightedContent = useMemo(() => { - if (!value) return null; - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - - // 重置正则表达式的 lastIndex - VARIABLE_PATTERN.lastIndex = 0; - - while ((match = VARIABLE_PATTERN.exec(value)) !== null) { - const matchStart = match.index; - const matchEnd = matchStart + match[0].length; - - // 添加变量前的普通文本 - if (matchStart > lastIndex) { - parts.push( - - {value.substring(lastIndex, matchStart)} - - ); - } - - // 添加高亮的变量 - // 关键:不使用 padding/margin/border,只使用背景色和下划线 - // 不改变字体、字重,保持和输入框完全一致 - parts.push( - - {match[0]} - - ); - - lastIndex = matchEnd; - } - - // 添加最后一段普通文本 - if (lastIndex < value.length) { - parts.push( - - {value.substring(lastIndex)} - - ); - } - - return parts.length > 0 ? parts : value; - }, [value]); - - return ( -
- {highlightedContent} -
- ); -}; - -export default HighlightLayer; - diff --git a/frontend/src/components/VariableInput/deleteHelper.ts b/frontend/src/components/VariableInput/deleteHelper.ts deleted file mode 100644 index f74987fa..00000000 --- a/frontend/src/components/VariableInput/deleteHelper.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 变量删除辅助函数 - * 处理变量的整体删除逻辑 - */ - -const VARIABLE_PATTERN = /\$\{([^}]+)\}/g; - -/** - * 查找光标位置所在的变量范围 - * @param text 文本 - * @param cursorPos 光标位置 - * @returns 变量范围 { start, end } 或 null - */ -export const findVariableAtCursor = ( - text: string, - cursorPos: number -): { start: number; end: number } | null => { - // 重置正则表达式 - VARIABLE_PATTERN.lastIndex = 0; - - let match: RegExpExecArray | null; - while ((match = VARIABLE_PATTERN.exec(text)) !== null) { - const start = match.index; - const end = start + match[0].length; - - // 检查光标是否在变量内部或紧邻变量边界 - if (cursorPos > start && cursorPos <= end) { - return { start, end }; - } - } - - return null; -}; - -/** - * 处理 Backspace 键删除 - * @param text 文本 - * @param selectionStart 选区起始位置 - * @param selectionEnd 选区结束位置 - * @returns 新文本和新光标位置,或 null(使用默认行为) - */ -export const handleBackspaceDelete = ( - text: string, - selectionStart: number, - selectionEnd: number -): { newText: string; newCursorPos: number } | null => { - // 如果有选区,使用默认行为 - if (selectionStart !== selectionEnd) { - return null; - } - - // 检查光标前一个字符是否是变量的结束符 '}' - if (selectionStart > 0 && text[selectionStart - 1] === '}') { - // 查找这个 '}' 对应的变量 - const variable = findVariableAtCursor(text, selectionStart); - if (variable) { - // 删除整个变量 - const newText = text.substring(0, variable.start) + text.substring(variable.end); - return { - newText, - newCursorPos: variable.start - }; - } - } - - // 使用默认行为 - return null; -}; - -/** - * 处理 Delete 键删除 - * @param text 文本 - * @param selectionStart 选区起始位置 - * @param selectionEnd 选区结束位置 - * @returns 新文本和新光标位置,或 null(使用默认行为) - */ -export const handleDeleteKey = ( - text: string, - selectionStart: number, - selectionEnd: number -): { newText: string; newCursorPos: number } | null => { - // 如果有选区,使用默认行为 - if (selectionStart !== selectionEnd) { - return null; - } - - // 检查光标后一个字符是否是变量的开始符 '$' - if (selectionStart < text.length && text[selectionStart] === '$' && text[selectionStart + 1] === '{') { - // 查找这个变量 - const variable = findVariableAtCursor(text, selectionStart + 1); - if (variable) { - // 删除整个变量 - const newText = text.substring(0, variable.start) + text.substring(variable.end); - return { - newText, - newCursorPos: variable.start - }; - } - } - - // 使用默认行为 - return null; -}; - diff --git a/frontend/src/components/VariableInput/index.tsx b/frontend/src/components/VariableInput/index.tsx deleted file mode 100644 index 8b05ac46..00000000 --- a/frontend/src/components/VariableInput/index.tsx +++ /dev/null @@ -1,542 +0,0 @@ -import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import type { VariableInputProps } from './types'; -import { detectTrigger, insertVariable, filterVariables } from './utils'; -import { collectNodeVariables, groupVariablesByNode } from '@/utils/workflow/collectNodeVariables'; -import HighlightLayer from './HighlightLayer'; -import { handleBackspaceDelete, handleDeleteKey } from './deleteHelper'; - -/** - * 变量输入组件 - * 支持在输入 ${ 时自动提示可用变量 - */ -const VariableInput: React.FC = ({ - value = '', - onChange, - allNodes, - allEdges, - currentNodeId, - formFields = [], - variant = 'input', - placeholder, - disabled = false, - rows = 3, -}) => { - const [showSuggestions, setShowSuggestions] = useState(false); - const [triggerInfo, setTriggerInfo] = useState({ shouldShow: false, startPos: -1, searchText: '' }); - const [selectedIndex, setSelectedIndex] = useState(0); // 当前选中的变量索引 - const [dropdownPosition, setDropdownPosition] = useState<'bottom' | 'top'>('bottom'); // 下拉框位置 - const inputRef = useRef(null); - const containerRef = useRef(null); - const dropdownRef = useRef(null); - const highlightRef = useRef(null); - - // 将表单字段转换为变量格式(匹配 NodeVariable 结构) - const formVariables = useMemo(() => { - return formFields.map(field => ({ - nodeId: 'form', - nodeName: '启动表单', - fieldName: field.name, - fieldType: field.type, - displayText: `\${form.${field.name}}`, - fullText: `form.${field.name}`, - })); - }, [formFields]); - - // 收集可用变量(节点变量 + 表单变量) - const allVariables = useMemo(() => { - const nodeVariables = collectNodeVariables(currentNodeId, allNodes, allEdges); - return [...formVariables, ...nodeVariables]; - }, [currentNodeId, allNodes, allEdges, formVariables]); - - // 根据搜索文本过滤变量 - const filteredVariables = useMemo(() => { - return filterVariables(allVariables, triggerInfo.searchText); - }, [allVariables, triggerInfo.searchText]); - - // 按节点分组 - const groupedVariables = useMemo(() => { - return groupVariablesByNode(filteredVariables); - }, [filteredVariables]); - - // 计算下拉框的位置 - const getDropdownStyle = useCallback(() => { - if (!inputRef.current) return {}; - - const inputRect = inputRef.current.getBoundingClientRect(); - const gap = 8; // 间距 - - if (dropdownPosition === 'top') { - // 显示在上方 - return { - bottom: window.innerHeight - inputRect.top + window.scrollY + gap, - left: inputRect.left + window.scrollX, - width: inputRect.width, - }; - } else { - // 显示在下方 - return { - top: inputRect.bottom + window.scrollY + gap, - left: inputRect.left + window.scrollX, - width: inputRect.width, - }; - } - }, [dropdownPosition]); - - // 点击外部关闭建议框 - useEffect(() => { - if (!showSuggestions) return; - - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - - // 检查是否点击了输入框或下拉列表 - const isClickInside = - (containerRef.current && containerRef.current.contains(target)) || - (dropdownRef.current && dropdownRef.current.contains(target)); - - if (!isClickInside) { - setShowSuggestions(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showSuggestions]); - - // 计算下拉框位置(上方或下方) - useEffect(() => { - if (!showSuggestions || !inputRef.current || !dropdownRef.current) return; - - const inputRect = inputRef.current.getBoundingClientRect(); - const dropdownHeight = 320; // 估算的下拉框高度 - const spaceBelow = window.innerHeight - inputRect.bottom; - const spaceAbove = inputRect.top; - - // 如果下方空间不足,且上方空间更多,则显示在上方 - if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { - setDropdownPosition('top'); - } else { - setDropdownPosition('bottom'); - } - }, [showSuggestions]); - - // 当显示建议时,重置选中索引 - useEffect(() => { - if (showSuggestions) { - setSelectedIndex(0); - } - }, [showSuggestions]); - - // 同步输入框和高亮层的样式 - useEffect(() => { - if (!inputRef.current || !highlightRef.current) return; - - const inputElement = inputRef.current; - const highlightElement = highlightRef.current; - - const syncStyles = () => { - if (!inputElement || !highlightElement) return; - - const inputStyles = window.getComputedStyle(inputElement); - - // 复制所有影响文本定位的样式(精确同步) - highlightElement.style.paddingTop = inputStyles.paddingTop; - highlightElement.style.paddingRight = inputStyles.paddingRight; - highlightElement.style.paddingBottom = inputStyles.paddingBottom; - highlightElement.style.paddingLeft = inputStyles.paddingLeft; - highlightElement.style.fontSize = inputStyles.fontSize; - highlightElement.style.fontFamily = inputStyles.fontFamily; - highlightElement.style.fontWeight = inputStyles.fontWeight; - highlightElement.style.lineHeight = inputStyles.lineHeight; - highlightElement.style.letterSpacing = inputStyles.letterSpacing; - highlightElement.style.wordSpacing = inputStyles.wordSpacing; - highlightElement.style.whiteSpace = variant === 'textarea' ? 'pre-wrap' : 'nowrap'; - highlightElement.style.wordWrap = 'break-word'; - highlightElement.style.overflowWrap = inputStyles.overflowWrap; - highlightElement.style.verticalAlign = inputStyles.verticalAlign; - highlightElement.style.boxSizing = inputStyles.boxSizing; - - // 关键:同步文本对齐属性,确保光标位置准确 - highlightElement.style.textAlign = inputStyles.textAlign; - highlightElement.style.direction = inputStyles.direction; - highlightElement.style.textIndent = inputStyles.textIndent; - }; - - // 同步滚动位置(仅 textarea) - const syncScroll = () => { - if (!inputElement || !highlightElement || variant !== 'textarea') return; - - highlightElement.scrollTop = inputElement.scrollTop; - highlightElement.scrollLeft = inputElement.scrollLeft; - }; - - // 初始同步(多次延迟确保 DOM 完全渲染,包括 Modal/Sheet 的样式应用) - syncStyles(); - setTimeout(syncStyles, 0); - setTimeout(syncStyles, 10); - setTimeout(syncStyles, 50); - setTimeout(syncStyles, 100); - - // 监听窗口大小变化(可能影响字体大小) - const resizeObserver = new ResizeObserver(syncStyles); - resizeObserver.observe(inputElement); - - // 监听父元素属性变化(例如 Modal/Sheet 的显示状态) - const mutationObserver = new MutationObserver(syncStyles); - let parent = inputElement.parentElement; - while (parent && parent !== document.body) { - mutationObserver.observe(parent, { - attributes: true, - attributeFilter: ['class', 'style'], - }); - parent = parent.parentElement; - } - - // 监听滚动事件(仅 textarea) - if (variant === 'textarea') { - inputElement.addEventListener('scroll', syncScroll); - } - - return () => { - resizeObserver.disconnect(); - mutationObserver.disconnect(); - if (variant === 'textarea') { - inputElement.removeEventListener('scroll', syncScroll); - } - }; - // ✅ 性能优化:移除 value 依赖,避免每次输入都重新同步样式 - // ResizeObserver 会自动处理元素大小变化 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [variant]); - - // 当 value 变化时,确保样式同步(特别是首次输入时) - useEffect(() => { - if (!inputRef.current || !highlightRef.current || !value) return; - - const syncCriticalStyles = () => { - const inputElement = inputRef.current; - const highlightContainer = highlightRef.current; - if (!inputElement || !highlightContainer) return; - - const inputStyles = window.getComputedStyle(inputElement); - - // 同步关键样式 - highlightContainer.style.paddingTop = inputStyles.paddingTop; - highlightContainer.style.paddingBottom = inputStyles.paddingBottom; - highlightContainer.style.paddingLeft = inputStyles.paddingLeft; - highlightContainer.style.paddingRight = inputStyles.paddingRight; - highlightContainer.style.lineHeight = inputStyles.lineHeight; - highlightContainer.style.fontSize = inputStyles.fontSize; - highlightContainer.style.fontFamily = inputStyles.fontFamily; - }; - - // 多次延迟确保样式完全应用 - syncCriticalStyles(); - const timer1 = setTimeout(syncCriticalStyles, 0); - const timer2 = setTimeout(syncCriticalStyles, 10); - const timer3 = setTimeout(syncCriticalStyles, 50); - - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - clearTimeout(timer3); - }; - }, [value]); - - // 监听滚动和窗口大小变化,更新下拉框位置 - useEffect(() => { - if (!showSuggestions) return; - - const handlePositionUpdate = () => { - // 强制重新渲染以更新位置 - if (dropdownRef.current) { - const style = getDropdownStyle(); - Object.assign(dropdownRef.current.style, style); - } - }; - - window.addEventListener('scroll', handlePositionUpdate, true); - window.addEventListener('resize', handlePositionUpdate); - - return () => { - window.removeEventListener('scroll', handlePositionUpdate, true); - window.removeEventListener('resize', handlePositionUpdate); - }; - }, [showSuggestions, getDropdownStyle]); - - // 处理输入变化 - const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - const cursorPos = e.target.selectionStart || 0; - - onChange(newValue); - - // 检测是否应该触发变量建议 - const trigger = detectTrigger(newValue, cursorPos); - setTriggerInfo(trigger); - setShowSuggestions(trigger.shouldShow && !disabled); - }; - - // 处理键盘事件 - const handleKeyDown = (e: React.KeyboardEvent) => { - const target = e.target as HTMLInputElement | HTMLTextAreaElement; - - // 处理变量整体删除(Backspace) - if (e.key === 'Backspace' && !showSuggestions) { - const result = handleBackspaceDelete( - value, - target.selectionStart || 0, - target.selectionEnd || 0 - ); - - if (result) { - e.preventDefault(); - onChange(result.newText); - - // 恢复光标位置 - setTimeout(() => { - if (inputRef.current) { - inputRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); - } - }, 0); - return; - } - } - - // 处理变量整体删除(Delete) - if (e.key === 'Delete' && !showSuggestions) { - const result = handleDeleteKey( - value, - target.selectionStart || 0, - target.selectionEnd || 0 - ); - - if (result) { - e.preventDefault(); - onChange(result.newText); - - // 恢复光标位置 - setTimeout(() => { - if (inputRef.current) { - inputRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); - } - }, 0); - return; - } - } - - // 建议框快捷键 - if (!showSuggestions) return; - - switch (e.key) { - case 'Escape': - // Esc 键关闭建议框 - setShowSuggestions(false); - e.preventDefault(); - break; - - case 'ArrowDown': - // ↓ 键向下选择 - e.preventDefault(); - setSelectedIndex(prev => - prev < filteredVariables.length - 1 ? prev + 1 : prev - ); - break; - - case 'ArrowUp': - // ↑ 键向上选择 - e.preventDefault(); - setSelectedIndex(prev => prev > 0 ? prev - 1 : prev); - break; - - case 'Enter': - // Enter 键选中当前变量 - e.preventDefault(); - if (filteredVariables[selectedIndex]) { - const variable = filteredVariables[selectedIndex]; - handleSelectVariable(variable.nodeName, variable.fieldName); - } - break; - } - }; - - // 插入变量 - const handleSelectVariable = (nodeName: string, fieldName: string) => { - if (!inputRef.current) return; - - const cursorPos = inputRef.current.selectionStart || 0; - - // 使用工具函数插入变量 - const { newText, newCursorPos } = insertVariable( - value, - cursorPos, - triggerInfo.startPos, - nodeName, - fieldName - ); - - // 更新值 - onChange(newText); - - // 关闭建议框 - setShowSuggestions(false); - - // 恢复焦点并设置光标位置 - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.setSelectionRange(newCursorPos, newCursorPos); - } - }, 0); - }; - - // 渲染输入框 - const renderInput = () => { - const baseStyle = { - // 关键:使用 WebKit 专属属性使文字完全透明,但保留光标和选区 - WebkitTextFillColor: 'transparent', - color: 'transparent', // 降级方案 - caretColor: 'hsl(var(--foreground))', - // 保留选区高亮 - WebkitUserSelect: 'text', - userSelect: 'text', - } as React.CSSProperties; - - // 只有有值时才设置透明,这样 placeholder 可以正常显示 - const commonProps = { - ref: inputRef as any, - value, - onChange: handleChange, - onKeyDown: handleKeyDown, - placeholder, - disabled, - className: 'variable-input', - style: value ? baseStyle : { caretColor: 'hsl(var(--foreground))' }, - }; - - if (variant === 'input') { - return ; - } - - return