Files
MarkLite/REFACTOR-PLAN.md
T

27 KiB
Raw Blame History

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 (离线) Shikirehype-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 — 类型安全

// 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 — 状态管理

// 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) — 持久化

// 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 };
// 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 — 编辑器

// 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 渲染管线

// 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-10MBIndexedDB 可达数百 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,逻辑不变:

// 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 重写:

// 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 变量

/* 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;
}

九、构建配置

// 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' },
      },
    },
  },
});
// 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"
  }
}

十、依赖清单

{
  "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 基础 UIApp 布局 + 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 原生 主进程改动最小化,降低重构风险