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 type { VariableInputProps } from './types';
|
||||||
import { detectTrigger, insertVariable, filterVariables } from './utils';
|
import { detectTrigger, insertVariable, filterVariables } from './utils';
|
||||||
import { collectNodeVariables, groupVariablesByNode } from '@/utils/workflow/collectNodeVariables';
|
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 inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const highlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 收集可用变量
|
// 收集可用变量
|
||||||
const allVariables = useMemo(() => {
|
const allVariables = useMemo(() => {
|
||||||
@ -113,6 +116,63 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showSuggestions]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!showSuggestions) return;
|
if (!showSuggestions) return;
|
||||||
@ -149,6 +209,53 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
|
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
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;
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@ -215,6 +322,17 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
|
|
||||||
// 渲染输入框
|
// 渲染输入框
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
|
const baseStyle = {
|
||||||
|
// 关键:使用 WebKit 专属属性使文字完全透明,但保留光标和选区
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
color: 'transparent', // 降级方案
|
||||||
|
caretColor: 'hsl(var(--foreground))',
|
||||||
|
// 保留选区高亮
|
||||||
|
WebkitUserSelect: 'text',
|
||||||
|
userSelect: 'text',
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
// 只有有值时才设置透明,这样 placeholder 可以正常显示
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
ref: inputRef as any,
|
ref: inputRef as any,
|
||||||
value,
|
value,
|
||||||
@ -222,6 +340,8 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
onKeyDown: handleKeyDown,
|
onKeyDown: handleKeyDown,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
className: 'variable-input',
|
||||||
|
style: value ? baseStyle : { caretColor: 'hsl(var(--foreground))' },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (variant === 'input') {
|
if (variant === 'input') {
|
||||||
@ -311,8 +431,24 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
{/* 输入框 */}
|
{/* 包装容器:输入框 + 高亮层 */}
|
||||||
{renderInput()}
|
<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 */}
|
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */}
|
||||||
{renderDropdown()}
|
{renderDropdown()}
|
||||||
|
|||||||
@ -432,7 +432,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
|||||||
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
|
{isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && (
|
||||||
<AccordionItem value="input" className="border-b">
|
<AccordionItem value="input" className="border-b">
|
||||||
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
||||||
输入映射
|
输入
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-1 space-y-4">
|
<AccordionContent className="px-1 space-y-4">
|
||||||
<Form {...inputForm}>
|
<Form {...inputForm}>
|
||||||
|
|||||||
@ -61,17 +61,30 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
|
|||||||
title: "节点描述",
|
title: "节点描述",
|
||||||
description: "节点的详细说明",
|
description: "节点的详细说明",
|
||||||
default: "通过Jenkins执行构建任务"
|
default: "通过Jenkins执行构建任务"
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
required: ["nodeName", "nodeCode", "jenkinsServerId"]
|
||||||
|
},
|
||||||
|
inputMappingSchema: {
|
||||||
|
type: "object",
|
||||||
|
title: "输入",
|
||||||
|
description: "从上游节点接收的数据映射配置",
|
||||||
|
properties: {
|
||||||
jenkinsServerId: {
|
jenkinsServerId: {
|
||||||
type: "number",
|
type: "number",
|
||||||
title: "Jenkins服务器",
|
title: "Jenkins服务器",
|
||||||
description: "选择要使用的Jenkins服务器",
|
description: "选择要使用的Jenkins服务器",
|
||||||
'x-dataSource': DataSourceType.JENKINS_SERVERS
|
'x-dataSource': DataSourceType.JENKINS_SERVERS
|
||||||
}
|
},
|
||||||
|
project: {
|
||||||
|
type: "string",
|
||||||
|
title: "项目",
|
||||||
|
description: "要触发构建的项目",
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["nodeName", "nodeCode", "jenkinsServerId"]
|
required: ["jenkinsServerId"]
|
||||||
},
|
},
|
||||||
// ✅ 输出能力定义(只读展示,传递给后端)
|
|
||||||
outputs: [
|
outputs: [
|
||||||
{
|
{
|
||||||
name: "buildNumber",
|
name: "buildNumber",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user