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
+13
View File
@@ -0,0 +1,13 @@
// 常量定义
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
export const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt'])
export const UPDATE_DEBOUNCE_MS = 150
export const LARGE_FILE_LINE_THRESHOLD = 2000
export const CLOSE_TIMEOUT_MS = 5000
export const WATCH_RESTART_DELAY_MS = 300
export const DIR_REFRESH_DEBOUNCE_MS = 300
+20
View File
@@ -0,0 +1,20 @@
import { ALLOWED_EXTENSIONS } from './constants'
export function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
export function getFileName(filePath: string): string {
return filePath.split(/[/\\]/).pop() || filePath
}
export function isAllowedFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
}
export function getFileExtension(filePath: string): string {
return filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
}
+32
View File
@@ -0,0 +1,32 @@
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight'
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img ?? []), ['src']]
}
})
.use(rehypeHighlight)
.use(rehypeStringify)
export async function renderMarkdown(content: string): Promise<string> {
try {
const result = await processor.process(content)
return String(result)
} catch (e) {
return `<p style="color:red">渲染错误: ${e instanceof Error ? e.message : String(e)}</p>`
}
}
+109
View File
@@ -0,0 +1,109 @@
// 滚动同步算法 — 基于块级元素 DOM 位置映射
import type { SearchMatch } from '../types/search'
export interface ScrollMap {
lineToPreviewMap: Float32Array
}
// 识别块级元素起始行
function identifyBlockStarts(lines: string[]): Set<number> {
const starts = new Set<number>()
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trimStart()
// 围栏代码块边界
if (line.startsWith('```')) {
if (!inCodeBlock) starts.add(i)
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) continue
// 块级元素起始行
if (
line.startsWith('#') ||
line.startsWith('- ') ||
line.startsWith('* ') ||
line.startsWith('+ ') ||
/^\d+\.\s/.test(line) ||
line.startsWith('> ') ||
line.startsWith('|') ||
line.startsWith('---') ||
line.startsWith('***') ||
line.startsWith('___') ||
(line === '' && i > 0 && lines[i - 1].trim() === '')
) {
starts.add(i)
}
}
return starts
}
export function buildScrollMap(
markdown: string,
previewContainer: HTMLElement
): ScrollMap {
const lines = markdown.split('\n')
const totalLines = lines.length
const lineToPreviewMap = new Float32Array(totalLines)
// 1. 识别块级元素起始行
const blockStarts = identifyBlockStarts(lines)
// 2. 获取预览 DOM 元素位置
const previewPositions: number[] = []
for (let i = 0; i < previewContainer.children.length; i++) {
previewPositions.push((previewContainer.children[i] as HTMLElement).offsetTop)
}
if (previewPositions.length === 0 || blockStarts.size === 0) {
// fallback: 线性映射
const lastPos = previewPositions[previewPositions.length - 1] || 0
for (let i = 0; i < totalLines; i++) {
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * lastPos
}
return { lineToPreviewMap }
}
// 3. 块起始行 → 预览位置映射
const uniqueStartsArr = [...blockStarts].sort((a, b) => a - b)
const uniqueStartsSet = new Set(uniqueStartsArr)
const domCount = previewPositions.length
for (let i = 0; i < uniqueStartsArr.length; i++) {
const lineIdx = uniqueStartsArr[i]
const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1)
lineToPreviewMap[lineIdx] = previewPositions[domIdx]
}
// 4. 线性插值填充所有行
for (let i = 0; i < totalLines; i++) {
if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue
// 找到前后最近的已映射行
let prevLine = -1
let nextLine = -1
for (let j = i - 1; j >= 0; j--) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break }
}
for (let j = i + 1; j < totalLines; j++) {
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break }
}
if (prevLine === -1 && nextLine === -1) {
lineToPreviewMap[i] = 0
} else if (prevLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[nextLine]
} else if (nextLine === -1) {
lineToPreviewMap[i] = lineToPreviewMap[prevLine]
} else {
const ratio = (i - prevLine) / (nextLine - prevLine)
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine])
}
}
return { lineToPreviewMap }
}
+56
View File
@@ -0,0 +1,56 @@
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
}