1
This commit is contained in:
parent
e7b914a2ae
commit
6b2e127eaa
89
frontend/src/components/VariableInput/HighlightLayer.tsx
Normal file
89
frontend/src/components/VariableInput/HighlightLayer.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{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;
|
||||
|
||||
104
frontend/src/components/VariableInput/deleteHelper.ts
Normal file
104
frontend/src/components/VariableInput/deleteHelper.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 变量删除辅助函数
|
||||
* 处理变量的整体删除逻辑
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@ 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';
|
||||
|
||||
/**
|
||||
* 变量输入组件
|
||||
@ -28,6 +30,7 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 收集可用变量
|
||||
const allVariables = useMemo(() => {
|
||||
@ -113,6 +116,63 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
}
|
||||
}, [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;
|
||||
};
|
||||
|
||||
// 同步滚动位置(仅 textarea)
|
||||
const syncScroll = () => {
|
||||
if (!inputElement || !highlightElement || variant !== 'textarea') return;
|
||||
|
||||
highlightElement.scrollTop = inputElement.scrollTop;
|
||||
highlightElement.scrollLeft = inputElement.scrollLeft;
|
||||
};
|
||||
|
||||
// 初始同步(延迟执行,确保 DOM 完全渲染)
|
||||
syncStyles();
|
||||
setTimeout(syncStyles, 0);
|
||||
|
||||
// 监听窗口大小变化(可能影响字体大小)
|
||||
const resizeObserver = new ResizeObserver(syncStyles);
|
||||
resizeObserver.observe(inputElement);
|
||||
|
||||
// 监听滚动事件(仅 textarea)
|
||||
if (variant === 'textarea') {
|
||||
inputElement.addEventListener('scroll', syncScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (variant === 'textarea') {
|
||||
inputElement.removeEventListener('scroll', syncScroll);
|
||||
}
|
||||
};
|
||||
}, [value, variant]);
|
||||
|
||||
// 监听滚动和窗口大小变化,更新下拉框位置
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
@ -149,6 +209,53 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
|
||||
// 处理键盘事件
|
||||
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) {
|
||||
@ -215,6 +322,17 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
|
||||
// 渲染输入框
|
||||
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,
|
||||
@ -222,6 +340,8 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
onKeyDown: handleKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
className: 'variable-input',
|
||||
style: value ? baseStyle : { caretColor: 'hsl(var(--foreground))' },
|
||||
};
|
||||
|
||||
if (variant === 'input') {
|
||||
@ -311,9 +431,25 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* 输入框 */}
|
||||
{/* 包装容器:输入框 + 高亮层 */}
|
||||
<div className="relative">
|
||||
{/* 底层:实际的输入框(文字透明,光标可见) */}
|
||||
{renderInput()}
|
||||
|
||||
{/* 顶层:高亮层(绝对定位,覆盖在输入框上) */}
|
||||
{value && (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<HighlightLayer value={value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */}
|
||||
{renderDropdown()}
|
||||
</div>
|
||||
|
||||
@ -432,7 +432,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
|
||||
<AccordionItem value="input" className="border-b">
|
||||
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
||||
输入映射
|
||||
输入
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1 space-y-4">
|
||||
<Form {...inputForm}>
|
||||
|
||||
@ -61,17 +61,30 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
|
||||
title: "节点描述",
|
||||
description: "节点的详细说明",
|
||||
default: "通过Jenkins执行构建任务"
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode", "jenkinsServerId"]
|
||||
},
|
||||
inputMappingSchema: {
|
||||
type: "object",
|
||||
title: "输入",
|
||||
description: "从上游节点接收的数据映射配置",
|
||||
properties: {
|
||||
jenkinsServerId: {
|
||||
type: "number",
|
||||
title: "Jenkins服务器",
|
||||
description: "选择要使用的Jenkins服务器",
|
||||
'x-dataSource': DataSourceType.JENKINS_SERVERS
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode", "jenkinsServerId"]
|
||||
project: {
|
||||
type: "string",
|
||||
title: "项目",
|
||||
description: "要触发构建的项目",
|
||||
default: ""
|
||||
},
|
||||
},
|
||||
required: ["jenkinsServerId"]
|
||||
},
|
||||
// ✅ 输出能力定义(只读展示,传递给后端)
|
||||
outputs: [
|
||||
{
|
||||
name: "buildNumber",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user