refactor: 删除自定义搜索,统一使用 CodeMirror 6 搜索

删除文件:
- SearchBar/SearchBar.tsx — 自定义搜索栏组件
- stores/searchStore.ts — 自定义搜索状态管理
- lib/searchEngine.ts — 自定义搜索匹配引擎
- types/search.ts — 搜索相关类型定义

清理代码:
- App.tsx:移除 SearchBar 导入和渲染
- useKeyboard.ts:移除 searchStore 引用和搜索快捷键
- Editor.tsx:移除 searchStore 引用和搜索逻辑
- types/index.ts:移除 search 类型导出
- global.css:删除旧搜索栏样式(~113 行)

CodeMirror 6 搜索面板移到顶部:
- .cm-panels { top: 0; bottom: auto; }
- .cm-panels-bottom { top: 0; bottom: auto; }

快捷键(CodeMirror 内置):
- Ctrl+F 搜索
- Ctrl+H 替换
- Enter/Shift+Enter 下一个/上一个
- Alt+C 大小写
- Alt+R 正则
This commit is contained in:
thzxx
2026-05-27 23:54:55 +08:00
parent e40a8bd88e
commit d57a2b2aac
9 changed files with 19 additions and 425 deletions
+3 -10
View File
@@ -1,25 +1,23 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useSearchStore } from './stores/searchStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useKeyboard } from './hooks/useKeyboard'
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
import { useFileWatch } from './hooks/useFileWatch'
import { recentFilesRepository } from './db/recentFilesRepository'
import { useDragDrop } from './hooks/useDragDrop'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { Preview } from './components/Preview/Preview'
import { Sidebar } from './components/Sidebar/Sidebar'
import { SearchBar } from './components/SearchBar/SearchBar'
import { StatusBar } from './components/StatusBar/StatusBar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { Toast } from './components/Toast/Toast'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { useDragDrop } from './hooks/useDragDrop'
export default function App() {
const tabs = useTabStore(s => s.tabs)
@@ -29,7 +27,6 @@ export default function App() {
const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB)
const saveToDB = useTabStore(s => s.saveToDB)
const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
@@ -80,9 +77,7 @@ export default function App() {
const result = await window.electronAPI.openFile()
if (result && 'filePath' in result) {
createTab(result.filePath, result.content)
// 追踪最近打开文件
if (result.filePath) recentFilesRepository.add(result.filePath)
// 保存标签页到 DB
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
}
@@ -137,7 +132,7 @@ export default function App() {
saveViewMode(mode)
}, [saveViewMode])
// M-11: 主进程事件注册(使用 ref 保持最新回调引用 + cleanup 清理)
// M-11: 主进程事件注册
const handleSaveRef = useRef(handleSave)
const handleSaveAsRef = useRef(handleSaveAs)
handleSaveRef.current = handleSave
@@ -149,7 +144,7 @@ export default function App() {
const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content)
recentFilesRepository.add(data.filePath)
if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
const onSave = () => handleSaveRef.current()
@@ -218,7 +213,6 @@ export default function App() {
<div id="content-wrapper">
{viewMode !== 'preview' && (
<div id="editor-panel" style={editorStyle}>
<SearchBar />
<Editor darkMode={darkMode} />
</div>
)}
@@ -270,7 +264,6 @@ function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
// 保存最终比例
const wrapper = document.getElementById('content-wrapper')
if (wrapper) {
const rect = wrapper.getBoundingClientRect()
+3 -13
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useCallback } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useSearchStore } from '../../stores/searchStore'
import { useCodeMirror } from './useCodeMirror'
import { EditorToolbar } from './EditorToolbar'
@@ -20,12 +19,10 @@ export function Editor({ darkMode }: EditorProps) {
containerRef,
viewRef,
setContent,
getContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
scrollTo
setSelection
} = useCodeMirror({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
@@ -43,15 +40,13 @@ export function Editor({ darkMode }: EditorProps) {
useEffect(() => {
if (!activeTab) return
setContent(activeTab.content)
// 恢复滚动和选区
requestAnimationFrame(() => {
setScrollTop(activeTab.scrollTop)
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
})
}, [activeTabId])
// 保存当前标签状态(切换前)
// 保存当前标签状态
useEffect(() => {
return () => {
if (!activeTabId) return
@@ -63,12 +58,10 @@ export function Editor({ darkMode }: EditorProps) {
}
}, [activeTabId])
// 搜索:Ctrl+F 聚焦搜索框,Enter 导航
// Ctrl+B 粗体, Ctrl+I 斜体
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
// Ctrl+B 粗体
if (isCtrl && e.key === 'b') {
e.preventDefault()
const view = viewRef.current
@@ -80,8 +73,6 @@ export function Editor({ darkMode }: EditorProps) {
selection: { anchor: from + 2, head: from + 2 + selected.length }
})
}
// Ctrl+I 斜体
if (isCtrl && e.key === 'i') {
e.preventDefault()
const view = viewRef.current
@@ -94,7 +85,6 @@ export function Editor({ darkMode }: EditorProps) {
})
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [viewRef])
@@ -1,147 +0,0 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { useSearchStore } from '../../stores/searchStore'
import { useTabStore } from '../../stores/tabStore'
import { findMatches } from '../../lib/searchEngine'
import { ChevronUp, ChevronDown, X } from '../Icons'
export function SearchBar() {
const searchInputRef = useRef<HTMLInputElement>(null)
const isVisible = useSearchStore(s => s.isVisible)
const showReplace = useSearchStore(s => s.showReplace)
const searchText = useSearchStore(s => s.searchText)
const replaceText = useSearchStore(s => s.replaceText)
const matches = useSearchStore(s => s.matches)
const currentIndex = useSearchStore(s => s.currentIndex)
const options = useSearchStore(s => s.options)
const setSearchText = useSearchStore(s => s.setSearchText)
const setReplaceText = useSearchStore(s => s.setReplaceText)
const setShowReplace = useSearchStore(s => s.setShowReplace)
const setMatches = useSearchStore(s => s.setMatches)
const findNext = useSearchStore(s => s.findNext)
const findPrev = useSearchStore(s => s.findPrev)
const toggleCaseSensitive = useSearchStore(s => s.toggleCaseSensitive)
const toggleRegex = useSearchStore(s => s.toggleRegex)
const close = useSearchStore(s => s.close)
const getActiveTab = useTabStore(s => s.getActiveTab)
const doSearch = useCallback((text: string) => {
const tab = getActiveTab()
if (!tab || !text) {
setMatches([])
return
}
const found = findMatches(tab.content, text, options)
setMatches(found)
}, [getActiveTab, options, setMatches])
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value
setSearchText(text)
// Editor 会监听 searchText 变化并执行搜索和高亮
}, [setSearchText])
const handleReplaceCurrent = useCallback(() => {
const tab = getActiveTab()
if (!tab || matches.length === 0 || currentIndex < 0) return
const m = matches[currentIndex]
const newContent = tab.content.substring(0, m.start) + replaceText + tab.content.substring(m.end)
useTabStore.getState().updateTabContent(tab.id, newContent)
doSearch(searchText)
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
// B-02: 全部替换(从后向前逐个替换)
const handleReplaceAll = useCallback(() => {
const tab = getActiveTab()
if (!tab || matches.length === 0) return
let result = tab.content
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i]
result = result.substring(0, m.start) + replaceText + result.substring(m.end)
}
useTabStore.getState().updateTabContent(tab.id, result)
doSearch(searchText)
}, [getActiveTab, matches, replaceText, searchText, doSearch])
useEffect(() => {
if (isVisible && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [isVisible])
if (!isVisible) return null
return (
<div id="search-bar">
<div className="search-row">
<button
id="btn-toggle-replace"
className={`search-opt-btn ${showReplace ? 'expanded' : ''}`}
onClick={() => setShowReplace(!showReplace)}
title="展开替换行"
>
</button>
<input
ref={searchInputRef}
type="text"
id="search-input"
placeholder="查找..."
value={searchText}
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.shiftKey ? findPrev() : findNext()
}
if (e.key === 'Escape') {
e.preventDefault()
close()
}
}}
/>
<span id="search-count">
{matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : searchText ? '无结果' : ''}
</span>
<button
className={`search-opt-btn ${options.caseSensitive ? 'active' : ''}`}
onClick={toggleCaseSensitive}
title="区分大小写 (Alt+C)"
>
Aa
</button>
<button
className={`search-opt-btn ${options.useRegex ? 'active' : ''}`}
onClick={toggleRegex}
title="正则表达式 (Alt+R)"
>
.*
</button>
<button className="search-nav-btn" onClick={findPrev} title="上一个 (Shift+Enter)">
<ChevronUp size={12} />
</button>
<button className="search-nav-btn" onClick={findNext} title="下一个 (Enter)">
<ChevronDown size={12} />
</button>
<button className="search-nav-btn" onClick={close} title="关闭 (Escape)">
<X size={14} />
</button>
</div>
{showReplace && (
<div id="replace-row">
<input
type="text"
id="replace-input"
placeholder="替换..."
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
/>
<button className="replace-btn" onClick={handleReplaceCurrent} title="替换 (Ctrl+Shift+G)"></button>
<button className="replace-btn" onClick={handleReplaceAll} title="全部替换 (Ctrl+Shift+H)"></button>
</div>
)}
</div>
)
}
-12
View File
@@ -1,13 +1,11 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { useSearchStore } from '../stores/searchStore'
import type { ViewMode } from '../types/settings'
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const setViewMode = useEditorStore(s => s.setViewMode)
// M-08: 使用 getState() 而非依赖 tabs/activeTabId 等频繁变化的值
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
@@ -18,7 +16,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
// M-08: 通过 getState() 读取最新状态
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
if (isCtrl && e.key === 'w') {
@@ -47,15 +44,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
}
return
}
// Search & Replace
const searchState = useSearchStore.getState()
if (isCtrl && e.key === 'f') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(false); return }
if (isCtrl && e.key === 'h') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(true); return }
if (e.key === 'Escape' && searchState.isVisible) { e.preventDefault(); searchState.close(); return }
if (e.altKey && e.key === 'c') { e.preventDefault(); searchState.toggleCaseSensitive(); return }
if (e.altKey && e.key === 'r') { e.preventDefault(); searchState.toggleRegex(); return }
if (isCtrl && e.shiftKey && e.key === 'G') { e.preventDefault(); searchState.findPrev(); return }
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
useEffect(() => {
-56
View File
@@ -1,56 +0,0 @@
import type { SearchMatch, SearchOptions } from '../types/search'
export function findMatches(
content: string,
searchText: string,
options: SearchOptions
): SearchMatch[] {
if (!searchText) return []
const matches: SearchMatch[] = []
if (options.useRegex) {
try {
const flags = options.caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
let m: RegExpExecArray | null
while ((m = regex.exec(content)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length })
if (m[0].length === 0) regex.lastIndex++
}
} catch {
// invalid regex — return empty
}
} else {
const haystack = options.caseSensitive ? content : content.toLowerCase()
const needle = options.caseSensitive ? searchText : searchText.toLowerCase()
let idx = 0
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
matches.push({ start: idx, end: idx + needle.length })
idx += needle.length
}
}
return matches
}
// 构建行首位置缓存(用于 O(log N) 二分查找行号)
export function buildLineStarts(content: string): number[] {
const starts = [0]
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) starts.push(i + 1) // '\n' = 0x0A
}
return starts
}
// 二分查找:返回 pos 所在行号
export function getLineNumber(lineStarts: number[], pos: number): number {
let lo = 0
let hi = lineStarts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (lineStarts[mid] <= pos) lo = mid
else hi = mid - 1
}
return lo
}
-64
View File
@@ -1,64 +0,0 @@
import { create } from 'zustand'
import type { SearchMatch, SearchOptions } from '../types/search'
interface SearchState {
isVisible: boolean
showReplace: boolean
searchText: string
replaceText: string
matches: SearchMatch[]
currentIndex: number
options: SearchOptions
setVisible: (visible: boolean) => void
setShowReplace: (show: boolean) => void
setSearchText: (text: string) => void
setReplaceText: (text: string) => void
setMatches: (matches: SearchMatch[]) => void
setCurrentIndex: (index: number) => void
toggleCaseSensitive: () => void
toggleRegex: () => void
findNext: () => void
findPrev: () => void
close: () => void
}
export const useSearchStore = create<SearchState>((set, get) => ({
isVisible: false,
showReplace: false,
searchText: '',
replaceText: '',
matches: [],
currentIndex: -1,
options: { caseSensitive: false, useRegex: false },
setVisible: (visible) => set({ isVisible: visible }),
setShowReplace: (show) => set({ showReplace: show }),
setSearchText: (text) => set({ searchText: text }),
setReplaceText: (text) => set({ replaceText: text }),
setMatches: (matches) => set({ matches, currentIndex: matches.length > 0 ? 0 : -1 }),
setCurrentIndex: (index) => set({ currentIndex: index }),
toggleCaseSensitive: () =>
set(state => ({ options: { ...state.options, caseSensitive: !state.options.caseSensitive } })),
toggleRegex: () =>
set(state => ({ options: { ...state.options, useRegex: !state.options.useRegex } })),
findNext: () => {
const { matches, currentIndex } = get()
if (matches.length === 0) return
set({ currentIndex: (currentIndex + 1) % matches.length })
},
findPrev: () => {
const { matches, currentIndex } = get()
if (matches.length === 0) return
set({ currentIndex: (currentIndex - 1 + matches.length) % matches.length })
},
close: () =>
set({
isVisible: false,
showReplace: false,
searchText: '',
replaceText: '',
matches: [],
currentIndex: -1
})
}))
+13 -113
View File
@@ -208,6 +208,19 @@ html, body {
/* ===== CodeMirror 搜索样式美化 ===== */
/* 搜索面板移到顶部 */
.cm-panels {
top: 0 !important;
bottom: auto !important;
}
.cm-panels-bottom {
top: 0 !important;
bottom: auto !important;
border-bottom: 1px solid var(--border) !important;
border-top: none !important;
}
/* 搜索面板整体 */
.cm-panel.cm-search {
background: var(--bg) !important;
@@ -984,119 +997,6 @@ html, body {
background: var(--primary);
}
/* Search & Replace Bar */
#btn-toggle-replace {
font-size: 10px;
transition: transform 0.15s ease;
}
#btn-toggle-replace.expanded { transform: rotate(90deg); }
#search-bar {
background: var(--search-bg);
border-bottom: 1px solid var(--search-border);
padding: 6px 10px;
flex-shrink: 0;
}
.search-row, #replace-row {
display: flex;
align-items: center;
gap: 4px;
}
#replace-row { margin-top: 4px; }
#search-input, #replace-input {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: var(--font-ui);
outline: none;
transition: border-color 0.15s ease;
}
#search-input:focus, #replace-input:focus { border-color: var(--primary); }
#search-count {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
min-width: 40px;
text-align: center;
}
.search-opt-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.15s ease;
}
.search-opt-btn:hover { background: var(--bg-tertiary); }
.search-opt-btn.active {
background: var(--primary-light);
color: var(--primary);
border-color: var(--primary);
}
.search-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.search-nav-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.replace-btn {
height: 26px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.replace-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* View Modes */
#app.mode-preview #editor-panel,
#app.mode-preview #resizer { display: none; }
-1
View File
@@ -1,5 +1,4 @@
export type { Tab } from './tab'
export type { FileNode, DirTreeResult } from './file'
export type { ViewMode, Settings } from './settings'
export type { SearchMatch, SearchOptions } from './search'
export type { ElectronAPI, IpcInvokeMap } from './ipc'
-9
View File
@@ -1,9 +0,0 @@
export interface SearchMatch {
start: number
end: number
}
export interface SearchOptions {
caseSensitive: boolean
useRegex: boolean
}