更换变量显示组件
This commit is contained in:
parent
ae2690899f
commit
834996cff8
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
354
frontend/src/components/CodeMirrorVariableInput/index.tsx
Normal file
354
frontend/src/components/CodeMirrorVariableInput/index.tsx
Normal 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;
|
||||
|
||||
@ -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<SelectOrVariableInputProps> = ({
|
||||
allNodes,
|
||||
allEdges,
|
||||
currentNodeId,
|
||||
formFields
|
||||
formFields,
|
||||
}) => {
|
||||
// 判断当前值是否为变量
|
||||
const isVariableValue = (val: any): boolean => {
|
||||
@ -62,9 +62,12 @@ export const SelectOrVariableInput: React.FC<SelectOrVariableInputProps> = ({
|
||||
// 是否是手动切换(防止自动模式识别覆盖手动切换)
|
||||
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<SelectOrVariableInputProps> = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
// 变量输入模式
|
||||
<VariableInput
|
||||
value={String(internalValue || '')}
|
||||
// 变量输入模式(统一使用 CodeMirror)
|
||||
<CodeMirrorVariableInput
|
||||
value={internalValue}
|
||||
onChange={(v) => {
|
||||
// 更新内部值
|
||||
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<SelectOrVariableInputProps> = ({
|
||||
formFields={formFields}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
variant="input"
|
||||
autoConvertNumber={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@ -9,8 +9,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { FlowEdge, FlowNode } from '../types';
|
||||
import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion';
|
||||
import VariableInput from '@/components/VariableInput';
|
||||
import type { FormField as FormFieldType } from '@/components/VariableInput/types';
|
||||
import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
|
||||
import type { FormField as FormFieldType } from '@/components/CodeMirrorVariableInput/types';
|
||||
|
||||
interface EdgeConfigModalProps {
|
||||
visible: boolean;
|
||||
@ -343,7 +343,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
<FormItem>
|
||||
<FormLabel>条件表达式</FormLabel>
|
||||
<FormControl>
|
||||
<VariableInput
|
||||
<CodeMirrorVariableInput
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
allNodes={allNodes}
|
||||
|
||||
@ -21,9 +21,9 @@ import { isConfigurableNode } from '../nodes/types';
|
||||
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
|
||||
import { loadDataSource, DataSourceType, type DataSourceOption } from '@/domain/dataSource';
|
||||
import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion';
|
||||
import VariableInput from '@/components/VariableInput';
|
||||
import CodeMirrorVariableInput from '@/components/CodeMirrorVariableInput';
|
||||
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 {
|
||||
visible: boolean;
|
||||
@ -546,12 +546,12 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// 其他字符串字段:使用 VariableInput
|
||||
// 其他字符串字段:使用 CodeMirrorVariableInput
|
||||
const isTextarea = prop.format === 'textarea' ||
|
||||
['content', 'body', 'expression', 'script'].includes(key);
|
||||
|
||||
return (
|
||||
<VariableInput
|
||||
<CodeMirrorVariableInput
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
allNodes={allNodesRef.current}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user