更换变量显示组件

This commit is contained in:
dengqichen 2025-11-03 17:59:39 +08:00
parent ae2690899f
commit 834996cff8
11 changed files with 536 additions and 892 deletions

View File

@ -12,6 +12,13 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.2.6", "@ant-design/icons": "^5.2.6",
"@ant-design/pro-components": "^2.8.2", "@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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View File

@ -14,6 +14,27 @@ importers:
'@ant-design/pro-components': '@ant-design/pro-components':
specifier: ^2.8.2 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) 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': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.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: peerDependencies:
react: '>=16.12.0' 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': '@ctrl/tinycolor@3.6.1':
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -824,6 +869,21 @@ packages:
'@juggle/resize-observer@3.4.0': '@juggle/resize-observer@3.4.0':
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} 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': '@monaco-editor/loader@1.4.0':
resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==}
peerDependencies: peerDependencies:
@ -2474,6 +2534,9 @@ packages:
create-react-class@15.7.0: create-react-class@15.7.0:
resolution: {integrity: sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==} resolution: {integrity: sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -4001,6 +4064,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
stylis@4.2.0: stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
@ -4197,6 +4263,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
warning@4.0.3: warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
@ -4666,6 +4735,63 @@ snapshots:
reactcss: 1.2.3(react@18.3.1) reactcss: 1.2.3(react@18.3.1)
tinycolor2: 1.6.0 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': {} '@ctrl/tinycolor@3.6.1': {}
'@dnd-kit/accessibility@3.1.1(react@18.3.1)': '@dnd-kit/accessibility@3.1.1(react@18.3.1)':
@ -4975,6 +5101,24 @@ snapshots:
'@juggle/resize-observer@3.4.0': {} '@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)': '@monaco-editor/loader@1.4.0(monaco-editor@0.52.2)':
dependencies: dependencies:
monaco-editor: 0.52.2 monaco-editor: 0.52.2
@ -6857,6 +7001,8 @@ snapshots:
loose-envify: 1.4.0 loose-envify: 1.4.0
object-assign: 4.1.1 object-assign: 4.1.1
crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -8508,6 +8654,8 @@ snapshots:
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
stylis@4.2.0: {} stylis@4.2.0: {}
stylis@4.3.4: {} stylis@4.3.4: {}
@ -8692,6 +8840,8 @@ snapshots:
void-elements@3.1.0: {} void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
warning@4.0.3: warning@4.0.3:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0

View File

@ -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<Decoration>[] = [];
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<CodeMirrorVariableInputProps> = ({
value,
onChange,
placeholder = '',
disabled = false,
allNodes,
allEdges,
currentNodeId,
formFields = [],
variant = 'input',
rows = 3,
theme = 'light',
autoConvertNumber = false,
onEditingChange
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onEditingChangeRef = useRef(onEditingChange);
const editingTimeoutRef = useRef<number | null>(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 (
<div
ref={editorRef}
className="codemirror-variable-input"
style={{
opacity: disabled ? 0.5 : 1,
pointerEvents: disabled ? 'none' : 'auto',
}}
/>
);
};
export default CodeMirrorVariableInput;

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { Button } from '@/components/ui/button';
import { ArrowLeftRight } from 'lucide-react'; import { ArrowLeftRight } from 'lucide-react';
import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types'; 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 { export interface SelectOrVariableInputProps {
// 基本属性 // 基本属性
@ -44,7 +44,7 @@ export const SelectOrVariableInput: React.FC<SelectOrVariableInputProps> = ({
allNodes, allNodes,
allEdges, allEdges,
currentNodeId, currentNodeId,
formFields formFields,
}) => { }) => {
// 判断当前值是否为变量 // 判断当前值是否为变量
const isVariableValue = (val: any): boolean => { const isVariableValue = (val: any): boolean => {
@ -62,9 +62,12 @@ export const SelectOrVariableInput: React.FC<SelectOrVariableInputProps> = ({
// 是否是手动切换(防止自动模式识别覆盖手动切换) // 是否是手动切换(防止自动模式识别覆盖手动切换)
const isManualToggleRef = useRef(false); const isManualToggleRef = useRef(false);
// 同步外部 value 到内部 internalValue仅在非手动切换时 // 是否正在编辑(防止用户输入时被外部值覆盖)
const isUserEditingRef = useRef(false);
// 同步外部 value 到内部 internalValue仅在非手动切换且非用户编辑时
useEffect(() => { useEffect(() => {
if (!isManualToggleRef.current) { if (!isManualToggleRef.current && !isUserEditingRef.current) {
setInternalValue(value); setInternalValue(value);
// 自动模式识别 // 自动模式识别
@ -127,25 +130,15 @@ export const SelectOrVariableInput: React.FC<SelectOrVariableInputProps> = ({
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
// 变量输入模式 // 变量输入模式(统一使用 CodeMirror
<VariableInput <CodeMirrorVariableInput
value={String(internalValue || '')} value={internalValue}
onChange={(v) => { onChange={(v) => {
// 更新内部值 setInternalValue(v);
setInternalValue(v || undefined); onChange(v as any);
}}
// 如果包含变量语法(${),认为是变量表达式 onEditingChange={(isEditing) => {
if (v.includes('${')) { isUserEditingRef.current = isEditing;
// 变量表达式始终保持字符串类型
onChange(v);
} else if (v === '') {
// 空值传递 undefined符合表单 number 类型验证
onChange(undefined as any);
} else {
// 普通文本:尝试转换为数字
const numValue = Number(v);
onChange(isNaN(numValue) ? v : numValue);
}
}} }}
allNodes={allNodes} allNodes={allNodes}
allEdges={allEdges} allEdges={allEdges}
@ -153,6 +146,8 @@ export const SelectOrVariableInput: React.FC<SelectOrVariableInputProps> = ({
formFields={formFields} formFields={formFields}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
variant="input"
autoConvertNumber={true}
/> />
)} )}
</div> </div>

View File

@ -1,92 +0,0 @@
import React, { useMemo } from 'react';
interface HighlightLayerProps {
value: string;
className?: string;
}
/**
*
*
*/
const HighlightLayer: React.FC<HighlightLayerProps> = ({ 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(
<span key={`text-${lastIndex}`}>
{value.substring(lastIndex, matchStart)}
</span>
);
}
// 添加高亮的变量
// 关键:不使用 padding/margin/border只使用背景色和下划线
// 不改变字体、字重,保持和输入框完全一致
parts.push(
<span
key={`var-${matchStart}`}
className="variable-highlight"
style={{
backgroundColor: 'hsl(199 89% 55% / 0.25)',
color: 'hsl(199 89% 25%)',
textDecoration: 'underline',
textDecorationColor: 'hsl(199 89% 48% / 0.6)',
textDecorationThickness: '2px',
textUnderlineOffset: '2px',
// 确保垂直对齐
display: 'inline',
verticalAlign: 'baseline',
}}
>
{match[0]}
</span>
);
lastIndex = matchEnd;
}
// 添加最后一段普通文本
if (lastIndex < value.length) {
parts.push(
<span key={`text-${lastIndex}`}>
{value.substring(lastIndex)}
</span>
);
}
return parts.length > 0 ? parts : value;
}, [value]);
return (
<div
className={className}
style={{
pointerEvents: 'none',
userSelect: 'none',
}}
>
{highlightedContent}
</div>
);
};
export default HighlightLayer;

View File

@ -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;
};

View File

@ -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<VariableInputProps> = ({
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<HTMLInputElement | HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(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<HTMLInputElement | HTMLTextAreaElement>) => {
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<HTMLInputElement | HTMLTextAreaElement>) => {
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 <Input {...commonProps} />;
}
return <Textarea {...commonProps} rows={rows} />;
};
// 渲染变量提示
const renderSuggestions = () => {
if (allVariables.length === 0) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
);
}
if (filteredVariables.length === 0) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
</div>
);
}
let globalIndex = 0; // 全局索引,用于跟踪所有变量
return (
<div className="max-h-80 overflow-y-auto">
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => (
<div key={nodeName} className="py-2">
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
📦 {nodeName}
</div>
<div className="space-y-0.5">
{variables.map((variable) => {
const currentIndex = globalIndex++;
const isSelected = currentIndex === selectedIndex;
return (
<div
key={`${variable.nodeId}.${variable.fieldName}`}
className={`flex items-center justify-between px-3 py-2 cursor-pointer rounded-sm transition-colors ${
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'
}`}
onClick={() => handleSelectVariable(variable.nodeName, variable.fieldName)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
ref={isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
>
<span className="font-mono text-sm">{variable.fieldName}</span>
<span className="ml-auto text-xs text-muted-foreground">{variable.fieldType}</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
};
// 渲染下拉列表(使用 Portal 渲染到 body
const renderDropdown = () => {
if (!showSuggestions) return null;
return createPortal(
<div
ref={dropdownRef}
className="fixed z-[99999]"
style={getDropdownStyle()}
>
<div className="w-full max-w-[500px] bg-popover text-popover-foreground rounded-md border shadow-lg">
{renderSuggestions()}
<div className="px-3 py-2 border-t text-xs text-muted-foreground bg-muted/30">
💡 <code className="px-1 py-0.5 bg-background rounded">${'{'}</code>
<kbd className="px-1 py-0.5 bg-background rounded border"></kbd>
<kbd className="px-1 py-0.5 bg-background rounded border">Enter</kbd>
<code className="px-1 py-0.5 bg-background rounded">{'}'}</code>
</div>
</div>
</div>,
document.body
);
};
return (
<div ref={containerRef} className="relative">
{/* 包装容器:输入框 + 高亮层 */}
<div className="relative">
{/* 底层:实际的输入框(文字透明,光标可见) */}
{renderInput()}
{/* 顶层:高亮层(绝对定位,覆盖在输入框上) */}
{value && (
<div
ref={highlightRef}
className="absolute inset-0 pointer-events-none"
style={{
overflow: 'hidden',
display: variant === 'input' ? 'flex' : 'block',
alignItems: variant === 'input' ? 'center' : 'flex-start',
}}
>
{/* Input 需要包装 div 避免 flex 影响文字Textarea 直接渲染 */}
{variant === 'input' ? (
<div style={{ width: '100%' }}>
<HighlightLayer value={value} />
</div>
) : (
<HighlightLayer value={value} />
)}
</div>
)}
</div>
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */}
{renderDropdown()}
</div>
);
};
export default VariableInput;

View File

@ -1,124 +0,0 @@
import type { TriggerInfo } from './types';
import type { NodeVariable } from '@/utils/workflow/collectNodeVariables';
/**
*
*
*
* 1. ${
* 2. ${ }
* 3. 100
* 4.
*
* @param text
* @param cursorPos 0
* @returns
*
* @example
* detectTrigger("Hello ${jen", 11)
* // => { shouldShow: true, startPos: 6, searchText: "jen" }
*
* @example
* detectTrigger("${var == ", 9)
* // => { shouldShow: false, startPos: -1, searchText: '' } // 已经在输入比较逻辑
*/
export const detectTrigger = (text: string, cursorPos: number): TriggerInfo => {
// 获取光标前的文本
const beforeCursor = text.substring(0, cursorPos);
// 找到最后一个 ${
const lastOpenIndex = beforeCursor.lastIndexOf('${');
// 如果没有找到 ${或者找到的位置在很早之前超过100个字符不触发
if (lastOpenIndex === -1 || (cursorPos - lastOpenIndex) > 100) {
return { shouldShow: false, startPos: -1, searchText: '' };
}
// 获取 ${ 之后到光标的文本
const textAfterOpen = beforeCursor.substring(lastOpenIndex + 2);
// 如果这段文本中已经有 },说明表达式已经闭合,不触发
if (textAfterOpen.includes('}')) {
return { shouldShow: false, startPos: -1, searchText: '' };
}
// ✅ 关键优化:检测是否包含比较运算符或逻辑运算符
// 如果用户已经在输入比较逻辑,则不显示变量提示
const hasOperators = /[=!<>&|]{1,2}|&&|\|\|/.test(textAfterOpen);
if (hasOperators) {
return { shouldShow: false, startPos: -1, searchText: '' };
}
// 返回触发信息
return {
shouldShow: true,
startPos: lastOpenIndex,
searchText: textAfterOpen.trim(),
};
};
/**
*
*
* ${
*
* @param originalText
* @param cursorPos
* @param triggerStartPos ${
* @param nodeName ${nodeName.fieldName}
* @param fieldName
* @returns
*
* @example
* insertVariable("Hello ${jen", 11, 6, "Jenkins构建", "buildNumber")
* // => { newText: "Hello ${Jenkins构建.buildNumber}", newCursorPos: 30 }
* // 光标位置在 } 之前,可以继续输入比较逻辑
*/
export const insertVariable = (
originalText: string,
cursorPos: number,
triggerStartPos: number,
nodeName: string,
fieldName: string
): { newText: string; newCursorPos: number } => {
// ${ 之前的文本
const before = originalText.substring(0, triggerStartPos);
// 光标之后的文本
const after = originalText.substring(cursorPos);
// 构建新文本before + ${nodeName.fieldName} + after
const variableText = `\${${nodeName}.${fieldName}}`;
const newText = `${before}${variableText}${after}`;
// ✅ 关键修改:将光标定位在 } 之前,方便用户继续输入比较逻辑
// 例如:${nodeName.fieldName|} → 用户可以直接输入 " == 'value'"
const newCursorPos = before.length + variableText.length - 1;
return { newText, newCursorPos };
};
/**
*
*
* @param variables
* @param searchText
* @returns
*/
export const filterVariables = (variables: NodeVariable[], searchText: string): NodeVariable[] => {
if (!searchText) {
return variables;
}
const lowerSearch = searchText.toLowerCase();
return variables.filter(v => {
// 匹配节点名称或字段名
const matchesNodeName = v.nodeName?.toLowerCase().includes(lowerSearch);
const matchesFieldName = v.fieldName?.toLowerCase().includes(lowerSearch);
const matchesFullText = v.fullText?.toLowerCase().includes(lowerSearch);
return matchesNodeName || matchesFieldName || matchesFullText;
});
};

View File

@ -9,8 +9,8 @@ import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import type { FlowEdge, FlowNode } from '../types'; import type { FlowEdge, FlowNode } from '../types';
import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion'; import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion';
import VariableInput from '@/components/VariableInput'; import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
import type { FormField as FormFieldType } from '@/components/VariableInput/types'; import type { FormField as FormFieldType } from '@/components/CodeMirrorVariableInput/types';
interface EdgeConfigModalProps { interface EdgeConfigModalProps {
visible: boolean; visible: boolean;
@ -343,7 +343,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<VariableInput <CodeMirrorVariableInput
value={field.value || ''} value={field.value || ''}
onChange={field.onChange} onChange={field.onChange}
allNodes={allNodes} allNodes={allNodes}

View File

@ -21,9 +21,9 @@ import { isConfigurableNode } from '../nodes/types';
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter'; import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
import { loadDataSource, DataSourceType, type DataSourceOption } from '@/domain/dataSource'; import { loadDataSource, DataSourceType, type DataSourceOption } from '@/domain/dataSource';
import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion'; import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion';
import VariableInput from '@/components/VariableInput'; import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
import SelectOrVariableInput from '@/components/SelectOrVariableInput'; import SelectOrVariableInput from '@/components/SelectOrVariableInput';
import type { FormField as FormFieldType } from '@/components/VariableInput/types'; import type { FormField as FormFieldType } from '@/components/CodeMirrorVariableInput/types';
interface NodeConfigModalProps { interface NodeConfigModalProps {
visible: boolean; visible: boolean;
@ -546,12 +546,12 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
); );
} }
// 其他字符串字段:使用 VariableInput // 其他字符串字段:使用 CodeMirrorVariableInput
const isTextarea = prop.format === 'textarea' || const isTextarea = prop.format === 'textarea' ||
['content', 'body', 'expression', 'script'].includes(key); ['content', 'body', 'expression', 'script'].includes(key);
return ( return (
<VariableInput <CodeMirrorVariableInput
value={field.value || ''} value={field.value || ''}
onChange={field.onChange} onChange={field.onChange}
allNodes={allNodesRef.current} allNodes={allNodesRef.current}