Browse Source

fix: 修复 ConfigContentEditor 搜索高亮图层重叠问题

- 移除复杂的高亮显示层,改用原生 textarea 选择高亮
- 添加搜索状态指示器和视觉反馈
- 保持所有搜索功能完整(键盘导航、匹配计数等)
- 解决图层重叠和滚动同步问题
feature/x1-web-request
root 6 days ago
parent
commit
49a3323d95
  1. 267
      src/X1.WebUI/src/components/ui/ConfigContentEditor.tsx

267
src/X1.WebUI/src/components/ui/ConfigContentEditor.tsx

@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { MagnifyingGlassIcon, Cross2Icon } from '@radix-ui/react-icons';
import { MagnifyingGlassIcon, Cross2Icon, UploadIcon, TrashIcon } from '@radix-ui/react-icons';
interface ConfigContentEditorProps {
value: string;
@ -27,84 +27,194 @@ export default function ConfigContentEditor({
className = ""
}: ConfigContentEditorProps) {
const [searchTerm, setSearchTerm] = useState('');
const [highlightedContent, setHighlightedContent] = useState('');
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 高亮搜索内容
// 计算搜索匹配项
useEffect(() => {
if (!searchTerm.trim()) {
setHighlightedContent(value);
setCurrentMatchIndex(0);
setTotalMatches(0);
return;
}
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const highlighted = value.replace(regex, '<mark class="bg-yellow-200 text-black">$1</mark>');
setHighlightedContent(highlighted);
}, [value, searchTerm]);
try {
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedSearchTerm})`, 'gi');
const matches = [...value.matchAll(regex)];
setTotalMatches(matches.length);
// 确保当前匹配索引在有效范围内
if (currentMatchIndex >= matches.length) {
setCurrentMatchIndex(0);
}
} catch (error) {
console.error('搜索匹配计算错误:', error);
setTotalMatches(0);
setCurrentMatchIndex(0);
}
}, [value, searchTerm, currentMatchIndex]);
// 搜索下一个匹配项
const findNext = () => {
// 处理文件读取
const handleFileRead = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
const allowedTypes = [
'text/plain',
'application/json',
'application/xml',
'text/xml',
'text/yaml',
'text/yml',
'application/yaml',
'application/yml'
];
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(txt|json|xml|yaml|yml|conf|config|cfg)$/i)) {
alert('请选择文本文件格式(.txt, .json, .xml, .yaml, .yml, .conf, .config, .cfg)');
return;
}
// 检查文件大小(限制为5MB)
if (file.size > 5 * 1024 * 1024) {
alert('文件大小不能超过5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
onChange(content);
}
};
reader.onerror = () => {
alert('读取文件失败,请重试');
};
reader.readAsText(file, 'UTF-8');
// 清空文件输入,允许重复选择同一文件
event.target.value = '';
};
// 触发文件选择
const handleFileSelect = () => {
fileInputRef.current?.click();
};
// 清空内容
const handleClear = () => {
if (confirm('确定要清空所有内容吗?')) {
onChange('');
setSearchTerm('');
setIsSearchVisible(false);
}
};
// 定位到指定匹配项
const scrollToMatch = (matchIndex: number) => {
if (!searchTerm.trim() || !textareaRef.current) return;
const textarea = textareaRef.current;
const text = textarea.value;
const currentPos = textarea.selectionStart;
const searchIndex = text.toLowerCase().indexOf(searchTerm.toLowerCase(), currentPos);
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const matches = [...text.matchAll(regex)];
if (searchIndex !== -1) {
textarea.setSelectionRange(searchIndex, searchIndex + searchTerm.length);
if (matchIndex >= 0 && matchIndex < matches.length) {
const match = matches[matchIndex];
const startIndex = match.index!;
const endIndex = startIndex + searchTerm.length;
// 设置选择范围并高亮
textarea.setSelectionRange(startIndex, endIndex);
textarea.focus();
} else {
// 如果从当前位置没找到,从头开始搜索
const firstIndex = text.toLowerCase().indexOf(searchTerm.toLowerCase());
if (firstIndex !== -1) {
textarea.setSelectionRange(firstIndex, firstIndex + searchTerm.length);
textarea.focus();
}
// 滚动到可见位置
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20;
const linesBeforeMatch = text.substring(0, startIndex).split('\n').length - 1;
const scrollTop = linesBeforeMatch * lineHeight - textarea.clientHeight / 2;
textarea.scrollTop = Math.max(0, scrollTop);
// 添加临时高亮效果
textarea.style.backgroundColor = '#fef3c7';
setTimeout(() => {
textarea.style.backgroundColor = '';
}, 500);
}
};
// 搜索下一个匹配项
const findNext = () => {
if (!searchTerm.trim()) return;
const nextIndex = (currentMatchIndex + 1) % totalMatches;
setCurrentMatchIndex(nextIndex);
scrollToMatch(nextIndex);
};
// 搜索上一个匹配项
const findPrevious = () => {
if (!searchTerm.trim() || !textareaRef.current) return;
const textarea = textareaRef.current;
const text = textarea.value;
const currentPos = textarea.selectionStart;
// 从当前位置向前搜索
const beforeText = text.substring(0, currentPos);
const lastIndex = beforeText.toLowerCase().lastIndexOf(searchTerm.toLowerCase());
if (!searchTerm.trim()) return;
if (lastIndex !== -1) {
textarea.setSelectionRange(lastIndex, lastIndex + searchTerm.length);
textarea.focus();
} else {
// 如果从当前位置没找到,从末尾开始搜索
const lastIndexFromEnd = text.toLowerCase().lastIndexOf(searchTerm.toLowerCase());
if (lastIndexFromEnd !== -1) {
textarea.setSelectionRange(lastIndexFromEnd, lastIndexFromEnd + searchTerm.length);
textarea.focus();
}
}
const prevIndex = currentMatchIndex === 0 ? totalMatches - 1 : currentMatchIndex - 1;
setCurrentMatchIndex(prevIndex);
scrollToMatch(prevIndex);
};
// 计算匹配数量
const getMatchCount = () => {
if (!searchTerm.trim()) return 0;
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const matches = value.match(regex);
return matches ? matches.length : 0;
// 搜索输入变化时重置匹配索引
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
setCurrentMatchIndex(0);
};
const matchCount = getMatchCount();
// 键盘快捷键支持
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isSearchVisible || !searchTerm.trim()) return;
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
findPrevious();
} else {
findNext();
}
}
};
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="flex items-center justify-between mb-2">
<Label htmlFor="configContent">{label}</Label>
<div className="flex items-center gap-1">
{/* 清空按钮 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClear}
disabled={disabled || !value.trim()}
title="清空内容"
>
<TrashIcon className="h-4 w-4" />
</Button>
{/* 文件读取按钮 */}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFileSelect}
disabled={disabled}
title="从文件读取内容"
>
<UploadIcon className="h-4 w-4" />
</Button>
{/* 搜索按钮 */}
<Button
type="button"
@ -119,22 +229,37 @@ export default function ConfigContentEditor({
</div>
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept=".txt,.json,.xml,.yaml,.yml,.conf,.config,.cfg,text/*"
onChange={handleFileRead}
style={{ display: 'none' }}
/>
{/* 搜索栏 */}
{isSearchVisible && (
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border mb-2">
<div className="relative flex-1">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索内容..."
placeholder="搜索内容... (Enter: 下一个, Shift+Enter: 上一个)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
className="pl-10"
autoFocus
/>
</div>
{searchTerm && (
<span className="text-sm text-gray-500">
{matchCount}
<span className="text-sm text-gray-500 whitespace-nowrap">
{totalMatches > 0 ? (
<span className="flex items-center gap-1">
<span>{currentMatchIndex + 1} / {totalMatches}</span>
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
</span>
) : '无匹配项'}
</span>
)}
<Button
@ -142,7 +267,7 @@ export default function ConfigContentEditor({
variant="outline"
size="sm"
onClick={findPrevious}
disabled={!searchTerm.trim()}
disabled={!searchTerm.trim() || totalMatches === 0}
title="上一个"
>
@ -152,7 +277,7 @@ export default function ConfigContentEditor({
variant="outline"
size="sm"
onClick={findNext}
disabled={!searchTerm.trim()}
disabled={!searchTerm.trim() || totalMatches === 0}
title="下一个"
>
@ -164,6 +289,8 @@ export default function ConfigContentEditor({
onClick={() => {
setSearchTerm('');
setIsSearchVisible(false);
setCurrentMatchIndex(0);
setTotalMatches(0);
}}
title="关闭搜索"
>
@ -173,7 +300,7 @@ export default function ConfigContentEditor({
)}
{/* 编辑器区域 - 占满剩余空间 */}
<div className="relative flex-1 min-h-0">
<div className="flex-1 min-h-0 relative">
<Textarea
ref={textareaRef}
value={value}
@ -181,28 +308,26 @@ export default function ConfigContentEditor({
placeholder={placeholder}
required={required}
disabled={disabled}
className="font-mono text-sm h-full resize-none"
style={{ height: '100%' }}
className={`font-mono text-sm h-full resize-none overflow-auto ${
searchTerm.trim() && totalMatches > 0 ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
}`}
style={{
height: '100%',
whiteSpace: 'pre',
wordWrap: 'normal',
overflowWrap: 'normal'
}}
/>
{/* 高亮显示层(仅用于显示,不可编辑) */}
{/* 搜索状态指示器 */}
{searchTerm.trim() && (
<div
className="absolute inset-0 pointer-events-none font-mono text-sm p-3 overflow-auto whitespace-pre-wrap"
style={{
backgroundColor: 'transparent',
color: 'transparent',
caretColor: 'transparent',
border: '1px solid transparent',
borderRadius: 'inherit'
}}
dangerouslySetInnerHTML={{ __html: highlightedContent }}
/>
<div className="absolute bottom-2 right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded shadow-lg">
{totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : '无匹配项'}
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
</p>
</div>
);

Loading…
Cancel
Save