docs: 添加 TypeScript + React + IndexedDB 重构方案
This commit is contained in:
@@ -0,0 +1,825 @@
|
|||||||
|
# MarkLite 重构方案:TypeScript + React + IndexedDB
|
||||||
|
|
||||||
|
## 一、重构目标与原则
|
||||||
|
|
||||||
|
| 维度 | 现状 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 语言 | JavaScript (无类型) | **TypeScript 5.x** (全量类型) |
|
||||||
|
| UI 框架 | 原生 DOM 操作 | **React 18** (函数组件 + Hooks) |
|
||||||
|
| 状态管理 | 全局变量 + 闭包 | **Zustand** (轻量状态管理) |
|
||||||
|
| 持久化 | localStorage | **IndexedDB** (via Dexie.js) |
|
||||||
|
| 样式 | 纯 CSS | **CSS Modules** + CSS 变量 |
|
||||||
|
| 构建 | 无打包 | **Vite** + electron-vite |
|
||||||
|
| Markdown | marked.min.js (离线) | **unified/remark/rehype** 生态 |
|
||||||
|
| 代码高亮 | highlight.js (离线) | **Shiki** 或 **rehype-highlight** |
|
||||||
|
| 编辑器 | `<textarea>` | **CodeMirror 6** (专业编辑器) |
|
||||||
|
|
||||||
|
**核心原则**:
|
||||||
|
|
||||||
|
1. **功能 100% 兼容** — 不丢失任何现有功能
|
||||||
|
2. **渐进式重构** — 主进程/预加载脚本改动最小,重点重构渲染进程
|
||||||
|
3. **类型安全** — 所有接口、IPC 通信、状态都有 TypeScript 类型
|
||||||
|
4. **可测试性** — 业务逻辑与 UI 解耦,便于单元测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、新项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MarkLite/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json # TypeScript 配置
|
||||||
|
├── electron.vite.config.ts # electron-vite 构建配置
|
||||||
|
├── .eslintrc.cjs # ESLint + TS 规则
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── main/ # ===== 主进程 =====
|
||||||
|
│ │ ├── index.ts # 入口:窗口创建、app 生命周期
|
||||||
|
│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册
|
||||||
|
│ │ ├── file-system.ts # 文件读写、目录树构建
|
||||||
|
│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录)
|
||||||
|
│ │ ├── window-manager.ts # 窗口创建、关闭拦截、单实例锁
|
||||||
|
│ │ └── menu.ts # 原生菜单(可选)
|
||||||
|
│ │
|
||||||
|
│ ├── preload/ # ===== 预加载 =====
|
||||||
|
│ │ └── index.ts # contextBridge 类型安全暴露
|
||||||
|
│ │
|
||||||
|
│ ├── renderer/ # ===== 渲染进程 (React) =====
|
||||||
|
│ │ ├── index.html # 入口 HTML
|
||||||
|
│ │ ├── main.tsx # React 入口 (createRoot)
|
||||||
|
│ │ ├── App.tsx # 根组件:布局编排
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── components/ # ===== UI 组件 =====
|
||||||
|
│ │ │ ├── Toolbar/
|
||||||
|
│ │ │ │ ├── Toolbar.tsx
|
||||||
|
│ │ │ │ └── Toolbar.module.css
|
||||||
|
│ │ │ ├── TabBar/
|
||||||
|
│ │ │ │ ├── TabBar.tsx
|
||||||
|
│ │ │ │ ├── TabItem.tsx
|
||||||
|
│ │ │ │ └── TabBar.module.css
|
||||||
|
│ │ │ ├── Editor/
|
||||||
|
│ │ │ │ ├── Editor.tsx # CodeMirror 6 封装
|
||||||
|
│ │ │ │ ├── EditorToolbar.tsx # 格式化工具栏(加粗/斜体/链接等)
|
||||||
|
│ │ │ │ ├── useCodeMirror.ts # CodeMirror 初始化 Hook
|
||||||
|
│ │ │ │ └── Editor.module.css
|
||||||
|
│ │ │ ├── Preview/
|
||||||
|
│ │ │ │ ├── Preview.tsx # Markdown 渲染面板
|
||||||
|
│ │ │ │ ├── MarkdownRenderer.tsx # rehype/remark 处理
|
||||||
|
│ │ │ │ ├── useScrollSync.ts # 滚动同步 Hook
|
||||||
|
│ │ │ │ └── Preview.module.css
|
||||||
|
│ │ │ ├── Sidebar/
|
||||||
|
│ │ │ │ ├── Sidebar.tsx
|
||||||
|
│ │ │ │ ├── FileTree.tsx # 文件树递归组件
|
||||||
|
│ │ │ │ ├── FileTreeItem.tsx
|
||||||
|
│ │ │ │ ├── IndependentFiles.tsx
|
||||||
|
│ │ │ │ └── Sidebar.module.css
|
||||||
|
│ │ │ ├── SearchBar/
|
||||||
|
│ │ │ │ ├── SearchBar.tsx
|
||||||
|
│ │ │ │ └── SearchBar.module.css
|
||||||
|
│ │ │ ├── StatusBar/
|
||||||
|
│ │ │ │ ├── StatusBar.tsx
|
||||||
|
│ │ │ │ └── StatusBar.module.css
|
||||||
|
│ │ │ ├── WelcomeScreen/
|
||||||
|
│ │ │ │ ├── WelcomeScreen.tsx
|
||||||
|
│ │ │ │ └── WelcomeScreen.module.css
|
||||||
|
│ │ │ ├── ModifiedBanner/
|
||||||
|
│ │ │ │ └── ModifiedBanner.tsx
|
||||||
|
│ │ │ ├── DropOverlay/
|
||||||
|
│ │ │ │ └── DropOverlay.tsx
|
||||||
|
│ │ │ └── Toast/
|
||||||
|
│ │ │ ├── Toast.tsx
|
||||||
|
│ │ │ └── Toast.module.css
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── stores/ # ===== 状态管理 (Zustand) =====
|
||||||
|
│ │ │ ├── tabStore.ts # 标签页状态
|
||||||
|
│ │ │ ├── editorStore.ts # 编辑器状态(视图模式、暗色主题等)
|
||||||
|
│ │ │ ├── sidebarStore.ts # 侧边栏状态
|
||||||
|
│ │ │ └── searchStore.ts # 搜索替换状态
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── hooks/ # ===== 自定义 Hooks =====
|
||||||
|
│ │ │ ├── useTheme.ts # 暗色/亮色主题
|
||||||
|
│ │ │ ├── useKeyboard.ts # 全局快捷键
|
||||||
|
│ │ │ ├── useDragDrop.ts # 拖拽打开
|
||||||
|
│ │ │ ├── useFileWatch.ts # 外部修改监听
|
||||||
|
│ │ │ ├── useUnsavedWarning.ts # 未保存提醒
|
||||||
|
│ │ │ └── useSettings.ts # 用户设置读写
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── lib/ # ===== 工具库 =====
|
||||||
|
│ │ │ ├── markdown.ts # Markdown 渲染管线(remark + rehype)
|
||||||
|
│ │ │ ├── sanitize.ts # HTML 安全过滤
|
||||||
|
│ │ │ ├── scrollSync.ts # 滚动同步算法(行号→DOM 映射)
|
||||||
|
│ │ │ ├── searchEngine.ts # 搜索匹配引擎(普通/正则/大小写)
|
||||||
|
│ │ │ ├── fileUtils.ts # 文件大小格式化、扩展名检查等
|
||||||
|
│ │ │ └── constants.ts # 常量定义(文件大小限制、扩展名列表等)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── db/ # ===== IndexedDB 层 =====
|
||||||
|
│ │ │ ├── schema.ts # Dexie 数据库定义 + 类型
|
||||||
|
│ │ │ ├── tabRepository.ts # 标签页状态 CRUD
|
||||||
|
│ │ │ ├── settingsRepository.ts # 用户设置 CRUD
|
||||||
|
│ │ │ └── recentFilesRepository.ts # 最近打开文件
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── types/ # ===== 全局类型 =====
|
||||||
|
│ │ │ ├── tab.ts # Tab 接口
|
||||||
|
│ │ │ ├── file.ts # FileNode, DirTree 等
|
||||||
|
│ │ │ ├── settings.ts # Settings 接口
|
||||||
|
│ │ │ ├── search.ts # SearchMatch, SearchOptions 等
|
||||||
|
│ │ │ └── ipc.ts # IPC 通道类型映射
|
||||||
|
│ │ │
|
||||||
|
│ │ └── styles/ # ===== 全局样式 =====
|
||||||
|
│ │ ├── variables.css # CSS 变量(亮色/暗色)
|
||||||
|
│ │ ├── global.css # 全局 reset + 通用样式
|
||||||
|
│ │ ├── markdown-body.css # Markdown 预览样式
|
||||||
|
│ │ └── code-theme.css # 代码高亮主题
|
||||||
|
│ │
|
||||||
|
│ └── shared/ # ===== 主进程/渲染进程共享 =====
|
||||||
|
│ ├── ipc-channels.ts # IPC 通道名常量
|
||||||
|
│ └── types.ts # 共享类型定义
|
||||||
|
│
|
||||||
|
├── assets/
|
||||||
|
│ └── icon.ico
|
||||||
|
├── DESIGN.md
|
||||||
|
├── DEVSETUP.md
|
||||||
|
├── README.md
|
||||||
|
├── LICENSE
|
||||||
|
└── REFACTOR-PLAN.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术选型详解
|
||||||
|
|
||||||
|
### 1. TypeScript — 类型安全
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/types/tab.ts
|
||||||
|
export interface Tab {
|
||||||
|
id: string; // nanoid 生成
|
||||||
|
filePath: string | null;
|
||||||
|
content: string;
|
||||||
|
isModified: boolean;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
selectionStart: number;
|
||||||
|
selectionEnd: number;
|
||||||
|
previewScrollTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/renderer/types/ipc.ts — IPC 通道类型映射
|
||||||
|
export interface IpcInvokeMap {
|
||||||
|
'dialog:openFile': [void, OpenFileResult | null];
|
||||||
|
'file:read': [string, ReadFileResult];
|
||||||
|
'file:save': [SaveFilePayload, SaveFileResult];
|
||||||
|
'file:saveAs': [SaveAsPayload, SaveFileResult];
|
||||||
|
'file:reload': [void, ReloadFileResult];
|
||||||
|
'dir:readTree': [string, ReadDirTreeResult];
|
||||||
|
'dir:openDialog': [void, string | null];
|
||||||
|
'dir:watch': [string, void];
|
||||||
|
'tab:switched': [string | null, void];
|
||||||
|
'window:forceClose': [void, void];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型安全的 IPC 调用封装
|
||||||
|
export function invoke<K extends keyof IpcInvokeMap>(
|
||||||
|
channel: K,
|
||||||
|
...args: IpcInvokeMap[K][0] extends void ? [] : [IpcInvokeMap[K][0]]
|
||||||
|
): Promise<IpcInvokeMap[K][1]> {
|
||||||
|
return window.electronAPI.invoke(channel, ...args);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Zustand — 状态管理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/stores/tabStore.ts
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { Tab } from '../types/tab';
|
||||||
|
|
||||||
|
interface TabState {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
mruStack: string[];
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: nanoid(),
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
activeTabId: tabId,
|
||||||
|
mruStack: [
|
||||||
|
state.activeTabId!,
|
||||||
|
...state.mruStack.filter(id => id !== state.activeTabId),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. IndexedDB (Dexie.js) — 持久化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/db/schema.ts
|
||||||
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
|
|
||||||
|
interface TabSnapshot {
|
||||||
|
id: string; // 与 Tab.id 对应
|
||||||
|
filePath: string | null;
|
||||||
|
content: string;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
selectionStart: number;
|
||||||
|
selectionEnd: number;
|
||||||
|
previewScrollTop: number;
|
||||||
|
isModified: boolean;
|
||||||
|
updatedAt: number; // 时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
id: string; // 固定为 'default'
|
||||||
|
darkMode: boolean;
|
||||||
|
viewMode: 'split' | 'editor' | 'preview';
|
||||||
|
splitRatio: number;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
sidebarWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentFile {
|
||||||
|
id?: number; // 自增主键
|
||||||
|
filePath: string;
|
||||||
|
lastOpened: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Dexie('MarkLite') as Dexie & {
|
||||||
|
tabSnapshots: EntityTable<TabSnapshot, 'id'>;
|
||||||
|
settings: EntityTable<Settings, 'id'>;
|
||||||
|
recentFiles: EntityTable<RecentFile, 'id'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
db.version(1).stores({
|
||||||
|
tabSnapshots: 'id, filePath, updatedAt',
|
||||||
|
settings: 'id',
|
||||||
|
recentFiles: '++id, filePath, lastOpened',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { db };
|
||||||
|
export type { TabSnapshot, Settings, RecentFile };
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/db/tabRepository.ts
|
||||||
|
import { db, type TabSnapshot } from './schema';
|
||||||
|
|
||||||
|
export const tabRepository = {
|
||||||
|
/** 保存所有标签快照(关闭时/定期) */
|
||||||
|
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||||
|
await db.transaction('rw', db.tabSnapshots, async () => {
|
||||||
|
await db.tabSnapshots.clear();
|
||||||
|
await db.tabSnapshots.bulkAdd(tabs);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 加载上次的标签快照 */
|
||||||
|
async loadAll(): Promise<TabSnapshot[]> {
|
||||||
|
return db.tabSnapshots.orderBy('updatedAt').toArray();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清除所有快照(用户主动清除时) */
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
await db.tabSnapshots.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. CodeMirror 6 — 编辑器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/components/Editor/useCodeMirror.ts
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { EditorState, type Extension } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view';
|
||||||
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
|
||||||
|
interface UseCodeMirrorOptions {
|
||||||
|
content: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
darkMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
history(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||||
|
markdown({ base: markdownLanguage }),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (darkMode) extensions.push(oneDark);
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: content,
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: containerRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, [darkMode]); // darkMode 变化时重建
|
||||||
|
|
||||||
|
// 外部内容更新(切换标签时)
|
||||||
|
const setContent = useCallback((newContent: string) => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (current !== newContent) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: newContent },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { containerRef, viewRef, setContent };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Markdown 渲染管线
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/lib/markdown.ts
|
||||||
|
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> {
|
||||||
|
const result = await processor.process(content);
|
||||||
|
return String(result);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、IndexedDB 替代 localStorage 的数据模型
|
||||||
|
|
||||||
|
| 原 localStorage Key | IndexedDB Store | 字段 | 说明 |
|
||||||
|
|---------------------|-----------------|------|------|
|
||||||
|
| `marklite-settings` | `settings` | darkMode, viewMode, splitRatio, sidebarCollapsed, sidebarWidth | 用户偏好 |
|
||||||
|
| *(无,标签状态丢失)* | `tabSnapshots` | id, filePath, content, scrollTop, ... | **新增**:标签页状态持久化 |
|
||||||
|
| *(无)* | `recentFiles` | filePath, lastOpened | **新增**:最近打开文件列表 |
|
||||||
|
|
||||||
|
**IndexedDB 优势**:
|
||||||
|
|
||||||
|
- 存储容量大(localStorage 约 5-10MB,IndexedDB 可达数百 MB)
|
||||||
|
- 支持大文件内容缓存(当前 20MB 限制的文件也能存)
|
||||||
|
- 支持索引查询(按 filePath、updatedAt 查询)
|
||||||
|
- 异步 API,不阻塞 UI
|
||||||
|
- 标签页状态可持久化(关闭窗口后恢复所有标签)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、状态管理架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ App.tsx (根组件) │
|
||||||
|
├─────────┬──────────┬──────────┬──────────┬──────────────┤
|
||||||
|
│Toolbar │ TabBar │ Sidebar │ Editor │ Preview │
|
||||||
|
│ │ │ │ (CM6) │ │
|
||||||
|
├─────────┴──────────┴──────────┴──────────┴──────────────┤
|
||||||
|
│ Zustand Stores │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ tabStore │ │editorStore│ │sidebarStore│ │searchStore│ │
|
||||||
|
│ │ - tabs │ │- viewMode│ │- tree │ │- matches │ │
|
||||||
|
│ │- activeId│ │- darkMode│ │- expanded │ │- index │ │
|
||||||
|
│ │ - mru │ │- splitPct│ │- rootPath │ │- options │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
||||||
|
│ │ IndexedDB (Dexie.js) │ │
|
||||||
|
│ │ tabSnapshots │ settings │ recentFiles │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、主进程重构(改动最小)
|
||||||
|
|
||||||
|
主进程保持 Electron 原生,仅将 JS → TS,逻辑不变:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/main/file-system.ts — 类型安全的文件操作
|
||||||
|
import { readFile, writeFile, stat, readdir } from 'fs/promises';
|
||||||
|
import { join, extname } from 'path';
|
||||||
|
import type { FileNode, ReadFileResult, SaveFileResult } from '../shared/types';
|
||||||
|
|
||||||
|
const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const;
|
||||||
|
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
|
||||||
|
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
return { success: false, error: `文件过大 (${formatBytes(stats.size)})` };
|
||||||
|
}
|
||||||
|
let content = await readFile(filePath, 'utf-8');
|
||||||
|
// 去除 BOM
|
||||||
|
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1);
|
||||||
|
return { success: true, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDirTree(dirPath: string): Promise<FileNode[]> {
|
||||||
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
||||||
|
const result: FileNode[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (shouldSkip(entry.name)) continue;
|
||||||
|
const fullPath = join(dirPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
result.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: fullPath,
|
||||||
|
type: 'dir',
|
||||||
|
children: await buildDirTree(fullPath),
|
||||||
|
});
|
||||||
|
} else if (ALLOWED_EXTENSIONS.includes(extname(entry.name).toLowerCase() as any)) {
|
||||||
|
result.push({ name: entry.name, path: fullPath, type: 'file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、滚动同步算法(保留现有逻辑)
|
||||||
|
|
||||||
|
现有基于**块级元素 DOM 位置映射**的算法非常优秀,直接用 TypeScript 重写:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/lib/scrollSync.ts
|
||||||
|
interface ScrollMap {
|
||||||
|
lineToPreviewMap: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScrollMap(
|
||||||
|
markdown: string,
|
||||||
|
previewContainer: HTMLElement
|
||||||
|
): ScrollMap {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
const totalLines = lines.length;
|
||||||
|
const lineToPreviewMap = new Array(totalLines).fill(0);
|
||||||
|
|
||||||
|
// 1. 识别块级元素起始行
|
||||||
|
const blockStarts = identifyBlockStarts(lines);
|
||||||
|
|
||||||
|
// 2. 获取预览 DOM 元素位置
|
||||||
|
const previewPositions = getPreviewPositions(previewContainer);
|
||||||
|
|
||||||
|
// 3. 块起始行 → 预览位置映射
|
||||||
|
// 4. 线性插值填充中间行
|
||||||
|
interpolateBlockPositions(blockStarts, previewPositions, lineToPreviewMap, totalLines);
|
||||||
|
|
||||||
|
return { lineToPreviewMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
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].trim();
|
||||||
|
if (line.match(/^```/)) {
|
||||||
|
if (!inCodeBlock) starts.add(i);
|
||||||
|
inCodeBlock = !inCodeBlock;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCodeBlock) continue;
|
||||||
|
if (line.match(/^#{1,6}\s/) || line.match(/^[-*+]\s/) || line.match(/^\d+\.\s/) ||
|
||||||
|
line.match(/^>/) || line.match(/^\|/) || line.match(/^---/) || line === '') {
|
||||||
|
starts.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、CSS Modules + CSS 变量
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* src/renderer/styles/variables.css */
|
||||||
|
:root {
|
||||||
|
/* 亮色主题 */
|
||||||
|
--color-primary: #1a73e8;
|
||||||
|
--color-primary-dark: #1557b0;
|
||||||
|
--color-primary-light: rgba(26, 115, 232, 0.1);
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-secondary: #f8f9fa;
|
||||||
|
--color-bg-tertiary: #f1f3f4;
|
||||||
|
--color-text: #333333;
|
||||||
|
--color-text-secondary: #5f6368;
|
||||||
|
--color-text-tertiary: #80868b;
|
||||||
|
--color-border: #e1e4e8;
|
||||||
|
--color-border-light: #f0f0f0;
|
||||||
|
--color-code-bg: #f6f8fa;
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
--toolbar-height: 36px;
|
||||||
|
--tabbar-height: 34px;
|
||||||
|
--statusbar-height: 24px;
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace;
|
||||||
|
--font-size: 14px;
|
||||||
|
|
||||||
|
/* 圆角/阴影 */
|
||||||
|
--radius: 6px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
--shadow-lg: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
--color-primary: #8ab4f8;
|
||||||
|
--color-primary-dark: #669df6;
|
||||||
|
--color-primary-light: rgba(138, 180, 248, 0.15);
|
||||||
|
--color-bg: #1e1e1e;
|
||||||
|
--color-bg-secondary: #252526;
|
||||||
|
--color-bg-tertiary: #2d2d2d;
|
||||||
|
--color-text: #d4d4d4;
|
||||||
|
--color-text-secondary: #9e9e9e;
|
||||||
|
--color-text-tertiary: #6e6e6e;
|
||||||
|
--color-border: #3e3e3e;
|
||||||
|
--color-border-light: #333333;
|
||||||
|
--color-code-bg: #2d2d2d;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、构建配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// electron.vite.config.ts
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/main',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: 'src/main/index.ts' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: 'src/preload/index.ts' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
plugins: [react()],
|
||||||
|
root: 'src/renderer',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/renderer',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: 'src/renderer/index.html' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json (关键 scripts)
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build && electron-builder",
|
||||||
|
"build:portable": "electron-vite build && electron-builder --win portable",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、依赖清单
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3",
|
||||||
|
"react-dom": "^18.3",
|
||||||
|
"zustand": "^5.0",
|
||||||
|
"dexie": "^4.0",
|
||||||
|
"nanoid": "^5.0",
|
||||||
|
"@codemirror/view": "^6.35",
|
||||||
|
"@codemirror/state": "^6.5",
|
||||||
|
"@codemirror/lang-markdown": "^6.3",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1",
|
||||||
|
"@codemirror/commands": "^6.7",
|
||||||
|
"@codemirror/language": "^6.10",
|
||||||
|
"unified": "^11.0",
|
||||||
|
"remark-parse": "^11.0",
|
||||||
|
"remark-gfm": "^4.0",
|
||||||
|
"remark-rehype": "^11.1",
|
||||||
|
"rehype-raw": "^7.0",
|
||||||
|
"rehype-sanitize": "^6.0",
|
||||||
|
"rehype-stringify": "^10.0",
|
||||||
|
"rehype-highlight": "^7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.0",
|
||||||
|
"electron-builder": "^25.0",
|
||||||
|
"electron-vite": "^3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3",
|
||||||
|
"typescript": "^5.6",
|
||||||
|
"@types/react": "^18.3",
|
||||||
|
"@types/react-dom": "^18.3",
|
||||||
|
"eslint": "^9.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、分阶段实施计划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预估工时 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **Phase 0** | 脚手架搭建:electron-vite + React + TypeScript + ESLint | 0.5 天 |
|
||||||
|
| **Phase 1** | 主进程 TypeScript 化(文件系统、IPC、窗口管理) | 1 天 |
|
||||||
|
| **Phase 2** | IndexedDB 层 + Zustand stores + 设置持久化 | 1 天 |
|
||||||
|
| **Phase 3** | 基础 UI:App 布局 + Toolbar + TabBar + StatusBar | 1 天 |
|
||||||
|
| **Phase 4** | CodeMirror 6 编辑器集成 + 暗色主题 | 1 天 |
|
||||||
|
| **Phase 5** | Markdown 预览(unified/rehype 管线)+ 滚动同步 | 1.5 天 |
|
||||||
|
| **Phase 6** | 侧边栏文件树 + 独立文件区 | 1 天 |
|
||||||
|
| **Phase 7** | 搜索替换(引擎 + 高亮 + 快捷键) | 1 天 |
|
||||||
|
| **Phase 8** | 拖拽打开 + 文件监听 + 未保存提醒 + Toast | 0.5 天 |
|
||||||
|
| **Phase 9** | 测试 + 修复 + 打包验证 | 1 天 |
|
||||||
|
| **总计** | | **约 9.5 天** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、关键决策说明
|
||||||
|
|
||||||
|
| 决策 | 理由 |
|
||||||
|
|------|------|
|
||||||
|
| **CodeMirror 6 替代 textarea** | 原生 undo/redo、语法高亮、行号内置、大文件性能优、扩展性强 |
|
||||||
|
| **Zustand 替代全局变量** | React 原生状态管理,比 Redux 轻量 10 倍,支持 selector 优化 |
|
||||||
|
| **Dexie.js 替代 localStorage** | 异步 API、支持索引、容量大、支持复杂查询 |
|
||||||
|
| **unified/rehype 替代 marked** | 插件化架构、GFM 原生支持、安全过滤内置、社区活跃 |
|
||||||
|
| **electron-vite 替代手动配置** | 开箱即用的 Electron + Vite 集成,HMR 热更新 |
|
||||||
|
| **CSS Modules 替代纯 CSS** | 样式隔离、TypeScript 类型提示、避免类名冲突 |
|
||||||
|
| **主进程保持 Electron 原生** | 主进程改动最小化,降低重构风险 |
|
||||||
Reference in New Issue
Block a user