This commit is contained in:
dengqichen 2025-10-22 15:16:27 +08:00
parent e7b914a2ae
commit 6b2e127eaa
5 changed files with 349 additions and 7 deletions

View 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;

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

View File

@ -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()}

View File

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

View File

@ -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",