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:
+3
-10
@@ -1,25 +1,23 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { useTabStore } from './stores/tabStore'
|
import { useTabStore } from './stores/tabStore'
|
||||||
import { useEditorStore } from './stores/editorStore'
|
import { useEditorStore } from './stores/editorStore'
|
||||||
import { useSearchStore } from './stores/searchStore'
|
|
||||||
import { useTheme } from './hooks/useTheme'
|
import { useTheme } from './hooks/useTheme'
|
||||||
import { useSettings } from './hooks/useSettings'
|
import { useSettings } from './hooks/useSettings'
|
||||||
import { useKeyboard } from './hooks/useKeyboard'
|
import { useKeyboard } from './hooks/useKeyboard'
|
||||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||||
import { useFileWatch } from './hooks/useFileWatch'
|
import { useFileWatch } from './hooks/useFileWatch'
|
||||||
import { recentFilesRepository } from './db/recentFilesRepository'
|
import { recentFilesRepository } from './db/recentFilesRepository'
|
||||||
|
import { useDragDrop } from './hooks/useDragDrop'
|
||||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||||
import { TabBar } from './components/TabBar/TabBar'
|
import { TabBar } from './components/TabBar/TabBar'
|
||||||
import { Editor } from './components/Editor/Editor'
|
import { Editor } from './components/Editor/Editor'
|
||||||
import { Preview } from './components/Preview/Preview'
|
import { Preview } from './components/Preview/Preview'
|
||||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||||
import { SearchBar } from './components/SearchBar/SearchBar'
|
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||||
import { Toast } from './components/Toast/Toast'
|
import { Toast } from './components/Toast/Toast'
|
||||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||||
import { useDragDrop } from './hooks/useDragDrop'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const tabs = useTabStore(s => s.tabs)
|
const tabs = useTabStore(s => s.tabs)
|
||||||
@@ -29,7 +27,6 @@ export default function App() {
|
|||||||
const setModified = useTabStore(s => s.setModified)
|
const setModified = useTabStore(s => s.setModified)
|
||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||||
const saveToDB = useTabStore(s => s.saveToDB)
|
|
||||||
|
|
||||||
const viewMode = useEditorStore(s => s.viewMode)
|
const viewMode = useEditorStore(s => s.viewMode)
|
||||||
const splitRatio = useEditorStore(s => s.splitRatio)
|
const splitRatio = useEditorStore(s => s.splitRatio)
|
||||||
@@ -80,9 +77,7 @@ export default function App() {
|
|||||||
const result = await window.electronAPI.openFile()
|
const result = await window.electronAPI.openFile()
|
||||||
if (result && 'filePath' in result) {
|
if (result && 'filePath' in result) {
|
||||||
createTab(result.filePath, result.content)
|
createTab(result.filePath, result.content)
|
||||||
// 追踪最近打开文件
|
|
||||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||||
// 保存标签页到 DB
|
|
||||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +132,7 @@ export default function App() {
|
|||||||
saveViewMode(mode)
|
saveViewMode(mode)
|
||||||
}, [saveViewMode])
|
}, [saveViewMode])
|
||||||
|
|
||||||
// M-11: 主进程事件注册(使用 ref 保持最新回调引用 + cleanup 清理)
|
// M-11: 主进程事件注册
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
const handleSaveAsRef = useRef(handleSaveAs)
|
const handleSaveAsRef = useRef(handleSaveAs)
|
||||||
handleSaveRef.current = handleSave
|
handleSaveRef.current = handleSave
|
||||||
@@ -149,7 +144,7 @@ export default function App() {
|
|||||||
|
|
||||||
const onOpen = (data: { filePath: string; content: string }) => {
|
const onOpen = (data: { filePath: string; content: string }) => {
|
||||||
createTab(data.filePath, data.content)
|
createTab(data.filePath, data.content)
|
||||||
recentFilesRepository.add(data.filePath)
|
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||||
}
|
}
|
||||||
const onSave = () => handleSaveRef.current()
|
const onSave = () => handleSaveRef.current()
|
||||||
@@ -218,7 +213,6 @@ export default function App() {
|
|||||||
<div id="content-wrapper">
|
<div id="content-wrapper">
|
||||||
{viewMode !== 'preview' && (
|
{viewMode !== 'preview' && (
|
||||||
<div id="editor-panel" style={editorStyle}>
|
<div id="editor-panel" style={editorStyle}>
|
||||||
<SearchBar />
|
|
||||||
<Editor darkMode={darkMode} />
|
<Editor darkMode={darkMode} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -270,7 +264,6 @@ function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
|
|||||||
document.removeEventListener('mouseup', handleMouseUp)
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
// 保存最终比例
|
|
||||||
const wrapper = document.getElementById('content-wrapper')
|
const wrapper = document.getElementById('content-wrapper')
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
const rect = wrapper.getBoundingClientRect()
|
const rect = wrapper.getBoundingClientRect()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useCallback } from 'react'
|
import React, { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useSearchStore } from '../../stores/searchStore'
|
|
||||||
import { useCodeMirror } from './useCodeMirror'
|
import { useCodeMirror } from './useCodeMirror'
|
||||||
import { EditorToolbar } from './EditorToolbar'
|
import { EditorToolbar } from './EditorToolbar'
|
||||||
|
|
||||||
@@ -20,12 +19,10 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
containerRef,
|
containerRef,
|
||||||
viewRef,
|
viewRef,
|
||||||
setContent,
|
setContent,
|
||||||
getContent,
|
|
||||||
getScrollTop,
|
getScrollTop,
|
||||||
setScrollTop,
|
setScrollTop,
|
||||||
getSelection,
|
getSelection,
|
||||||
setSelection,
|
setSelection
|
||||||
scrollTo
|
|
||||||
} = useCodeMirror({
|
} = useCodeMirror({
|
||||||
content: activeTab?.content ?? '',
|
content: activeTab?.content ?? '',
|
||||||
onChange: useCallback((value: string) => {
|
onChange: useCallback((value: string) => {
|
||||||
@@ -43,15 +40,13 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return
|
if (!activeTab) return
|
||||||
setContent(activeTab.content)
|
setContent(activeTab.content)
|
||||||
|
|
||||||
// 恢复滚动和选区
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setScrollTop(activeTab.scrollTop)
|
setScrollTop(activeTab.scrollTop)
|
||||||
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
setSelection(activeTab.selectionStart, activeTab.selectionEnd)
|
||||||
})
|
})
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// 保存当前标签状态(切换前)
|
// 保存当前标签状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (!activeTabId) return
|
if (!activeTabId) return
|
||||||
@@ -63,12 +58,10 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
}
|
}
|
||||||
}, [activeTabId])
|
}, [activeTabId])
|
||||||
|
|
||||||
// 搜索:Ctrl+F 聚焦搜索框,Enter 导航
|
// Ctrl+B 粗体, Ctrl+I 斜体
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
// Ctrl+B 粗体
|
|
||||||
if (isCtrl && e.key === 'b') {
|
if (isCtrl && e.key === 'b') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
@@ -80,8 +73,6 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
selection: { anchor: from + 2, head: from + 2 + selected.length }
|
selection: { anchor: from + 2, head: from + 2 + selected.length }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+I 斜体
|
|
||||||
if (isCtrl && e.key === 'i') {
|
if (isCtrl && e.key === 'i') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
@@ -94,7 +85,6 @@ export function Editor({ darkMode }: EditorProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [viewRef])
|
}, [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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { useSearchStore } from '../stores/searchStore'
|
|
||||||
import type { ViewMode } from '../types/settings'
|
import type { ViewMode } from '../types/settings'
|
||||||
|
|
||||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||||
|
|
||||||
// M-08: 使用 getState() 而非依赖 tabs/activeTabId 等频繁变化的值
|
|
||||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
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 === '2') { e.preventDefault(); setViewMode('editor'); return }
|
||||||
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
|
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
|
||||||
|
|
||||||
// M-08: 通过 getState() 读取最新状态
|
|
||||||
const tabState = useTabStore.getState()
|
const tabState = useTabStore.getState()
|
||||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||||
if (isCtrl && e.key === 'w') {
|
if (isCtrl && e.key === 'w') {
|
||||||
@@ -47,15 +44,6 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
|
|||||||
}
|
}
|
||||||
return
|
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])
|
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -208,6 +208,19 @@ html, body {
|
|||||||
|
|
||||||
/* ===== CodeMirror 搜索样式美化 ===== */
|
/* ===== 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 {
|
.cm-panel.cm-search {
|
||||||
background: var(--bg) !important;
|
background: var(--bg) !important;
|
||||||
@@ -984,119 +997,6 @@ html, body {
|
|||||||
background: var(--primary);
|
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 */
|
/* View Modes */
|
||||||
#app.mode-preview #editor-panel,
|
#app.mode-preview #editor-panel,
|
||||||
#app.mode-preview #resizer { display: none; }
|
#app.mode-preview #resizer { display: none; }
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export type { Tab } from './tab'
|
export type { Tab } from './tab'
|
||||||
export type { FileNode, DirTreeResult } from './file'
|
export type { FileNode, DirTreeResult } from './file'
|
||||||
export type { ViewMode, Settings } from './settings'
|
export type { ViewMode, Settings } from './settings'
|
||||||
export type { SearchMatch, SearchOptions } from './search'
|
|
||||||
export type { ElectronAPI, IpcInvokeMap } from './ipc'
|
export type { ElectronAPI, IpcInvokeMap } from './ipc'
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export interface SearchMatch {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchOptions {
|
|
||||||
caseSensitive: boolean
|
|
||||||
useRegex: boolean
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user