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
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import type { ViewMode } from '../types/settings'
interface EditorState {
viewMode: ViewMode
darkMode: boolean
splitRatio: number
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
setSplitRatio: (ratio: number) => void
toggleDarkMode: () => void
}
export const useEditorStore = create<EditorState>((set) => ({
viewMode: 'split',
darkMode: false,
splitRatio: 50,
setViewMode: (mode) => set({ viewMode: mode }),
setDarkMode: (dark) => set({ darkMode: dark }),
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
}))
+64
View File
@@ -0,0 +1,64 @@
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
})
}))
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import type { FileNode } from '../types/file'
interface SidebarState {
isVisible: boolean
rootPath: string | null
tree: FileNode[]
expandedDirs: Set<string>
sidebarWidth: number
setVisible: (visible: boolean) => void
setRootPath: (path: string | null) => void
setTree: (tree: FileNode[]) => void
toggleDir: (path: string) => void
setSidebarWidth: (width: number) => void
}
export const useSidebarStore = create<SidebarState>((set) => ({
isVisible: true,
rootPath: null,
tree: [],
expandedDirs: new Set<string>(),
sidebarWidth: 240,
setVisible: (visible) => set({ isVisible: visible }),
setRootPath: (path) => set({ rootPath: path }),
setTree: (tree) => set({ tree }),
toggleDir: (path) =>
set(state => {
const newSet = new Set(state.expandedDirs)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
return { expandedDirs: newSet }
}),
setSidebarWidth: (width) => set({ sidebarWidth: width })
}))
+127
View File
@@ -0,0 +1,127 @@
import { create } from 'zustand'
import type { Tab } from '../types/tab'
interface TabState {
tabs: Tab[]
activeTabId: string | null
mruStack: string[]
createTab: (filePath?: string | null, content?: string) => Tab
closeTab: (tabId: string) => void
switchToTab: (tabId: string) => void
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null
getTabIndex: (tabId: string) => number
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
}
let tabIdCounter = 0
export const useTabStore = create<TabState>((set, get) => ({
tabs: [],
activeTabId: null,
mruStack: [],
createTab: (filePath = null, content = '') => {
// 检查是否已打开
if (filePath) {
const existing = get().tabs.find(t => t.filePath === filePath)
if (existing) {
get().switchToTab(existing.id)
return existing
}
}
const tab: Tab = {
id: String(++tabIdCounter),
filePath,
content,
isModified: false,
scrollTop: 0,
scrollLeft: 0,
selectionStart: 0,
selectionEnd: 0,
previewScrollTop: 0
}
set(state => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id
}))
return tab
},
closeTab: (tabId) => {
set(state => {
const index = state.tabs.findIndex(t => t.id === tabId)
if (index === -1) return state
const newTabs = state.tabs.filter(t => t.id !== tabId)
const newMru = state.mruStack.filter(id => id !== tabId)
let newActiveId = state.activeTabId
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null
} else {
const newIndex = Math.min(index, newTabs.length - 1)
newActiveId = newTabs[newIndex].id
}
}
return {
tabs: newTabs,
activeTabId: newActiveId,
mruStack: newMru
}
})
},
switchToTab: (tabId) => {
set(state => {
if (state.activeTabId === tabId) return state
const newMru = state.activeTabId
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
: state.mruStack
return {
activeTabId: tabId,
mruStack: newMru
}
})
},
updateTabContent: (tabId, content) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, content, isModified: true } : t
)
}))
},
setModified: (tabId, modified) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, isModified: modified } : t
)
}))
},
getActiveTab: () => {
const { tabs, activeTabId } = get()
return tabs.find(t => t.id === activeTabId) ?? null
},
getTabIndex: (tabId) => {
return get().tabs.findIndex(t => t.id === tabId)
},
updateTabScroll: (tabId, scroll) => {
set(state => ({
tabs: state.tabs.map(t =>
t.id === tabId ? { ...t, ...scroll } : t
)
}))
}
}))