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:
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user