refactor: v2.0 全量重构 — TypeScript + React + Zustand + IndexedDB

技术栈升级:
- JavaScript → TypeScript 5.6(全量类型安全)
- 原生 DOM → React 18(函数组件 + Hooks)
- 全局变量 → Zustand 5(轻量状态管理)
- localStorage → IndexedDB / Dexie.js 4(大容量、异步、索引)
- marked.js → unified / remark / rehype(插件化渲染管线)
- 无打包 → electron-vite 3(HMR 热更新)
- 纯 CSS → CSS Variables + CSS Modules

新增功能:
- 标签页状态 IndexedDB 持久化(关闭后可恢复)
- 最近打开文件列表
- 大文件虚拟化行号(>2000 行)
- 搜索高亮二分查找优化 O(log N)
- rehype-sanitize HTML 安全过滤

文件结构:
- 7 个源文件 → 51 个模块化文件
- src/main/     主进程(6 文件)
- src/preload/  预加载(1 文件)
- src/renderer/ 渲染进程(42 文件:组件/hooks/stores/lib/db/types/styles)
- src/shared/   共享类型(2 文件)

构建验证:
- TypeScript 检查零错误
- electron-vite build 成功
- 产物:main 15kB + preload 2kB + renderer 1.5MB
This commit is contained in:
thzxx
2026-05-27 20:29:23 +08:00
parent c2cdba546b
commit 5e1c89d280
59 changed files with 4674 additions and 314 deletions
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
export function useDragDrop(showToast: (msg: string) => void) {
const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files) return
let rejected = 0
for (const file of Array.from(files)) {
const filePath = (file as File & { path?: string }).path || file.name
if (!isAllowedFile(filePath)) {
rejected++
continue
}
if (file.size > MAX_FILE_SIZE) {
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
continue
}
if (window.electronAPI) {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content) {
createTab(filePath, result.content)
}
} else {
const reader = new FileReader()
reader.onload = (ev) => {
const content = ev.target?.result as string
createTab(file.name, content)
}
reader.readAsText(file)
}
}
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}
}, [createTab, showToast])
useEffect(() => {
const prevent = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
document.addEventListener('dragenter', prevent)
document.addEventListener('dragleave', prevent)
document.addEventListener('dragover', prevent)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', prevent)
document.removeEventListener('dragleave', prevent)
document.removeEventListener('dragover', prevent)
document.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSearchStore } from '../stores/searchStore'
import { useEditorStore } from '../stores/editorStore'
import { findMatches } from '../lib/searchEngine'
import type { SearchMatch } from '../types/search'
export function useFileWatch() {
const getActiveTab = useTabStore(s => s.getActiveTab)
useEffect(() => {
if (!window.electronAPI) return
window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab()
if (tab && tab.filePath === filePath) {
// 显示修改横幅(通过状态管理触发 UI 更新)
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
}
})
return () => {
window.electronAPI?.removeAllListeners('file:externallyModified')
}
}, [getActiveTab])
}
// 搜索 Hook
export function useSearch() {
const store = useSearchStore()
const doSearch = (content: string) => {
if (!store.searchText) {
store.setMatches([])
return
}
const matches = findMatches(content, store.searchText, store.options)
store.setMatches(matches)
if (matches.length > 0) {
// 找到离光标最近的匹配
store.setCurrentIndex(0)
}
}
const replaceCurrent = (content: string): string | null => {
if (store.matches.length === 0 || store.currentIndex < 0) return null
const m = store.matches[store.currentIndex]
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
}
const replaceAll = (content: string): string => {
if (store.matches.length === 0) return content
let result = ''
let lastEnd = 0
// 从后往前替换
for (let i = store.matches.length - 1; i >= 0; i--) {
const m = store.matches[i]
result = content.substring(lastEnd, m.start) + store.replaceText + result
lastEnd = m.end
}
result = content.substring(0, store.matches[0].start) + result
return result
}
return { ...store, doSearch, replaceCurrent, replaceAll }
}
+124
View File
@@ -0,0 +1,124 @@
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 createTab = useTabStore(s => s.createTab)
const closeTab = useTabStore(s => s.closeTab)
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
const switchToTab = useTabStore(s => s.switchToTab)
const mruStack = useTabStore(s => s.mruStack)
const setViewMode = useEditorStore(s => s.setViewMode)
const searchStore = useSearchStore
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') {
e.preventDefault()
handleOpenFile()
return
}
if (isCtrl && e.key === 's' && !e.shiftKey) {
e.preventDefault()
handleSave()
return
}
if (isCtrl && e.shiftKey && e.key === 'S') {
e.preventDefault()
handleSaveAs()
return
}
if (isCtrl && e.key === '1') {
e.preventDefault()
setViewMode('split')
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 === 't') {
e.preventDefault()
createTab(null, '')
return
}
if (isCtrl && e.key === 'w') {
e.preventDefault()
if (activeTabId) closeTab(activeTabId)
return
}
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
if (tabs.length > 1) {
if (e.shiftKey) {
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
switchToTab(targetId)
}
}
} else {
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = (idx + 1) % tabs.length
switchToTab(tabs[next].id)
}
}
return
}
// Search & Replace
const state = searchStore.getState()
if (isCtrl && e.key === 'f') {
e.preventDefault()
state.setVisible(true)
state.setShowReplace(false)
return
}
if (isCtrl && e.key === 'h') {
e.preventDefault()
state.setVisible(true)
state.setShowReplace(true)
return
}
if (e.key === 'Escape' && state.isVisible) {
e.preventDefault()
state.close()
return
}
if (e.altKey && e.key === 'c') {
e.preventDefault()
state.toggleCaseSensitive()
return
}
if (e.altKey && e.key === 'r') {
e.preventDefault()
state.toggleRegex()
return
}
if (isCtrl && e.shiftKey && e.key === 'G') {
e.preventDefault()
state.findPrev()
return
}
if (isCtrl && e.shiftKey && e.key === 'H') {
e.preventDefault()
// replaceAll 需要外部实现
return
}
}, [handleOpenFile, handleSave, handleSaveAs, createTab, closeTab, activeTabId, tabs, switchToTab, mruStack, setViewMode, searchStore])
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}, [handleKeydown])
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ViewMode } from '../types/settings'
export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
const setViewMode = useEditorStore(s => s.setViewMode)
const setSplitRatio = useEditorStore(s => s.setSplitRatio)
// 初始化
useEffect(() => {
settingsRepository.load().then(settings => {
setViewMode(settings.viewMode)
setSplitRatio(settings.splitRatio)
})
}, [setViewMode, setSplitRatio])
const saveViewMode = (mode: ViewMode) => {
setViewMode(mode)
settingsRepository.save({ viewMode: mode })
}
const saveSplitRatio = (ratio: number) => {
setSplitRatio(ratio)
settingsRepository.save({ splitRatio: ratio })
}
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
export function useTheme() {
const darkMode = useEditorStore(s => s.darkMode)
const setDarkMode = useEditorStore(s => s.setDarkMode)
// 初始化:从 IndexedDB 加载主题设置
useEffect(() => {
settingsRepository.load().then(settings => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
})
}, [setDarkMode])
// 应用主题到 DOM
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode)
settingsRepository.save({ darkMode })
}, [darkMode])
return { darkMode, toggleDarkMode: useEditorStore(s => s.toggleDarkMode) }
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from 'react'
export function useUnsavedWarning(hasUnsaved: () => boolean) {
useEffect(() => {
if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved()) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}
const api = window.electronAPI
api.onConfirmClose(() => {
if (!hasUnsaved()) {
api.forceClose()
return
}
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
api.forceClose()
} else {
api.cancelClose()
}
})
return () => {
api.removeAllListeners('window:confirmClose')
}
}, [hasUnsaved])
}