diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 2b01910..61f6210 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,28 +1,34 @@ 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) - // L-13: 只选择需要的字段,避免整个 store 变化触发重渲染 + // 搜索状态 const searchIsVisible = useSearchStore(s => s.isVisible) - const searchFindNext = useSearchStore(s => s.findNext) - const searchFindPrev = useSearchStore(s => s.findPrev) + 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 @@ -39,64 +45,41 @@ export function Editor() { 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 - updateLineNumbers(tab.content) - updateFileSizeDisplay(tab.content) - updateCursorPos() + 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 - } + if (lineNumbersRef.current) lineNumbersRef.current.scrollTop = tab.scrollTop }) }, [activeTabId, getActiveTab]) - // 保存当前标签状态 - const saveCurrentState = useCallback(() => { - const tab = getActiveTab() - const textarea = textareaRef.current - if (!tab || !textarea) return - useTabStore.getState().updateTabScroll(tab.id, { - scrollTop: textarea.scrollTop, - scrollLeft: textarea.scrollLeft, - selectionStart: textarea.selectionStart, - selectionEnd: textarea.selectionEnd, - previewScrollTop: 0 // will be set by preview - }) - }, [getActiveTab]) - // 更新行号 const updateLineNumbers = useCallback((content: string) => { - const count = (content.match(/\n/g) || []).length + 1 - setLineCount(count) - }, []) - - // 更新文件大小 - const updateFileSizeDisplay = useCallback((content: string) => { - const bytes = new Blob([content]).size - if (bytes < 1024) setFileSize(bytes + ' B') - else if (bytes < 1024 * 1024) setFileSize((bytes / 1024).toFixed(1) + ' KB') - else setFileSize((bytes / (1024 * 1024)).toFixed(1) + ' MB') - }, []) - - // 更新光标位置 - 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 }) + setLineCount((content.match(/\n/g) || []).length + 1) }, []) // 输入事件 @@ -104,35 +87,142 @@ export function Editor() { 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) - updateFileSizeDisplay(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]) - // 防抖更新 - if (updateTimerRef.current) clearTimeout(updateTimerRef.current) - updateTimerRef.current = setTimeout(() => { - // 触发预览更新(通过 store) - }, 150) - }, [getActiveTab, updateTabContent, setModified, updateLineNumbers, updateFileSizeDisplay]) - - // 滚动同步:通知 Preview 跟随滚动 + // 滚动同步 const handleScroll = useCallback(() => { const textarea = textareaRef.current if (!textarea) return - // 行号跟随 if (lineNumbersRef.current) { lineNumbersRef.current.scrollTop = textarea.scrollTop } - // 计算滚动比例,派发给 Preview + // 更新搜索高亮位置 + 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 } })) }, []) - // Tab 键支持 + // 搜索:执行搜索并更新 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() @@ -148,45 +238,28 @@ export function Editor() { document.execCommand('insertText', false, ' ') } } - - // 搜索导航 if (e.key === 'Enter' && searchIsVisible) { e.preventDefault() - if (e.shiftKey) { - searchFindPrev() - } else { - searchFindNext() - } + const state = useSearchStore.getState() + if (e.shiftKey) state.findPrev() + else state.findNext() } - }, [searchIsVisible, searchFindNext, searchFindPrev]) + }, [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 - const LARGE_THRESHOLD = 2000 - if (lineCount > LARGE_THRESHOLD) { - // 虚拟化 - const textarea = textareaRef.current - if (!textarea) return null - const scrollTop = textarea.scrollTop - const viewportHeight = textarea.clientHeight - const buffer = 20 - const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer) - const endLine = Math.min(lineCount, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer) - const topPadding = startLine * lineHeight - const bottomPadding = (lineCount - endLine) * lineHeight - return ( - <> -
- {Array.from({ length: endLine - startLine }, (_, i) => ( -
- {startLine + i + 1} -
- ))} -
- - ) - } return Array.from({ length: lineCount }, (_, i) => (
{i + 1} @@ -194,6 +267,16 @@ export function Editor() { )) } + // 清理 overlay + useEffect(() => { + return () => { + if (highlightRef.current) { + highlightRef.current.remove() + highlightRef.current = null + } + } + }, []) + return (
diff --git a/src/renderer/components/SearchBar/SearchBar.tsx b/src/renderer/components/SearchBar/SearchBar.tsx index 09c5a72..50beaea 100644 --- a/src/renderer/components/SearchBar/SearchBar.tsx +++ b/src/renderer/components/SearchBar/SearchBar.tsx @@ -39,8 +39,8 @@ export function SearchBar() { const handleSearchChange = useCallback((e: React.ChangeEvent) => { const text = e.target.value setSearchText(text) - doSearch(text) - }, [setSearchText, doSearch]) + // Editor 会监听 searchText 变化并执行搜索和高亮 + }, [setSearchText]) const handleReplaceCurrent = useCallback(() => { const tab = getActiveTab() diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index d485436..bc06bea 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -53,13 +53,15 @@ export function Sidebar() { const dirPath = await window.electronAPI.openFolderDialog() if (dirPath) { setRootPath(dirPath) + // 展开根目录 + expandDirs([dirPath]) const result = await window.electronAPI.readDirTree(dirPath) if (result.success && result.tree) { setTree(result.tree) window.electronAPI.watchDir(dirPath) } } - }, [setRootPath, setTree]) + }, [setRootPath, setTree, expandDirs]) const refreshTree = useCallback(async () => { if (!rootPath || !window.electronAPI) return @@ -153,7 +155,7 @@ export function Sidebar() { <>
文件夹目录树