import React, { useEffect, useRef, useCallback, useState } from 'react' import { useTabStore } from '../../stores/tabStore' import { useSearchStore } from '../../stores/searchStore' import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine' export function Editor() { const textareaRef = useRef(null) const lineNumbersRef = useRef(null) const wrapperRef = useRef(null) const highlightRef = useRef(null) const activeTabId = useTabStore(s => s.activeTabId) const tabs = useTabStore(s => s.tabs) const activeTab = tabs.find(t => t.id === activeTabId) ?? null const getActiveTab = useTabStore(s => s.getActiveTab) const updateTabContent = useTabStore(s => s.updateTabContent) const setModified = useTabStore(s => s.setModified) // 搜索状态 const searchIsVisible = useSearchStore(s => s.isVisible) const searchText = useSearchStore(s => s.searchText) const searchMatches = useSearchStore(s => s.matches) const searchCurrentIndex = useSearchStore(s => s.currentIndex) const searchOptions = useSearchStore(s => s.options) const [lineCount, setLineCount] = useState(1) const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 }) const [fileSize, setFileSize] = useState('') const updateTimerRef = useRef | null>(null) // 行高 const lineHeightRef = useRef(20.8) useEffect(() => { const textarea = textareaRef.current if (!textarea) return const style = getComputedStyle(textarea) const measure = document.createElement('div') measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;` measure.textContent = 'X' document.body.appendChild(measure) const singleHeight = measure.offsetHeight measure.textContent = 'X\nX' const doubleHeight = measure.offsetHeight document.body.removeChild(measure) lineHeightRef.current = doubleHeight - singleHeight || 20.8 }, []) // 字符宽度 const charWidthRef = useRef(8) useEffect(() => { const textarea = textareaRef.current if (!textarea) return const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) return const style = getComputedStyle(textarea) ctx.font = `${style.fontSize} ${style.fontFamily}` charWidthRef.current = ctx.measureText('M').width }, []) // 加载标签内容 useEffect(() => { const tab = getActiveTab() const textarea = textareaRef.current if (!tab || !textarea) return textarea.value = tab.content setLineCount((tab.content.match(/\n/g) || []).length + 1) const bytes = new Blob([tab.content]).size setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB') requestAnimationFrame(() => { textarea.scrollTop = tab.scrollTop textarea.scrollLeft = tab.scrollLeft textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd) if (lineNumbersRef.current) lineNumbersRef.current.scrollTop = tab.scrollTop }) }, [activeTabId, getActiveTab]) // 更新行号 const updateLineNumbers = useCallback((content: string) => { setLineCount((content.match(/\n/g) || []).length + 1) }, []) // 输入事件 const handleInput = useCallback(() => { const textarea = textareaRef.current const tab = getActiveTab() if (!textarea || !tab) return const content = textarea.value updateTabContent(tab.id, content) setModified(tab.id, true) updateLineNumbers(content) const bytes = new Blob([content]).size setFileSize(bytes < 1024 ? bytes + ' B' : bytes < 1048576 ? (bytes / 1024).toFixed(1) + ' KB' : (bytes / 1048576).toFixed(1) + ' MB') }, [getActiveTab, updateTabContent, setModified, updateLineNumbers]) // 滚动同步 const handleScroll = useCallback(() => { const textarea = textareaRef.current if (!textarea) return if (lineNumbersRef.current) { lineNumbersRef.current.scrollTop = textarea.scrollTop } // 更新搜索高亮位置 if (highlightRef.current) { highlightRef.current.style.top = -textarea.scrollTop + 'px' } const maxScroll = textarea.scrollHeight - textarea.clientHeight const ratio = maxScroll > 0 ? textarea.scrollTop / maxScroll : 0 window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { ratio } })) }, []) // 搜索:执行搜索并更新 matches const activeContent = activeTab?.content useEffect(() => { if (!searchIsVisible || !searchText) { if (highlightRef.current) highlightRef.current.innerHTML = '' return } const textarea = textareaRef.current if (!textarea) return const matches = findMatches(textarea.value, searchText, searchOptions) useSearchStore.getState().setMatches(matches) renderHighlights(textarea.value, matches, searchCurrentIndex) }, [searchIsVisible, searchText, searchOptions, activeContent]) // 搜索:currentIndex 变化时滚动到匹配位置 useEffect(() => { if (searchCurrentIndex < 0 || searchMatches.length === 0) return const textarea = textareaRef.current if (!textarea) return const m = searchMatches[searchCurrentIndex] if (!m) return // 选中匹配文本 textarea.focus() textarea.setSelectionRange(m.start, m.end) // 滚动到匹配位置 const lineStarts = buildLineStarts(textarea.value) const line = getLineNumber(lineStarts, m.start) const lineHeight = lineHeightRef.current const matchTop = line * lineHeight const viewTop = textarea.scrollTop const viewBottom = viewTop + textarea.clientHeight if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) { textarea.scrollTop = matchTop - textarea.clientHeight / 2 } // 重新渲染高亮 renderHighlights(textarea.value, searchMatches, searchCurrentIndex) }, [searchCurrentIndex, searchMatches]) // 渲染搜索高亮 overlay const renderHighlights = useCallback((content: string, matches: Array<{start: number; end: number}>, currentIndex: number) => { const wrapper = wrapperRef.current const textarea = textareaRef.current if (!wrapper || !textarea) return // 创建或复用 overlay if (!highlightRef.current) { const overlay = document.createElement('div') overlay.id = 'search-highlights' overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;height:' + textarea.scrollHeight + 'px;pointer-events:none;overflow:visible;z-index:1;' wrapper.style.position = 'relative' wrapper.appendChild(overlay) highlightRef.current = overlay } const overlay = highlightRef.current overlay.style.top = -textarea.scrollTop + 'px' overlay.style.height = textarea.scrollHeight + 'px' overlay.innerHTML = '' if (matches.length === 0) return const style = getComputedStyle(textarea) const paddingTop = parseFloat(style.paddingTop) const paddingLeft = parseFloat(style.paddingLeft) const lineHeight = lineHeightRef.current const charWidth = charWidthRef.current const lineStarts = buildLineStarts(content) for (let i = 0; i < matches.length; i++) { const m = matches[i] const startLine = getLineNumber(lineStarts, m.start) const startCol = m.start - lineStarts[startLine] const endLine = getLineNumber(lineStarts, m.end) const endCol = m.end - lineStarts[endLine] if (startLine === endLine) { // 单行匹配 const div = document.createElement('div') div.className = 'search-hl' + (i === currentIndex ? ' current' : '') div.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${Math.max((endCol - startCol) * charWidth, 8)}px;` overlay.appendChild(div) } else { // 多行匹配:第一行 const div1 = document.createElement('div') div1.className = 'search-hl' + (i === currentIndex ? ' current' : '') const firstLineWidth = textarea.clientWidth - (startCol * charWidth + paddingLeft) - 20 div1.style.cssText = `position:absolute;top:${startLine * lineHeight + paddingTop}px;left:${startCol * charWidth + paddingLeft}px;height:${lineHeight}px;width:${firstLineWidth}px;` overlay.appendChild(div1) // 中间行 for (let l = startLine + 1; l < endLine; l++) { const divM = document.createElement('div') divM.className = 'search-hl' + (i === currentIndex ? ' current' : '') divM.style.cssText = `position:absolute;top:${l * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${textarea.clientWidth - paddingLeft * 2}px;` overlay.appendChild(divM) } // 最后一行 const div2 = document.createElement('div') div2.className = 'search-hl' + (i === currentIndex ? ' current' : '') div2.style.cssText = `position:absolute;top:${endLine * lineHeight + paddingTop}px;left:${paddingLeft}px;height:${lineHeight}px;width:${endCol * charWidth}px;` overlay.appendChild(div2) } } }, []) // Tab 键 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Tab') { e.preventDefault() const textarea = textareaRef.current if (!textarea) return const start = textarea.selectionStart const end = textarea.selectionEnd if (start !== end) { const lines = textarea.value.substring(start, end).split('\n') const indented = lines.map(line => ' ' + line).join('\n') document.execCommand('insertText', false, indented) } else { document.execCommand('insertText', false, ' ') } } if (e.key === 'Enter' && searchIsVisible) { e.preventDefault() const state = useSearchStore.getState() if (e.shiftKey) state.findPrev() else state.findNext() } }, [searchIsVisible]) // 更新光标位置 const updateCursorPos = useCallback(() => { const textarea = textareaRef.current if (!textarea) return const text = textarea.value const pos = textarea.selectionStart const textBefore = text.substring(0, pos) const lines = textBefore.split('\n') setCursorPos({ line: lines.length, col: lines[lines.length - 1].length + 1 }) }, []) // 渲染行号 const renderLineNumbers = () => { const lineHeight = lineHeightRef.current return Array.from({ length: lineCount }, (_, i) => (
{i + 1}
)) } // 清理 overlay useEffect(() => { return () => { if (highlightRef.current) { highlightRef.current.remove() highlightRef.current = null } } }, []) return (
{renderLineNumbers()}