|
|
@ -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); |
|
|
|
|
|
|
|
// 搜索下一个匹配项
|
|
|
|
const findNext = () => { |
|
|
|
if (!searchTerm.trim() || !textareaRef.current) return; |
|
|
|
// 确保当前匹配索引在有效范围内
|
|
|
|
if (currentMatchIndex >= matches.length) { |
|
|
|
setCurrentMatchIndex(0); |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
|
console.error('搜索匹配计算错误:', error); |
|
|
|
setTotalMatches(0); |
|
|
|
setCurrentMatchIndex(0); |
|
|
|
} |
|
|
|
}, [value, searchTerm, currentMatchIndex]); |
|
|
|
|
|
|
|
const textarea = textareaRef.current; |
|
|
|
const text = textarea.value; |
|
|
|
const currentPos = textarea.selectionStart; |
|
|
|
const searchIndex = text.toLowerCase().indexOf(searchTerm.toLowerCase(), currentPos); |
|
|
|
// 处理文件读取
|
|
|
|
const handleFileRead = (event: React.ChangeEvent<HTMLInputElement>) => { |
|
|
|
const file = event.target.files?.[0]; |
|
|
|
if (!file) return; |
|
|
|
|
|
|
|
if (searchIndex !== -1) { |
|
|
|
textarea.setSelectionRange(searchIndex, searchIndex + searchTerm.length); |
|
|
|
textarea.focus(); |
|
|
|
} else { |
|
|
|
// 如果从当前位置没找到,从头开始搜索
|
|
|
|
const firstIndex = text.toLowerCase().indexOf(searchTerm.toLowerCase()); |
|
|
|
if (firstIndex !== -1) { |
|
|
|
textarea.setSelectionRange(firstIndex, firstIndex + searchTerm.length); |
|
|
|
textarea.focus(); |
|
|
|
// 检查文件类型
|
|
|
|
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 findPrevious = () => { |
|
|
|
// 定位到指定匹配项
|
|
|
|
const scrollToMatch = (matchIndex: number) => { |
|
|
|
if (!searchTerm.trim() || !textareaRef.current) return; |
|
|
|
|
|
|
|
const textarea = textareaRef.current; |
|
|
|
const text = textarea.value; |
|
|
|
const currentPos = textarea.selectionStart; |
|
|
|
const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); |
|
|
|
const matches = [...text.matchAll(regex)]; |
|
|
|
|
|
|
|
// 从当前位置向前搜索
|
|
|
|
const beforeText = text.substring(0, currentPos); |
|
|
|
const lastIndex = beforeText.toLowerCase().lastIndexOf(searchTerm.toLowerCase()); |
|
|
|
if (matchIndex >= 0 && matchIndex < matches.length) { |
|
|
|
const match = matches[matchIndex]; |
|
|
|
const startIndex = match.index!; |
|
|
|
const endIndex = startIndex + searchTerm.length; |
|
|
|
|
|
|
|
if (lastIndex !== -1) { |
|
|
|
textarea.setSelectionRange(lastIndex, lastIndex + searchTerm.length); |
|
|
|
// 设置选择范围并高亮
|
|
|
|
textarea.setSelectionRange(startIndex, endIndex); |
|
|
|
textarea.focus(); |
|
|
|
} else { |
|
|
|
// 如果从当前位置没找到,从末尾开始搜索
|
|
|
|
const lastIndexFromEnd = text.toLowerCase().lastIndexOf(searchTerm.toLowerCase()); |
|
|
|
if (lastIndexFromEnd !== -1) { |
|
|
|
textarea.setSelectionRange(lastIndexFromEnd, lastIndexFromEnd + 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 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 findNext = () => { |
|
|
|
if (!searchTerm.trim()) return; |
|
|
|
|
|
|
|
const nextIndex = (currentMatchIndex + 1) % totalMatches; |
|
|
|
setCurrentMatchIndex(nextIndex); |
|
|
|
scrollToMatch(nextIndex); |
|
|
|
}; |
|
|
|
|
|
|
|
const matchCount = getMatchCount(); |
|
|
|
// 搜索上一个匹配项
|
|
|
|
const findPrevious = () => { |
|
|
|
if (!searchTerm.trim()) return; |
|
|
|
|
|
|
|
const prevIndex = currentMatchIndex === 0 ? totalMatches - 1 : currentMatchIndex - 1; |
|
|
|
setCurrentMatchIndex(prevIndex); |
|
|
|
scrollToMatch(prevIndex); |
|
|
|
}; |
|
|
|
|
|
|
|
// 搜索输入变化时重置匹配索引
|
|
|
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
|
|
setSearchTerm(e.target.value); |
|
|
|
setCurrentMatchIndex(0); |
|
|
|
}; |
|
|
|
|
|
|
|
// 键盘快捷键支持
|
|
|
|
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> |
|
|
|
); |
|
|
|