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,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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user