From 5e1c89d280dc5edfc62d305a8ec5cfa2ff97eb8c Mon Sep 17 00:00:00 2001 From: thzxx Date: Wed, 27 May 2026 20:29:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20v2.0=20=E5=85=A8=E9=87=8F=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20=E2=80=94=20TypeScript=20+=20React=20+=20Zustand=20?= =?UTF-8?q?+=20IndexedDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 技术栈升级: - 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 --- .eslintrc.cjs | 21 + .gitignore | 7 + DESIGN.md | 691 +++++++++------ README.md | 219 +++-- electron.vite.config.ts | 40 + package.json | 47 +- src/main/file-system.ts | 75 ++ src/main/file-watcher.ts | 83 ++ src/main/index.ts | 129 +++ src/main/ipc-channels.ts | 23 + src/main/ipc-handlers.ts | 183 ++++ src/main/window-manager.ts | 59 ++ src/preload/index.ts | 47 ++ src/renderer/App.tsx | 208 +++++ .../components/DropOverlay/DropOverlay.tsx | 63 ++ src/renderer/components/Editor/Editor.tsx | 210 +++++ .../ModifiedBanner/ModifiedBanner.tsx | 16 + src/renderer/components/Preview/Preview.tsx | 75 ++ .../components/SearchBar/SearchBar.tsx | 156 ++++ src/renderer/components/Sidebar/Sidebar.tsx | 220 +++++ .../components/StatusBar/StatusBar.tsx | 23 + src/renderer/components/TabBar/TabBar.tsx | 60 ++ src/renderer/components/Toast/Toast.tsx | 13 + src/renderer/components/Toolbar/Toolbar.tsx | 76 ++ .../WelcomeScreen/WelcomeScreen.tsx | 43 + src/renderer/db/recentFilesRepository.ts | 29 + src/renderer/db/schema.ts | 43 + src/renderer/db/settingsRepository.ts | 29 + src/renderer/db/tabRepository.ts | 18 + src/renderer/hooks/useDragDrop.ts | 66 ++ src/renderer/hooks/useFileWatch.ts | 66 ++ src/renderer/hooks/useKeyboard.ts | 124 +++ src/renderer/hooks/useSettings.ts | 31 + src/renderer/hooks/useTheme.ts | 24 + src/renderer/hooks/useUnsavedWarning.ts | 35 + src/renderer/index.html | 13 + src/renderer/lib/constants.ts | 13 + src/renderer/lib/fileUtils.ts | 20 + src/renderer/lib/markdown.ts | 32 + src/renderer/lib/scrollSync.ts | 109 +++ src/renderer/lib/searchEngine.ts | 56 ++ src/renderer/main.tsx | 12 + src/renderer/stores/editorStore.ts | 24 + src/renderer/stores/searchStore.ts | 64 ++ src/renderer/stores/sidebarStore.ts | 39 + src/renderer/stores/tabStore.ts | 127 +++ src/renderer/styles/global.css | 792 ++++++++++++++++++ src/renderer/styles/markdown-body.css | 138 +++ src/renderer/styles/variables.css | 45 + src/renderer/types/file.ts | 13 + src/renderer/types/index.ts | 5 + src/renderer/types/ipc.ts | 59 ++ src/renderer/types/search.ts | 9 + src/renderer/types/settings.ts | 17 + src/renderer/types/tab.ts | 11 + src/shared/ipc-channels.ts | 29 + src/shared/types.ts | 65 ++ tsconfig.json | 28 + tsconfig.node.json | 16 + 59 files changed, 4674 insertions(+), 314 deletions(-) create mode 100644 .eslintrc.cjs create mode 100644 electron.vite.config.ts create mode 100644 src/main/file-system.ts create mode 100644 src/main/file-watcher.ts create mode 100644 src/main/index.ts create mode 100644 src/main/ipc-channels.ts create mode 100644 src/main/ipc-handlers.ts create mode 100644 src/main/window-manager.ts create mode 100644 src/preload/index.ts create mode 100644 src/renderer/App.tsx create mode 100644 src/renderer/components/DropOverlay/DropOverlay.tsx create mode 100644 src/renderer/components/Editor/Editor.tsx create mode 100644 src/renderer/components/ModifiedBanner/ModifiedBanner.tsx create mode 100644 src/renderer/components/Preview/Preview.tsx create mode 100644 src/renderer/components/SearchBar/SearchBar.tsx create mode 100644 src/renderer/components/Sidebar/Sidebar.tsx create mode 100644 src/renderer/components/StatusBar/StatusBar.tsx create mode 100644 src/renderer/components/TabBar/TabBar.tsx create mode 100644 src/renderer/components/Toast/Toast.tsx create mode 100644 src/renderer/components/Toolbar/Toolbar.tsx create mode 100644 src/renderer/components/WelcomeScreen/WelcomeScreen.tsx create mode 100644 src/renderer/db/recentFilesRepository.ts create mode 100644 src/renderer/db/schema.ts create mode 100644 src/renderer/db/settingsRepository.ts create mode 100644 src/renderer/db/tabRepository.ts create mode 100644 src/renderer/hooks/useDragDrop.ts create mode 100644 src/renderer/hooks/useFileWatch.ts create mode 100644 src/renderer/hooks/useKeyboard.ts create mode 100644 src/renderer/hooks/useSettings.ts create mode 100644 src/renderer/hooks/useTheme.ts create mode 100644 src/renderer/hooks/useUnsavedWarning.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/lib/constants.ts create mode 100644 src/renderer/lib/fileUtils.ts create mode 100644 src/renderer/lib/markdown.ts create mode 100644 src/renderer/lib/scrollSync.ts create mode 100644 src/renderer/lib/searchEngine.ts create mode 100644 src/renderer/main.tsx create mode 100644 src/renderer/stores/editorStore.ts create mode 100644 src/renderer/stores/searchStore.ts create mode 100644 src/renderer/stores/sidebarStore.ts create mode 100644 src/renderer/stores/tabStore.ts create mode 100644 src/renderer/styles/global.css create mode 100644 src/renderer/styles/markdown-body.css create mode 100644 src/renderer/styles/variables.css create mode 100644 src/renderer/types/file.ts create mode 100644 src/renderer/types/index.ts create mode 100644 src/renderer/types/ipc.ts create mode 100644 src/renderer/types/search.ts create mode 100644 src/renderer/types/settings.ts create mode 100644 src/renderer/types/tab.ts create mode 100644 src/shared/ipc-channels.ts create mode 100644 src/shared/types.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..138a0bf --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,21 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true, node: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended' + ], + ignorePatterns: ['dist', '.eslintrc.cjs', 'lib/', 'assets/'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn' + } +} diff --git a/.gitignore b/.gitignore index e7ff74d..553bb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,10 @@ out/ # Logs *.log npm-debug.log* + +# TypeScript +*.tsbuildinfo + +# Vite +.vite/ +.npmrc diff --git a/DESIGN.md b/DESIGN.md index 9db2217..20b877b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,163 +1,418 @@ -# MarkLite - 设计文档 +# MarkLite v2.0 — 架构设计文档 ## 1. 项目概述 -MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron 框架构建,提供简洁现代的用户界面,支持多标签页、Markdown 文件的打开、编辑和实时预览,支持亮色/暗色主题切换,支持搜索替换和文件树侧边栏。 +MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程序。基于 Electron + React + TypeScript 构建,采用 Zustand 状态管理、IndexedDB 持久化、unified/rehype Markdown 渲染管线,提供类型安全、模块化、可测试的现代化架构。 + +### 1.1 核心原则 + +1. **类型安全** — 全量 TypeScript,所有 IPC 通信、状态、接口均有类型定义 +2. **模块化** — 51 个源文件按职责分层:主进程 / 预加载 / 渲染进程(组件 / stores / hooks / lib / db / types) +3. **功能 100% 兼容** — 重构不丢失任何现有功能 +4. **可测试性** — 业务逻辑(lib/)与 UI(components/)解耦,stores 和 lib 可独立测试 ## 2. 技术架构 ### 2.1 技术栈 -| 组件 | 技术选型 | 说明 | -|------|----------|------| -| 桌面框架 | Electron v28 | 跨平台桌面应用框架 | -| 前端 | HTML + CSS + JavaScript | 原生前端技术,无框架依赖 | -| Markdown 解析 | marked.js v12 | 高性能 Markdown 解析库(本地离线) | -| 代码高亮 | highlight.js v11 | 代码块语法高亮(本地离线) | -| 打包 | electron-builder | 生成 Windows NSIS 安装包 | +| 组件 | 技术选型 | 版本 | 说明 | +|------|----------|------|------| +| 桌面框架 | Electron | v28 | 跨平台桌面应用框架 | +| 前端框架 | React | v18 | 函数组件 + Hooks | +| 类型系统 | TypeScript | v5.6 | 全量类型安全 | +| 状态管理 | Zustand | v5 | 轻量级状态管理,selector 优化 | +| 持久化 | Dexie.js (IndexedDB) | v4 | 标签页状态 / 用户设置 / 最近文件 | +| Markdown 解析 | unified / remark / rehype | v11 | 插件化渲染管线 | +| 代码高亮 | rehype-highlight | v7 | 基于 highlight.js 的语法高亮 | +| 构建工具 | electron-vite | v3 | Electron + Vite 集成,HMR 热更新 | +| 打包工具 | electron-builder | v25 | 生成 Windows NSIS 安装包 | +| 样式 | CSS Variables + CSS Modules | — | 主题驱动,样式隔离 | ### 2.2 进程架构 ``` -┌─────────────────────────────────────────┐ -│ Main Process (main.js) │ -│ - 窗口管理 │ -│ - 文件系统操作 (fs) │ -│ - 文件监听 (fs.watch) │ -│ - 目录树读取与监听 │ -│ - 未保存提醒拦截 │ -│ - IPC 通信主端 │ -└──────────────┬──────────────────────────┘ - │ IPC (contextBridge) -┌──────────────▼──────────────────────────┐ -│ Preload Script (preload.js) │ -│ - 安全的 API 桥接 │ -│ - exposeInMainWorld │ -└──────────────┬──────────────────────────┘ - │ -┌──────────────▼──────────────────────────┐ -│ Renderer Process (renderer/) │ -│ - 多标签页管理 │ -│ - UI 渲染(亮色/暗色主题) │ -│ - Markdown 编辑与预览 │ -│ - 搜索替换(高亮、正则、大小写) │ -│ - 文件树侧边栏 │ -│ - 拖拽文件处理 │ -│ - 快捷键处理 │ -│ - 用户设置持久化 (localStorage) │ -└─────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ Main Process (src/main/) 6 文件 │ +│ index.ts 入口:窗口创建、app 生命周期、单实例锁 │ +│ ipc-handlers.ts 所有 ipcMain.handle 注册 │ +│ file-system.ts 文件读写、目录树构建、BOM 剥离 │ +│ file-watcher.ts fs.watch 封装(单文件 + 目录监听) │ +│ window-manager.ts 窗口创建、关闭拦截、单实例锁 │ +│ ipc-channels.ts IPC 通道名常量 │ +└───────────────────────┬──────────────────────────────────────┘ + │ contextBridge (安全隔离) +┌───────────────────────▼──────────────────────────────────────┐ +│ Preload Script (src/preload/) 1 文件 │ +│ index.ts contextBridge 类型安全暴露 │ +│ electronAPI 22 个方法/事件的类型安全接口 │ +└───────────────────────┬──────────────────────────────────────┘ + │ +┌───────────────────────▼──────────────────────────────────────┐ +│ Renderer Process (src/renderer/) 42 文件 React 18 │ +│ │ +│ ┌─ components/ (11) ──────────────────────────────────────┐ │ +│ │ Toolbar · TabBar · Editor · Preview · Sidebar │ │ +│ │ SearchBar · StatusBar · WelcomeScreen · Toast │ │ +│ │ ModifiedBanner · DropOverlay │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ stores/ (4) ───────────────────────────────────────────┐ │ +│ │ tabStore · editorStore · sidebarStore · searchStore │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ hooks/ (6) ────────────────────────────────────────────┐ │ +│ │ useTheme · useSettings · useKeyboard · useDragDrop │ │ +│ │ useFileWatch · useUnsavedWarning │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ lib/ (5) ──────────────────────────────────────────────┐ │ +│ │ markdown · scrollSync · searchEngine · fileUtils │ │ +│ │ constants │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ db/ (4) ───────────────────────────────────────────────┐ │ +│ │ schema · tabRepository · settingsRepository │ │ +│ │ recentFilesRepository │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ types/ (6) ────────────────────────────────────────────┐ │ +│ │ tab · file · settings · search · ipc · index │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ styles/ (3) ───────────────────────────────────────────┐ │ +│ │ variables.css · global.css · markdown-body.css │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────┐ +│ Shared (src/shared/) 2 文件 │ +│ ipc-channels.ts IPC 通道名常量(主进程/渲染进程共用) │ +│ types.ts 共享类型定义(FileNode, 结果类型等) │ +└──────────────────────────────────────────────────────────────┘ ``` ### 2.3 安全模型 -- `contextIsolation: true` + `nodeIntegration: false` -- 通过 `contextBridge.exposeInMainWorld` 安全暴露 API -- CSP 策略:`default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:` -- 渲染进程无法直接访问 Node.js API +| 层级 | 措施 | 说明 | +|------|------|------| +| Electron | `contextIsolation: true` | 渲染进程与主进程隔离 | +| Electron | `nodeIntegration: false` | 渲染进程无法访问 Node.js API | +| IPC | `contextBridge.exposeInMainWorld` | 仅暴露 22 个类型安全方法/事件 | +| CSP | `default-src 'self'; script-src 'self'` | 阻断内联脚本、外部资源加载 | +| HTML | `rehype-sanitize` | 渲染 Markdown 时过滤危险标签/属性/JS 协议 | +| 链接 | 协议白名单 | 仅允许 `http:` / `https:` / `#` 锚点 | -## 3. 功能设计 +## 3. 状态管理架构 -### 3.1 核心功能 +### 3.1 Zustand Stores -1. **多标签页** - - Ctrl+T 新建空白标签 - - Ctrl+W 关闭当前标签(未保存时确认) - - Ctrl+Tab / Ctrl+Shift+Tab 切换标签 - - 点击标签切换,hover 显示关闭按钮 - - 同一文件不会重复打开(自动切换到已有标签) - - 每个标签独立保存:内容、滚动位置、光标位置、修改状态 +``` +┌─────────────────────────────────────────────────────────┐ +│ 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 │ │ +│ └───────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` -2. **文件打开** - - 工具栏 → 打开按钮(支持 .md / .txt / .markdown) - - 拖拽文件到窗口打开(支持多文件同时拖入) - - 支持 Windows 文件关联(双击 .md 文件打开) - - 支持命令行参数传入文件路径 +### 3.2 tabStore — 标签页状态 -3. **文件保存** - - Ctrl+S 快捷键保存 - - 工具栏 → 保存按钮 - - Ctrl+Shift+S 另存为 +```typescript +interface TabState { + tabs: Tab[] // 所有标签页 + activeTabId: string | null // 当前活动标签 ID + mruStack: string[] // MRU 标签栈(Ctrl+Tab 切换) -4. **实时预览** - - 左侧编辑器 + 右侧预览(默认分屏模式) - - 编辑 150ms 防抖后更新预览 - - 基于 DOM 位置映射的滚动同步 + createTab(filePath?, content?) // 创建标签(同文件不重复打开) + closeTab(tabId) // 关闭标签(自动切换到相邻标签) + switchToTab(tabId) // 切换标签(保存当前状态 → MRU 记录 → 加载目标) + updateTabContent(tabId, content) // 更新内容(标记修改) + setModified(tabId, modified) // 设置修改状态 + getActiveTab() // 获取当前标签 + updateTabScroll(tabId, scroll) // 更新滚动/光标位置 +} +``` -5. **视图模式** - - 编辑+预览(Split View)— Ctrl+1 - - 纯编辑模式 — Ctrl+2 - - 纯预览模式 — Ctrl+3 - - 记忆上次使用的视图模式(localStorage) +### 3.3 editorStore — 编辑器状态 -6. **文件修改检测** - - 主进程通过 `fs.watch` 监听当前活动标签的文件 - - 外部修改时显示黄色提示横幅 - - 支持「重新加载」或「忽略」 - - 切换标签时自动切换监听目标 +```typescript +interface EditorState { + viewMode: 'split' | 'editor' | 'preview' // 视图模式 + darkMode: boolean // 暗色主题 + splitRatio: number // 分屏比例 (20~80) +} +``` -7. **未保存提醒** - - 关闭窗口时检测所有标签的未保存修改 - - 弹出确认对话框,防止误操作 - - 主进程 5 秒超时兜底,防止渲染进程无响应时窗口卡死 +### 3.4 sidebarStore — 侧边栏状态 -8. **暗色主题** - - 工具栏右侧月亮/太阳图标切换 - - CSS 变量驱动,一键切换整套配色 - - 主题偏好持久化(localStorage) +```typescript +interface SidebarState { + isVisible: boolean // 是否显示 + rootPath: string | null // 当前打开的文件夹路径 + tree: FileNode[] // 目录树数据 + expandedDirs: Set // 已展开的目录集合 + sidebarWidth: number // 侧边栏宽度 (180~500) +} +``` -9. **搜索替换** - - Ctrl+F 打开搜索栏,Ctrl+H 打开搜索+替换栏 - - 实时高亮所有匹配项,当前匹配用不同颜色标识 - - Enter / Shift+Enter 导航下一个/上一个匹配 - - 区分大小写(Alt+C)、正则表达式(Alt+R)切换 - - 替换当前(Ctrl+Shift+G)、全部替换(Ctrl+Shift+H) - - 自动将选中文本填充到搜索框 - - Esc 关闭搜索栏 +### 3.5 searchStore — 搜索状态 -10. **文件树侧边栏** - - 工具栏按钮打开文件夹选择对话框 - - 递归读取目录结构,只显示 .md/.markdown/.txt 文件 - - 自动跳过 node_modules、.git、dist 等无关目录 - - 点击文件夹展开/折叠,点击文件在新标签页打开 - - 已打开的文件自动切换到对应标签 - - fs.watch 监听目录变化,自动刷新树 - - 侧边栏折叠状态持久化(localStorage) +```typescript +interface SearchState { + isVisible: boolean // 搜索栏是否可见 + showReplace: boolean // 替换行是否展开 + searchText: string // 搜索文本 + replaceText: string // 替换文本 + matches: SearchMatch[] // 所有匹配项 + currentIndex: number // 当前匹配索引 + options: SearchOptions // { caseSensitive, useRegex } +} +``` -### 3.2 UI 设计 +## 4. 数据持久化 — IndexedDB -#### 色彩方案(亮色) +通过 Dexie.js 封装 IndexedDB,替代 localStorage: -| 角色 | 色值 | -|------|------| -| 主色调 | `#1a73e8` | -| 背景色 | `#ffffff` | -| 次级背景 | `#f8f9fa` | -| 三级背景 | `#f1f3f4` | -| 文字色 | `#333333` | -| 次级文字 | `#5f6368` | -| 代码块背景 | `#f6f8fa` | -| 边框色 | `#e1e4e8` | +### 4.1 数据库 Schema -#### 色彩方案(暗色) +```typescript +// db/schema.ts +const db = new Dexie('MarkLite') -| 角色 | 色值 | -|------|------| -| 主色调 | `#8ab4f8` | -| 背景色 | `#1e1e1e` | -| 次级背景 | `#252526` | -| 三级背景 | `#2d2d2d` | -| 文字色 | `#d4d4d4` | -| 次级文字 | `#9e9e9e` | -| 代码块背景 | `#2d2d2d` | -| 边框色 | `#3e3e3e` | +db.version(1).stores({ + tabSnapshots: 'id, filePath, updatedAt', // 标签页快照 + settings: 'id', // 用户设置 + recentFiles: '++id, filePath, lastOpened' // 最近打开文件 +}) +``` -#### 字体 +### 4.2 数据模型 -- **UI 字体**: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif -- **编辑器字体**: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace +| Store | 字段 | 说明 | +|-------|------|------| +| `tabSnapshots` | id, filePath, content, scrollTop, scrollLeft, selectionStart, selectionEnd, previewScrollTop, isModified, updatedAt | 标签页状态快照,关闭时保存,启动时恢复 | +| `settings` | id(固定'default'), darkMode, viewMode, splitRatio, sidebarCollapsed, sidebarWidth | 用户偏好设置 | +| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 | + +### 4.3 IndexedDB 优势(vs localStorage) + +| 维度 | localStorage | IndexedDB | +|------|-------------|-----------| +| 容量 | ~5-10MB | 数百 MB | +| API | 同步 | 异步,不阻塞 UI | +| 索引 | 无 | 支持索引查询 | +| 大文件 | 无法缓存 20MB 文件 | 可缓存任意大小文件 | +| 标签恢复 | 关闭后丢失 | 可持久化恢复所有标签 | + +## 5. IPC 通信设计 + +### 5.1 渲染进程 → 主进程(invoke) + +| 通道 | 参数 | 返回值 | 说明 | +|------|------|--------|------| +| `dialog:openFile` | 无 | `OpenFileResponse` | 打开文件对话框 | +| `file:read` | `filePath: string` | `ReadFileResult` | 读取文件内容 | +| `file:save` | `{ filePath, content }` | `SaveFileResult` | 保存文件 | +| `file:saveAs` | `{ content }` | `SaveFileResult` | 另存为 | +| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 | +| `file:stats` | `filePath: string` | `FileStatsResult` | 获取文件元信息 | +| `file:reload` | 无 | `ReloadFileResult` | 重新加载当前文件 | +| `tab:switched` | `filePath: string \| null` | `void` | 通知主进程切换活动文件 | +| `window:forceClose` | 无 | `void` | 强制关闭窗口 | +| `window:cancelClose` | 无 | `void` | 取消关闭 | +| `dir:readTree` | `dirPath: string` | `ReadDirTreeResult` | 递归读取目录树 | +| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 | +| `dir:watch` | `dirPath: string` | `void` | 监听目录变化 | +| `dir:unwatch` | 无 | `void` | 停止监听目录变化 | + +### 5.2 主进程 → 渲染进程(send) + +| 通道 | 数据 | 说明 | +|------|------|------| +| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 | +| `file:externallyModified` | `filePath: string` | 文件被外部修改 | +| `window:confirmClose` | 无 | 请求确认关闭 | +| `sidebar:dirChanged` | 无 | 目录结构变化,通知刷新树 | + +### 5.3 类型安全 + +所有 IPC 通道通过 `src/shared/types.ts` 和 `src/renderer/types/ipc.ts` 定义类型: + +```typescript +// src/renderer/types/ipc.ts +export interface ElectronAPI { + openFile: () => Promise + readFile: (filePath: string) => Promise + saveFile: (data: SaveFilePayload) => Promise + // ... 共 22 个方法/事件 +} +``` + +## 6. 标签页数据模型 + +```typescript +// src/renderer/types/tab.ts +export interface Tab { + id: string // 唯一标识(自增计数器) + filePath: string | null // 文件路径(未命名标签为 null) + content: string // 编辑器内容 + isModified: boolean // 是否已修改 + scrollTop: number // 编辑器滚动位置 + scrollLeft: number + selectionStart: number // 光标选区 + selectionEnd: number + previewScrollTop: number // 预览面板滚动位置 +} +``` + +切换标签时自动保存当前状态、恢复目标状态。使用 `execCommand('insertText')` 替换内容以保留 undo/redo 历史。 + +## 7. Markdown 渲染管线 + +### 7.1 渲染流程 + +``` +Markdown 文本 + │ + ▼ +remark-parse 解析为 MDAST(Markdown AST) + │ + ▼ +remark-gfm 扩展 GFM 语法(表格、任务列表、删除线) + │ + ▼ +remark-rehype 转换为 HAST(HTML AST) + │ + ▼ +rehype-raw 解析内联 HTML + │ + ▼ +rehype-sanitize 安全过滤(移除 script/iframe/on* 事件/javascript: 协议) + │ + ▼ +rehype-highlight 代码块语法高亮(180+ 语言) + │ + ▼ +rehype-stringify 序列化为 HTML 字符串 + │ + ▼ +dangerouslySetInnerHTML 渲染到 React DOM +``` + +### 7.2 支持的语法 + +- 标题(h1-h6)、段落、换行 +- **粗体**、*斜体*、~~删除线~~ +- 有序/无序列表、任务列表 `- [x]` +- 代码块(围栏式 + 语法高亮,180+ 语言)、行内代码 +- 链接、图片(支持相对路径转 file:// URL) +- 表格、引用块、水平线 +- HTML 内联元素 +- GFM(GitHub Flavored Markdown) + +## 8. 滚动同步算法 + +基于**块级元素 DOM 位置映射**的编辑器-预览滚动同步: + +``` +1. 识别编辑器中的块级元素起始行 + - 标题 (#)、列表 (-/*/+)、引用 (>)、表格 (|) + - 围栏代码块 (```)、水平线 (---)、段落分隔(连续空行) + +2. 获取预览面板中每个 DOM 子元素的 offsetTop + +3. 将块起始行号映射到预览 DOM 位置 + - 块起始行数 : 预览 DOM 元素数 = 线性对应 + +4. 线性插值填充所有中间行 + - 对于非块起始行,找到前后最近的已映射行 + - 按行号比例插值计算预览位置 + +5. 滚动时通过映射表直接查找目标位置 + - O(1) 查找,无需遍历 +``` + +## 9. 搜索替换引擎 + +### 9.1 匹配算法 + +``` +普通文本搜索: + - 大小写不敏感时:haystack.toLowerCase().indexOf(needle) + - 大小写敏感时:haystack.indexOf(needle) + - O(N) 线性扫描 + +正则表达式搜索: + - new RegExp(text, flags) + - 循环 exec() 收集所有匹配 + - 空匹配保护:regex.lastIndex++ +``` + +### 9.2 高亮渲染 + +``` +字符位置 → 像素坐标转换: + 1. 构建 lineStarts 缓存(每行首字符的偏移量) + 2. 二分查找 O(log N):字符位置 → 行号 + 3. 列号 = 字符位置 - lineStarts[行号] + 4. top = 行号 × 行高 + paddingTop + 5. left = 列号 × 字符宽度 + paddingLeft + +搜索高亮 overlay: + - 绝对定位 div,覆盖在 textarea 上方 + - pointer-events: none 不拦截编辑器交互 + - 滚动时同步更新 overlay top 偏移 +``` + +### 9.3 替换策略 + +- **替换当前**:使用 `execCommand('insertText')` 保留 undo 历史 +- **全部替换**:从后往前拼接,一次 `execCommand('insertText')` 原子操作,Ctrl+Z 可一次性撤销 + +## 10. UI 设计 + +### 10.1 色彩方案 + +#### 亮色主题 + +| 角色 | CSS 变量 | 色值 | +|------|----------|------| +| 主色调 | `--primary` | `#1a73e8` | +| 背景色 | `--bg` | `#ffffff` | +| 次级背景 | `--bg-secondary` | `#f8f9fa` | +| 三级背景 | `--bg-tertiary` | `#f1f3f4` | +| 文字色 | `--text` | `#333333` | +| 次级文字 | `--text-secondary` | `#5f6368` | +| 代码块背景 | `--code-bg` | `#f6f8fa` | +| 边框色 | `--border` | `#e1e4e8` | + +#### 暗色主题 + +| 角色 | CSS 变量 | 色值 | +|------|----------|------| +| 主色调 | `--primary` | `#8ab4f8` | +| 背景色 | `--bg` | `#1e1e1e` | +| 次级背景 | `--bg-secondary` | `#252526` | +| 三级背景 | `--bg-tertiary` | `#2d2d2d` | +| 文字色 | `--text` | `#d4d4d4` | +| 次级文字 | `--text-secondary` | `#9e9e9e` | +| 代码块背景 | `--code-bg` | `#2d2d2d` | +| 边框色 | `--border` | `#3e3e3e` | + +### 10.2 字体 + +- **UI 字体**: `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif` +- **编辑器字体**: `"Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace` - **预览字体**: 同 UI 字体 -#### 布局 +### 10.3 布局 ``` ┌──────────────────────────────────────────────────────────────────────────┐ @@ -182,128 +437,13 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 └──────────────────────────────────────────────────────────────────────────┘ ``` -## 4. 文件结构 - -``` -MarkLite/ -├── package.json # 项目配置与依赖 -├── main.js # Electron 主进程 -├── preload.js # 预加载脚本(IPC 桥接) -├── renderer/ -│ ├── index.html # 主页面结构(含侧边栏、搜索栏) -│ ├── style.css # UI 样式(亮色/暗色主题、侧边栏、搜索栏) -│ └── renderer.js # 渲染进程逻辑(标签页、编辑、预览、搜索、文件树) -├── lib/ -│ ├── marked.min.js # Markdown 解析库(离线) -│ ├── highlight.min.js # 代码高亮库(离线) -│ └── highlight-github.css # 代码高亮主题 -├── assets/ -│ └── icon.ico # 应用图标(多尺寸) -├── DESIGN.md # 设计文档(本文件) -├── DEVSETUP.md # 开发环境配置指南 -├── README.md # 项目说明 -├── LICENSE # MIT 许可证 -└── .gitignore # Git 忽略文件 -``` - -## 5. IPC 通信设计 - -### 5.1 渲染进程 → 主进程(invoke) - -| 通道 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `dialog:openFile` | 无 | `{ filePath, content }` 或 `null` | 打开文件对话框 | -| `file:read` | `filePath` | `{ success, content }` | 读取文件内容 | -| `file:save` | `{ filePath, content }` | `{ success, filePath }` | 保存文件 | -| `file:saveAs` | `{ content }` | `{ success, filePath }` | 另存为 | -| `file:getCurrentPath` | 无 | `string \| null` | 获取当前文件路径 | -| `file:stats` | `filePath` | `{ success, size, mtime }` | 获取文件元信息 | -| `file:reload` | 无 | `{ success, content, filePath }` | 重新加载当前文件 | -| `tab:switched` | `filePath` | 无 | 通知主进程切换活动文件 | -| `window:forceClose` | 无 | 无 | 强制关闭窗口(跳过未保存检查) | -| `dir:readTree` | `dirPath` | `{ success, tree, rootPath }` | 递归读取目录树 | -| `dir:openDialog` | 无 | `string \| null` | 打开文件夹选择对话框 | -| `dir:watch` | `dirPath` | 无 | 监听目录变化 | -| `dir:unwatch` | 无 | 无 | 停止监听目录变化 | - -### 5.2 主进程 → 渲染进程(send) - -| 通道 | 数据 | 说明 | -|------|------|------| -| `file:openInTab` | `{ filePath, content }` | 在新标签中打开文件 | -| `file:opened` | `{ filePath, content }` | 文件已打开(兼容旧路径) | -| `file:externallyModified` | `filePath` | 文件被外部修改 | -| `menu:save` | 无 | 菜单触发保存 | -| `menu:saveAs` | 无 | 菜单触发另存为 | -| `menu:viewMode` | `mode` | 菜单切换视图模式 | -| `window:confirmClose` | 无 | 请求确认关闭 | -| `window:closing` | 无 | 窗口即将关闭 | -| `sidebar:dirChanged` | 无 | 目录结构变化,通知渲染进程刷新树 | - -## 6. 标签页数据模型 - -每个标签页在渲染进程中维护独立状态: - -```javascript -{ - id: Number, // 唯一标识 - filePath: String | null, // 文件路径(未命名标签为 null) - content: String, // 编辑器内容 - isModified: Boolean, // 是否已修改 - scrollTop: Number, // 编辑器滚动位置 - scrollLeft: Number, - selectionStart: Number, // 光标选区 - selectionEnd: Number, - previewScrollTop: Number // 预览面板滚动位置 -} -``` - -切换标签时自动保存当前状态、恢复目标状态。 - -## 7. 数据持久化 - -通过 `localStorage` 存储用户偏好,key 为 `marklite-settings`: - -```json -{ - "darkMode": false, - "viewMode": "split", - "splitRatio": 50, - "sidebarCollapsed": false -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `darkMode` | boolean | 暗色主题开关 | -| `viewMode` | string | 视图模式:`split` / `editor` / `preview` | -| `splitRatio` | number | 分屏比例(20~80) | -| `sidebarCollapsed` | boolean | 侧边栏是否折叠 | - -## 8. Markdown 渲染支持 - -支持标准 Markdown 和 GFM(GitHub Flavored Markdown): - -- 标题(h1-h6) -- 段落、换行 -- **粗体**、*斜体*、~~删除线~~ -- 有序/无序列表 -- 任务列表(`- [x]`) -- 代码块(围栏式 + 语法高亮,180+ 语言) -- 行内代码 -- 链接、图片 -- 表格 -- 引用块 -- 水平线 -- HTML 内联 - -## 9. 快捷键 +## 11. 快捷键 | 快捷键 | 功能 | |:-------|:-----| | `Ctrl + T` | 新建标签页 | | `Ctrl + W` | 关闭当前标签页 | -| `Ctrl + Tab` | 切换到下一个标签页 | +| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) | | `Ctrl + Shift + Tab` | 切换到上一个标签页 | | `Ctrl + O` | 打开文件 | | `Ctrl + S` | 保存文件 | @@ -320,15 +460,80 @@ MarkLite/ | `Ctrl + Shift + H` | 全部替换 | | `Esc` | 关闭搜索栏 | -## 10. 构建与发布 +## 12. 构建与发布 -使用 `electron-builder` 打包: +### 12.1 开发模式 + +```bash +npm run dev # electron-vite dev(HMR 热更新) +``` + +### 12.2 生产构建 + +```bash +npm run build # electron-vite build + electron-builder --win +npm run build:portable # 便携版(免安装) +``` + +### 12.3 构建产物 + +``` +dist/ +├── main/index.js 15 kB 主进程(TypeScript 编译) +├── preload/index.js 2 kB 预加载脚本 +└── renderer/ + ├── index.html 入口 HTML + ├── assets/index-*.css 18 kB 样式 + └── assets/index-*.js 1.4 MB React 应用 +``` + +### 12.4 代码检查 + +```bash +npm run typecheck # TypeScript 类型检查 +npm run lint # ESLint 代码检查 +``` + +### 12.5 打包配置 - 输出格式:NSIS 安装包(.exe) - 目标平台:Windows x64 - 应用图标:assets/icon.ico - 文件关联:`.md` / `.markdown` / `.txt` - 支持自定义安装目录、桌面/开始菜单快捷方式 -- 便携版:`npm run build:portable` 详见 [DEVSETUP.md](DEVSETUP.md)。 + +## 13. 依赖清单 + +### 运行时依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| react | ^18.3 | UI 框架 | +| react-dom | ^18.3 | React DOM 渲染 | +| zustand | ^5.0 | 状态管理 | +| dexie | ^4.0 | IndexedDB 封装 | +| nanoid | ^5.0 | 唯一 ID 生成 | +| unified | ^11.0 | Markdown 处理管线 | +| remark-parse | ^11.0 | Markdown 解析器 | +| remark-gfm | ^4.0 | GFM 扩展 | +| remark-rehype | ^11.1 | MDAST → HAST 转换 | +| rehype-raw | ^7.0 | 内联 HTML 解析 | +| rehype-sanitize | ^6.0 | HTML 安全过滤 | +| rehype-stringify | ^10.0 | HAST → HTML 序列化 | +| rehype-highlight | ^7.0 | 代码语法高亮 | + +### 开发依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| electron | ^28.0 | 桌面框架 | +| electron-builder | ^25.0 | 打包工具 | +| electron-vite | ^3.0 | 构建工具 | +| @vitejs/plugin-react | ^4.3 | Vite React 插件 | +| typescript | ^5.6 | 类型系统 | +| @types/react | ^18.3 | React 类型 | +| @types/react-dom | ^18.3 | React DOM 类型 | +| eslint | ^9.0 | 代码检查 | +| @typescript-eslint/eslint-plugin | ^8.0 | TypeScript ESLint 规则 | diff --git a/README.md b/README.md index 3d92777..5c192da 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,15 @@

Platform Electron + TypeScript + React License - Version + Version

- 基于 Electron 构建,开箱即用的 Markdown 桌面编辑器。
- 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树。 + 基于 Electron + React + TypeScript 构建的现代化 Markdown 桌面编辑器。
+ 多标签页 · 实时预览 · 代码高亮 · 暗色主题 · 拖拽打开 · 搜索替换 · 文件树 · IndexedDB 持久化。

--- @@ -26,20 +28,21 @@ | 功能 | 说明 | |------|------| -| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab 切换 | -| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) | -| ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置 | -| 👁 **实时预览** | 右侧预览面板,编辑即渲染,滚动同步 | -| 🔤 **代码高亮** | 基于 highlight.js,支持 180+ 种编程语言语法高亮 | +| 📑 **多标签页** | 同时打开多个文件,Ctrl+T 新建、Ctrl+W 关闭、Ctrl+Tab MRU 切换 | +| 📂 **文件打开** | 按钮打开 / 拖拽打开 / 文件关联(双击 .md) / 命令行参数 | +| ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置、大文件虚拟化行号 | +| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | +| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🎨 **三种视图** | 分屏模式 / 纯编辑 / 纯预览,自由切换 | | 📐 **分屏调节** | 拖拽中间分隔条,自由调整编辑区与预览区比例 | -| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆 | -| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载 | -| 💾 **文件保存** | 保存 / 另存为,支持 .md 和 .txt 格式 | +| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) | +| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | +| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 🔍 **搜索替换** | Ctrl+F 搜索、Ctrl+H 替换,支持高亮匹配、大小写、正则表达式 | -| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,自动刷新 | +| 📁 **文件树** | 侧边栏浏览项目目录,点击打开文件,目录变化自动刷新 | +| 💾 **状态持久化** | 标签页状态、用户设置通过 IndexedDB 持久化,关闭后可恢复 | | ⌨️ **快捷键** | 完整的键盘快捷键支持,操作高效 | -| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 | +| 📦 **NSIS 安装包** | 一键打包为 Windows exe 安装程序 / 便携版 | ## 📸 界面预览 @@ -49,7 +52,7 @@ ├──────────────────────────────────────────────────────────────────────────┤ │ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ ├──────────────────────────────────────────────────────────────────────────┤ -│ [README.md] [DESIGN.md] [main.js] [+] │ +│ [README.md] [DESIGN.md] [main.ts] [+] │ ├──────────┬─────────────────────────┬─────────────────────────────────────┤ │ 资源管理器│ │ │ │ ▼ MarkLite│ 🔍 查找... 3/12 │ MarkLite │ @@ -71,8 +74,8 @@ ### 环境要求 -- **Node.js** >= 16.x -- **npm** >= 8.x +- **Node.js** >= 18.x +- **npm** >= 9.x - **Windows** 10/11 x64 ### 安装与运行 @@ -85,8 +88,8 @@ cd MarkLite # 安装依赖 npm install -# 启动应用 -npm start +# 启动开发模式(带 HMR 热更新) +npm run dev ``` ### 打包为 exe 安装包 @@ -101,13 +104,23 @@ npm run build:portable 打包完成后,安装包位于 `dist/` 目录。详见 [DEVSETUP.md](DEVSETUP.md)。 +### 其他命令 + +```bash +# TypeScript 类型检查 +npm run typecheck + +# ESLint 代码检查 +npm run lint +``` + ## ⌨️ 快捷键 | 快捷键 | 功能 | |:-------|:-----| | `Ctrl + T` | 新建标签页 | | `Ctrl + W` | 关闭当前标签页 | -| `Ctrl + Tab` | 切换到下一个标签页 | +| `Ctrl + Tab` | 切换到下一个标签页(MRU 顺序) | | `Ctrl + Shift + Tab` | 切换到上一个标签页 | | `Ctrl + O` | 打开文件 | | `Ctrl + S` | 保存文件 | @@ -129,57 +142,145 @@ npm run build:portable | 组件 | 技术 | 说明 | |:-----|:-----|:-----| | 桌面框架 | [Electron](https://www.electronjs.org/) v28 | 跨平台桌面应用框架 | -| Markdown 解析 | [marked.js](https://marked.js.org/) v12 | 高性能 Markdown 解析器 | -| 代码高亮 | [highlight.js](https://highlightjs.org/) v11 | 代码语法高亮引擎 | +| 前端框架 | [React](https://react.dev/) v18 | 函数组件 + Hooks | +| 类型系统 | [TypeScript](https://www.typescriptlang.org/) v5.6 | 全量类型安全 | +| 状态管理 | [Zustand](https://zustand-demo.pmnd.rs/) v5 | 轻量级状态管理 | +| 持久化 | [Dexie.js](https://dexie.org/) v4 (IndexedDB) | 标签页状态 & 用户设置持久化 | +| Markdown 解析 | [unified](https://unifiedjs.com/) / [remark](https://remark.js.org/) / [rehype](https://rehype.js.org/) | 插件化 Markdown 渲染管线 | +| 代码高亮 | [rehype-highlight](https://github.com/rehypejs/rehype-highlight) | 基于 highlight.js 的语法高亮 | +| 构建工具 | [electron-vite](https://electron-vite.org/) v3 | Electron + Vite 集成,HMR 热更新 | | 打包工具 | [electron-builder](https://www.electron.build/) | 生成 exe 安装包 | -| 前端 | HTML + CSS + JavaScript | 原生技术,零框架依赖 | +| 样式 | CSS Variables + CSS Modules | 主题驱动,样式隔离 | ## 📁 项目结构 ``` MarkLite/ -├── package.json # 项目配置 & electron-builder 打包配置 -├── main.js # Electron 主进程(窗口、文件系统、IPC) -├── preload.js # 预加载脚本(安全 IPC 桥接) -├── renderer/ -│ ├── index.html # 主页面结构 -│ ├── style.css # UI 样式(亮色/暗色主题) -│ └── renderer.js # 渲染进程逻辑(标签页、编辑、预览) -├── lib/ -│ ├── marked.min.js # marked.js(本地离线) -│ ├── highlight.min.js # highlight.js(本地离线) -│ └── highlight-github.css # GitHub 风格代码主题 +├── package.json # 项目配置 & 依赖 & electron-builder 打包配置 +├── tsconfig.json # TypeScript 配置 +├── tsconfig.node.json # Node 端 TypeScript 配置 +├── electron.vite.config.ts # electron-vite 构建配置 +├── .eslintrc.cjs # ESLint + TypeScript 规则 +│ +├── src/ +│ ├── main/ # ===== 主进程 (Node.js) ===== +│ │ ├── index.ts # 入口:窗口创建、app 生命周期、单实例锁 +│ │ ├── ipc-handlers.ts # 所有 ipcMain.handle 注册 +│ │ ├── file-system.ts # 文件读写、目录树构建、BOM 剥离 +│ │ ├── file-watcher.ts # fs.watch 封装(单文件 + 目录监听) +│ │ ├── window-manager.ts # 窗口创建、关闭拦截、单实例锁 +│ │ └── ipc-channels.ts # IPC 通道名常量 +│ │ +│ ├── preload/ # ===== 预加载脚本 ===== +│ │ └── index.ts # contextBridge 类型安全暴露 +│ │ +│ ├── renderer/ # ===== 渲染进程 (React) ===== +│ │ ├── index.html # 入口 HTML(含 CSP 策略) +│ │ ├── main.tsx # React 入口 (createRoot) +│ │ ├── App.tsx # 根组件:布局编排、全局事件 +│ │ │ +│ │ ├── components/ # ----- UI 组件 ----- +│ │ │ ├── Toolbar/ # 工具栏(打开、保存、视图切换、暗色主题) +│ │ │ ├── TabBar/ # 标签页栏(多标签、关闭、MRU 切换) +│ │ │ ├── Editor/ # 编辑器(行号、大文件虚拟化、Tab 缩进) +│ │ │ ├── Preview/ # Markdown 预览面板(unified/rehype 渲染) +│ │ │ ├── Sidebar/ # 侧边栏文件树(递归、展开折叠、独立文件区) +│ │ │ ├── SearchBar/ # 搜索替换栏(高亮、正则、大小写) +│ │ │ ├── StatusBar/ # 状态栏(文件名、编码、语言) +│ │ │ ├── WelcomeScreen/ # 欢迎屏幕 +│ │ │ ├── Toast/ # Toast 通知 +│ │ │ ├── ModifiedBanner/ # 文件外部修改提示横幅 +│ │ │ └── DropOverlay/ # 拖拽文件覆盖层 +│ │ │ +│ │ ├── stores/ # ----- Zustand 状态管理 ----- +│ │ │ ├── tabStore.ts # 标签页状态(tabs、activeTabId、mruStack) +│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode、splitRatio) +│ │ │ ├── sidebarStore.ts # 侧边栏状态(tree、expandedDirs、rootPath) +│ │ │ └── searchStore.ts # 搜索替换状态(matches、currentIndex、options) +│ │ │ +│ │ ├── hooks/ # ----- 自定义 Hooks ----- +│ │ │ ├── useTheme.ts # 暗色/亮色主题(IndexedDB 持久化) +│ │ │ ├── useSettings.ts # 用户设置读写 +│ │ │ ├── useKeyboard.ts # 全局快捷键 +│ │ │ ├── useDragDrop.ts # 拖拽打开文件 +│ │ │ ├── useFileWatch.ts # 外部文件修改监听 +│ │ │ └── useUnsavedWarning.ts # 未保存提醒(Electron + 浏览器兼容) +│ │ │ +│ │ ├── lib/ # ----- 工具库 ----- +│ │ │ ├── markdown.ts # Markdown 渲染管线(unified → remark → rehype) +│ │ │ ├── scrollSync.ts # 滚动同步算法(块级元素 DOM 位置映射) +│ │ │ ├── searchEngine.ts # 搜索匹配引擎(普通/正则/大小写/二分查找) +│ │ │ ├── fileUtils.ts # 文件大小格式化、扩展名检查 +│ │ │ └── constants.ts # 常量定义 +│ │ │ +│ │ ├── db/ # ----- IndexedDB 持久化层 ----- +│ │ │ ├── schema.ts # Dexie 数据库定义 + 类型(tabSnapshots/settings/recentFiles) +│ │ │ ├── tabRepository.ts # 标签页状态 CRUD +│ │ │ ├── settingsRepository.ts # 用户设置 CRUD +│ │ │ └── recentFilesRepository.ts # 最近打开文件 +│ │ │ +│ │ ├── types/ # ----- TypeScript 类型 ----- +│ │ │ ├── tab.ts # Tab 接口 +│ │ │ ├── file.ts # FileNode, DirTree 等 +│ │ │ ├── settings.ts # Settings 接口 + 默认值 +│ │ │ ├── search.ts # SearchMatch, SearchOptions +│ │ │ ├── ipc.ts # ElectronAPI 接口 + IPC 通道类型映射 +│ │ │ └── index.ts # 类型统一导出 +│ │ │ +│ │ └── styles/ # ----- 全局样式 ----- +│ │ ├── variables.css # CSS 变量(亮色/暗色主题) +│ │ ├── global.css # 全局 reset + 布局 + 组件样式 +│ │ └── markdown-body.css # Markdown 预览正文样式 +│ │ +│ └── shared/ # ===== 主进程/渲染进程共享 ===== +│ ├── ipc-channels.ts # IPC 通道名常量 +│ └── types.ts # 共享类型定义 +│ +├── lib/ # 第三方离线库(marked.js, highlight.js) ├── assets/ -│ └── icon.ico # 应用图标(多尺寸) -├── DESIGN.md # 架构设计文档 -├── DEVSETUP.md # 开发环境配置指南 -├── LICENSE # MIT 许可证 -└── README.md # 项目说明 +│ └── icon.ico # 应用图标(多尺寸) +├── DESIGN.md # 架构设计文档 +├── DEVSETUP.md # 开发环境配置指南 +├── REFACTOR-PLAN.md # 重构方案文档 +├── LICENSE # MIT 许可证 +└── README.md # 本文件 ``` ## 🏗️ 架构设计 ``` -┌──────────────────────────────────────────┐ -│ Main Process (main.js) │ -│ 窗口管理 · 文件系统 · 文件监听 · IPC │ -│ 目录树读取 · 侧边栏目录监听 │ -└──────────────┬───────────────────────────┘ - │ contextBridge (安全隔离) -┌──────────────▼───────────────────────────┐ -│ Preload Script (preload.js) │ -│ 安全 API 桥接层 │ -└──────────────┬───────────────────────────┘ - │ -┌──────────────▼───────────────────────────┐ -│ Renderer Process (renderer/) │ -│ 标签页 · 编辑器 · 预览 · 暗色主题 │ -│ 搜索替换 · 文件树侧边栏 · 拖拽 · 快捷键 │ -│ localStorage │ -└──────────────────────────────────────────┘ -``` +┌─────────────────────────────────────────────────────────────┐ +│ Main Process (src/main/) │ +│ 窗口管理 · 文件系统 · 文件监听 · IPC · 目录树 · 单实例锁 │ +└──────────────────────┬──────────────────────────────────────┘ + │ contextBridge (安全隔离) +┌──────────────────────▼──────────────────────────────────────┐ +│ Preload Script (src/preload/) │ +│ 类型安全 API 桥接层 │ +└──────────────────────┬──────────────────────────────────────┘ + │ +┌──────────────────────▼──────────────────────────────────────┐ +│ Renderer Process (src/renderer/) — React 18 │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Components (11 个) │ │ +│ │ Toolbar · TabBar · Editor · Preview · Sidebar │ │ +│ │ SearchBar · StatusBar · WelcomeScreen · Toast │ │ +│ │ ModifiedBanner · DropOverlay │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Zustand Stores (4 个) │ │ +│ │ tabStore · editorStore · sidebarStore · searchStore │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ IndexedDB (Dexie.js) │ │ +│ │ tabSnapshots · settings · recentFiles │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ -> 安全策略:`contextIsolation: true` + `nodeIntegration: false` + CSP,渲染进程无法直接访问 Node.js API。 +安全策略:contextIsolation: true + nodeIntegration: false + CSP +渲染进程无法直接访问 Node.js API +``` ## 📝 支持的 Markdown 语法 @@ -187,9 +288,9 @@ MarkLite/ - ✅ **粗体** / *斜体* / ~~删除线~~ - ✅ 有序列表 / 无序列表 - ✅ 任务列表 `- [x]` -- ✅ 代码块(围栏式 + 语法高亮) +- ✅ 代码块(围栏式 + 语法高亮,180+ 语言) - ✅ 行内代码 -- ✅ 链接 / 图片 +- ✅ 链接 / 图片(支持相对路径) - ✅ 表格 - ✅ 引用块 - ✅ 水平线 diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..0ba89f6 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'dist/main', + rollupOptions: { + input: { index: resolve(__dirname, 'src/main/index.ts') } + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + outDir: 'dist/preload', + rollupOptions: { + input: { index: resolve(__dirname, 'src/preload/index.ts') } + } + } + }, + renderer: { + plugins: [react()], + root: resolve(__dirname, 'src/renderer'), + build: { + outDir: resolve(__dirname, 'dist/renderer'), + rollupOptions: { + input: { index: resolve(__dirname, 'src/renderer/index.html') } + } + }, + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer'), + '@shared': resolve(__dirname, 'src/shared') + } + } + } +}) diff --git a/package.json b/package.json index 3129efe..28da3d0 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,44 @@ { "name": "marklite", - "version": "1.2.1", - "description": "Lightweight Markdown Reader for Windows", - "main": "main.js", + "version": "2.0.0", + "description": "Lightweight Markdown Editor for Windows", + "main": "./dist/main/index.js", "scripts": { - "start": "electron .", - "build": "electron-builder --win", - "build:portable": "electron-builder --win portable" + "dev": "electron-vite dev", + "build": "electron-vite build && electron-builder --win", + "build:portable": "electron-vite build && electron-builder --win portable", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit" }, "author": "MarkLite", "license": "MIT", + "dependencies": { + "dexie": "^4.0.11", + "nanoid": "^5.1.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", + "zustand": "^5.0.6" + }, "devDependencies": { - "electron": "^28.0.0", - "electron-builder": "^24.9.1" + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@typescript-eslint/eslint-plugin": "^8.26.1", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^28.3.3", + "electron-builder": "^25.1.8", + "electron-vite": "^3.0.0", + "eslint": "^9.22.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "typescript": "^5.6.3" }, "build": { "appId": "com.marklite.app", @@ -21,10 +47,7 @@ "output": "dist" }, "files": [ - "main.js", - "preload.js", - "renderer/**/*", - "lib/**/*", + "dist/**/*", "assets/**/*" ], "win": { diff --git a/src/main/file-system.ts b/src/main/file-system.ts new file mode 100644 index 0000000..04b6150 --- /dev/null +++ b/src/main/file-system.ts @@ -0,0 +1,75 @@ +import { readFile, stat, writeFile, readdir } from 'fs/promises' +import { join, extname } from 'path' + +const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB +const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt']) +const SKIP_DIRS = new Set([ + 'node_modules', '.git', '.svn', '.hg', 'dist', 'out', + '.next', '.nuxt', '__pycache__', '.DS_Store' +]) + +export interface FileNode { + name: string + path: string + type: 'file' | 'dir' + children?: FileNode[] +} + +export async function readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const fileStat = await stat(filePath) + if (fileStat.size > MAX_FILE_SIZE) { + return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } + } + let content = await readFile(filePath, 'utf-8') + // 剥离 UTF-8 BOM + if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) + return { success: true, content } + } catch (err) { + return { success: false, error: (err as Error).message } + } +} + +export async function saveFileContent(filePath: string, content: string): Promise<{ success: boolean; filePath?: string; error?: string }> { + try { + await writeFile(filePath, content, 'utf-8') + return { success: true, filePath } + } catch (err) { + return { success: false, error: (err as Error).message } + } +} + +export async function buildDirTree(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1 + if (!a.isDirectory() && b.isDirectory()) return 1 + return a.name.localeCompare(b.name) + }) + + const children: FileNode[] = [] + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue + if (entry.name.startsWith('.')) continue + + const childPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + const subChildren = await buildDirTree(childPath) + if (subChildren.length > 0) { + children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) + } + } else { + const ext = extname(entry.name).toLowerCase() + if (ALLOWED_EXTENSIONS.has(ext)) { + children.push({ name: entry.name, path: childPath, type: 'file' }) + } + } + } + return children +} + +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' +} diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts new file mode 100644 index 0000000..6647f8a --- /dev/null +++ b/src/main/file-watcher.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs' +import { BrowserWindow } from 'electron' + +export class FileWatcher { + private watcher: fs.FSWatcher | null = null + private currentPath: string | null = null + private isSelfWriting = false + + constructor(private getMainWindow: () => BrowserWindow | null) {} + + start(filePath: string): void { + this.stop() + if (!filePath) return + try { + this.currentPath = filePath + this.watcher = fs.watch(filePath, (eventType) => { + if (eventType === 'change') { + if (this.isSelfWriting) return + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('file:externallyModified', filePath) + } + } + }) + } catch { + // silently ignore + } + } + + stop(): void { + if (this.watcher) { + this.watcher.close() + this.watcher = null + } + } + + getCurrentPath(): string | null { + return this.currentPath + } + + setSelfWriting(value: boolean): void { + this.isSelfWriting = value + } +} + +export class SidebarWatcher { + private watcher: fs.FSWatcher | null = null + private watchPath: string | null = null + private refreshTimer: NodeJS.Timeout | null = null + + constructor(private getMainWindow: () => BrowserWindow | null) {} + + start(dirPath: string): void { + this.stop() + if (!dirPath) return + try { + this.watchPath = dirPath + this.watcher = fs.watch(dirPath, { recursive: true }, () => { + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = setTimeout(() => { + win.webContents.send('sidebar:dirChanged') + }, 300) + } + }) + } catch { + // silently ignore + } + } + + stop(): void { + if (this.watcher) { + this.watcher.close() + this.watcher = null + } + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + this.watchPath = null + } +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..3e6dbc3 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,129 @@ +import { app, BrowserWindow } from 'electron' +import { join } from 'path' +import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager' +import { FileWatcher, SidebarWatcher } from './file-watcher' +import { registerIpcHandlers } from './ipc-handlers' +import { readFileContent } from './file-system' + +let mainWindow: BrowserWindow | null = null +const state = { + activeFilePath: null as string | null, + pendingFilePath: null as string | null, + isClosing: false, + closeTimeout: null as NodeJS.Timeout | null +} + +const fileWatcher = new FileWatcher(() => mainWindow) +const sidebarWatcher = new SidebarWatcher(() => mainWindow) + +function openFileInTab(filePath: string): void { + if (!mainWindow || mainWindow.isDestroyed()) return + readFileContent(filePath).then(result => { + if (result.success) { + state.activeFilePath = filePath + fileWatcher.start(filePath) + mainWindow!.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`) + mainWindow!.webContents.send('file:openInTab', { filePath, content: result.content }) + } + }) +} + +// 单实例锁 +const lockOk = setupSingleInstanceLock((filePath) => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + if (filePath) openFileInTab(filePath) + } +}) + +if (!lockOk) { + app.quit() +} else { + + function setupCloseHandler(): void { + if (!mainWindow) return + mainWindow.on('close', (e) => { + if (state.isClosing) return + state.isClosing = true + e.preventDefault() + try { + if (mainWindow && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('window:confirmClose') + } else { + mainWindow?.removeAllListeners('close') + mainWindow?.close() + return + } + } catch { + mainWindow?.removeAllListeners('close') + mainWindow?.close() + return + } + state.closeTimeout = setTimeout(() => { + state.closeTimeout = null + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.removeAllListeners('close') + mainWindow.close() + } + }, 5000) + }) + } + + app.whenReady().then(() => { + app.on('open-file', (event, filePath) => { + event.preventDefault() + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { + state.pendingFilePath = filePath + } else if (mainWindow && !mainWindow.isDestroyed()) { + openFileInTab(filePath) + } else { + state.pendingFilePath = filePath + } + }) + + mainWindow = createWindow() + + // 注册 IPC 处理器 + registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) + + // 加载渲染进程页面 + if (process.env.ELECTRON_RENDERER_URL) { + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + + mainWindow.webContents.on('did-finish-load', () => { + if (state.pendingFilePath) { + openFileInTab(state.pendingFilePath) + state.pendingFilePath = null + } + }) + + setupCloseHandler() + + mainWindow.on('closed', () => { + fileWatcher.stop() + sidebarWatcher.stop() + mainWindow = null + }) + + // 命令行文件 + const cmdFile = getFilePathFromArgs(process.argv) + if (cmdFile) { + state.pendingFilePath = cmdFile + } + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow() + setupCloseHandler() + } + }) +} diff --git a/src/main/ipc-channels.ts b/src/main/ipc-channels.ts new file mode 100644 index 0000000..0ac90a1 --- /dev/null +++ b/src/main/ipc-channels.ts @@ -0,0 +1,23 @@ +export const IPC_CHANNELS = { + DIALOG_OPEN_FILE: 'dialog:openFile', + FILE_READ: 'file:read', + FILE_SAVE: 'file:save', + FILE_SAVE_AS: 'file:saveAs', + FILE_GET_CURRENT_PATH: 'file:getCurrentPath', + FILE_STATS: 'file:stats', + FILE_RELOAD: 'file:reload', + TAB_SWITCHED: 'tab:switched', + WINDOW_FORCE_CLOSE: 'window:forceClose', + WINDOW_CANCEL_CLOSE: 'window:cancelClose', + DIR_READ_TREE: 'dir:readTree', + DIR_OPEN_DIALOG: 'dir:openDialog', + DIR_WATCH: 'dir:watch', + DIR_UNWATCH: 'dir:unwatch', + FILE_OPEN_IN_TAB: 'file:openInTab', + FILE_EXTERNALLY_MODIFIED: 'file:externallyModified', + MENU_SAVE: 'menu:save', + MENU_SAVE_AS: 'menu:saveAs', + MENU_VIEW_MODE: 'menu:viewMode', + WINDOW_CONFIRM_CLOSE: 'window:confirmClose', + SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged' +} as const diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts new file mode 100644 index 0000000..d17efe6 --- /dev/null +++ b/src/main/ipc-handlers.ts @@ -0,0 +1,183 @@ +import { ipcMain, dialog, BrowserWindow } from 'electron' +import { readFileContent, saveFileContent, buildDirTree } from './file-system' +import { FileWatcher, SidebarWatcher } from './file-watcher' +import { IPC_CHANNELS } from './ipc-channels' +import { stat } from 'fs/promises' +import { join, basename } from 'path' + +export function registerIpcHandlers( + getMainWindow: () => BrowserWindow | null, + fileWatcher: FileWatcher, + sidebarWatcher: SidebarWatcher, + state: { activeFilePath: string | null; pendingFilePath: string | null } +): void { + // 打开文件对话框 + ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => { + const win = getMainWindow() + if (!win) return null + try { + const result = await dialog.showOpenDialog(win, { + properties: ['openFile'], + filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }] + }) + if (!result.canceled && result.filePaths.length > 0) { + const filePath = result.filePaths[0] + const fileResult = await readFileContent(filePath) + if (fileResult.success) { + state.activeFilePath = filePath + fileWatcher.start(filePath) + win.setTitle(`MarkLite - ${basename(filePath)}`) + return { filePath, content: fileResult.content } + } + return { error: fileResult.error } + } + return null + } catch (err) { + return { error: (err as Error).message } + } + }) + + // 读取文件 + ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: unknown, filePath: string) => { + return readFileContent(filePath) + }) + + // 保存文件 + ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: unknown, data: { filePath: string | null; content: string }) => { + const win = getMainWindow() + try { + if (data.filePath) { + fileWatcher.stop() + fileWatcher.setSelfWriting(true) + const result = await saveFileContent(data.filePath, data.content) + fileWatcher.setSelfWriting(false) + state.activeFilePath = data.filePath + setTimeout(() => { + if (state.activeFilePath === data.filePath && data.filePath) { + fileWatcher.start(data.filePath) + } + }, 300) + if (win && !win.isDestroyed()) { + win.setTitle(`MarkLite - ${basename(data.filePath)}`) + } + return result + } else { + if (!win) return { success: false, error: '窗口不可用' } + const saveResult = await dialog.showSaveDialog(win, { + filters: [{ name: 'Markdown 文件', extensions: ['md'] }] + }) + if (!saveResult.canceled) { + const result = await saveFileContent(saveResult.filePath, data.content) + if (result.success) { + state.activeFilePath = saveResult.filePath + fileWatcher.start(saveResult.filePath) + win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`) + } + return result + } + return { success: false, canceled: true } + } + } catch (err) { + fileWatcher.setSelfWriting(false) + fileWatcher.start(state.activeFilePath || '') + return { success: false, error: (err as Error).message } + } + }) + + // 另存为 + ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: unknown, data: { content: string }) => { + const win = getMainWindow() + if (!win) return { success: false, error: '窗口不可用' } + try { + const result = await dialog.showSaveDialog(win, { + filters: [{ name: 'Markdown 文件', extensions: ['md'] }] + }) + if (!result.canceled) { + const saveResult = await saveFileContent(result.filePath, data.content) + if (saveResult.success) { + state.activeFilePath = result.filePath + fileWatcher.start(result.filePath) + win.setTitle(`MarkLite - ${basename(result.filePath)}`) + } + return saveResult + } + return { success: false, canceled: true } + } catch (err) { + return { success: false, error: (err as Error).message } + } + }) + + // 获取当前路径 + ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) + + // 文件统计 + ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: unknown, filePath: string) => { + try { + const fileStat = await stat(filePath) + return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } + } catch (err) { + return { success: false, error: (err as Error).message } + } + }) + + // 重新加载 + ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => { + if (!state.activeFilePath) return { success: false, error: '没有打开的文件' } + const result = await readFileContent(state.activeFilePath) + return { ...result, filePath: state.activeFilePath } + }) + + // 目录树 + ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: unknown, dirPath: string) => { + try { + const tree = await buildDirTree(dirPath) + return { success: true, tree, rootPath: dirPath } + } catch (err) { + return { success: false, error: (err as Error).message } + } + }) + + // 打开文件夹对话框 + ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => { + const win = getMainWindow() + if (!win) return null + const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) + if (!result.canceled && result.filePaths.length > 0) { + return result.filePaths[0] + } + return null + }) + + // 目录监听 + ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: unknown, dirPath: string) => { + sidebarWatcher.start(dirPath) + }) + + ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => { + sidebarWatcher.stop() + }) + + // 标签切换 + ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: unknown, filePath: string | null) => { + state.activeFilePath = filePath || null + fileWatcher.start(filePath || '') + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite') + } + }) + + // 窗口控制 + ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + fileWatcher.stop() + win.removeAllListeners('close') + win.close() + } + }) + + ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => { + // 重置关闭状态 + }) +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts new file mode 100644 index 0000000..32708d5 --- /dev/null +++ b/src/main/window-manager.ts @@ -0,0 +1,59 @@ +import { BrowserWindow, app } from 'electron' +import { join } from 'path' + +export function createWindow(): BrowserWindow { + const mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + icon: join(__dirname, '../assets/icon.ico'), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'default', + show: false + }) + + mainWindow.setMenu(null) + + mainWindow.once('ready-to-show', () => { + mainWindow.show() + }) + + return mainWindow +} + +export function setupSingleInstanceLock( + onSecondInstance: (filePath: string | null) => void +): boolean { + const gotTheLock = app.requestSingleInstanceLock() + if (!gotTheLock) { + app.quit() + return false + } + + app.on('second-instance', (_event, commandLine) => { + const filePath = getFilePathFromArgs(commandLine) + onSecondInstance(filePath) + }) + + return true +} + +export function getFilePathFromArgs(args: string[]): string | null { + for (let i = 1; i < args.length; i++) { + const arg = args[i] + if (!arg.startsWith('--') && !arg.startsWith('-')) { + try { + const fs = require('fs') + if (fs.existsSync(arg)) return arg + } catch { + // ignore + } + } + } + return null +} diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..5a5caa7 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,47 @@ +import { contextBridge, ipcRenderer, shell } from 'electron' + +contextBridge.exposeInMainWorld('electronAPI', { + // File operations + openFile: () => ipcRenderer.invoke('dialog:openFile'), + readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), + saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data), + saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data), + getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'), + getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath), + reloadFile: () => ipcRenderer.invoke('file:reload'), + + // Tab management + tabSwitched: (filePath: string | null) => ipcRenderer.invoke('tab:switched', filePath), + + // Window control + forceClose: () => ipcRenderer.invoke('window:forceClose'), + cancelClose: () => ipcRenderer.invoke('window:cancelClose'), + + // Shell + openExternal: (url: string) => shell.openExternal(url), + + // File Tree (Sidebar) + readDirTree: (dirPath: string) => ipcRenderer.invoke('dir:readTree', dirPath), + openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'), + watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath), + unwatchDir: () => ipcRenderer.invoke('dir:unwatch'), + + // Events from main process + onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => + ipcRenderer.on('file:openInTab', (_event, data) => callback(data)), + onMenuSave: (callback: () => void) => + ipcRenderer.on('menu:save', () => callback()), + onMenuSaveAs: (callback: () => void) => + ipcRenderer.on('menu:saveAs', () => callback()), + onViewModeChange: (callback: (mode: string) => void) => + ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)), + onExternalModification: (callback: (filePath: string) => void) => + ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)), + onDirChanged: (callback: () => void) => + ipcRenderer.on('sidebar:dirChanged', () => callback()), + onConfirmClose: (callback: () => void) => + ipcRenderer.on('window:confirmClose', () => callback()), + + // Remove listeners + removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel) +}) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 0000000..06a978c --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,208 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react' +import { useTabStore } from './stores/tabStore' +import { useEditorStore } from './stores/editorStore' +import { useSearchStore } from './stores/searchStore' +import { useTheme } from './hooks/useTheme' +import { useSettings } from './hooks/useSettings' +import { useKeyboard } from './hooks/useKeyboard' +import { useUnsavedWarning } from './hooks/useUnsavedWarning' +import { useFileWatch } from './hooks/useFileWatch' +import { Toolbar } from './components/Toolbar/Toolbar' +import { TabBar } from './components/TabBar/TabBar' +import { Editor } from './components/Editor/Editor' +import { Preview } from './components/Preview/Preview' +import { Sidebar } from './components/Sidebar/Sidebar' +import { SearchBar } from './components/SearchBar/SearchBar' +import { StatusBar } from './components/StatusBar/StatusBar' +import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen' +import { Toast } from './components/Toast/Toast' +import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner' +import { DropOverlay } from './components/DropOverlay/DropOverlay' +import { useDragDrop } from './hooks/useDragDrop' + +export default function App() { + const tabs = useTabStore(s => s.tabs) + const activeTabId = useTabStore(s => s.activeTabId) + const getActiveTab = useTabStore(s => s.getActiveTab) + const createTab = useTabStore(s => s.createTab) + const setModified = useTabStore(s => s.setModified) + const updateTabContent = useTabStore(s => s.updateTabContent) + + const viewMode = useEditorStore(s => s.viewMode) + const splitRatio = useEditorStore(s => s.splitRatio) + + const { darkMode, toggleDarkMode } = useTheme() + const { saveViewMode, saveSplitRatio } = useSettings() + + const [showModifiedBanner, setShowModifiedBanner] = useState(false) + const [modifiedFilePath, setModifiedFilePath] = useState(null) + const [toastMessage, setToastMessage] = useState(null) + + const showToast = useCallback((msg: string) => { + setToastMessage(msg) + setTimeout(() => setToastMessage(null), 3000) + }, []) + + // 拖拽打开 + useDragDrop(showToast) + + // 文件修改检测 + useFileWatch() + + // 未保存提醒 + useUnsavedWarning(() => tabs.some(t => t.isModified)) + + // 文件外部修改事件 + useEffect(() => { + const handler = (e: Event) => { + const filePath = (e as CustomEvent).detail + const tab = getActiveTab() + if (tab && tab.filePath === filePath) { + setShowModifiedBanner(true) + setModifiedFilePath(filePath) + } + } + window.addEventListener('file-externally-modified', handler) + return () => window.removeEventListener('file-externally-modified', handler) + }, [getActiveTab]) + + // 打开文件 + const handleOpenFile = useCallback(async () => { + if (window.electronAPI) { + const result = await window.electronAPI.openFile() + if (result && 'filePath' in result) { + createTab(result.filePath, result.content) + } + } + }, [createTab]) + + // 保存文件 + const handleSave = useCallback(async () => { + const tab = getActiveTab() + if (!tab) return + if (window.electronAPI) { + const result = await window.electronAPI.saveFile({ + filePath: tab.filePath, + content: tab.content + }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存') + } + } + }, [getActiveTab, showToast]) + + // 另存为 + const handleSaveAs = useCallback(async () => { + const tab = getActiveTab() + if (!tab) return + if (window.electronAPI) { + const result = await window.electronAPI.saveFileAs({ content: tab.content }) + if (result.success) { + useTabStore.getState().setModified(tab.id, false) + showToast('已保存') + } + } + }, [getActiveTab, showToast]) + + // 快捷键 + useKeyboard(handleOpenFile, handleSave, handleSaveAs) + + // 视图模式切换 + const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => { + saveViewMode(mode) + }, [saveViewMode]) + + // 主进程事件 + useEffect(() => { + if (!window.electronAPI) return + window.electronAPI.removeAllListeners('file:openInTab') + window.electronAPI.removeAllListeners('menu:save') + window.electronAPI.removeAllListeners('menu:saveAs') + + window.electronAPI.onFileOpenInTab((data: { filePath: string; content: string }) => { + createTab(data.filePath, data.content) + }) + window.electronAPI.onMenuSave(() => handleSave()) + window.electronAPI.onMenuSaveAs(() => handleSaveAs()) + }, [createTab, handleSave, handleSaveAs]) + + const activeTab = getActiveTab() + const hasTabs = tabs.length > 0 + + // 分屏样式 + const editorStyle: React.CSSProperties = viewMode === 'split' + ? { flex: `0 0 ${splitRatio}%` } + : {} + const previewStyle: React.CSSProperties = viewMode === 'split' + ? { flex: `0 0 ${100 - splitRatio}%` } + : {} + + return ( +
+ + +
+ + +
+ + + {showModifiedBanner && ( + { + if (window.electronAPI && modifiedFilePath) { + window.electronAPI.reloadFile().then(result => { + if (result.success && result.content) { + const tab = getActiveTab() + if (tab) { + updateTabContent(tab.id, result.content) + setModified(tab.id, false) + } + } + setShowModifiedBanner(false) + }) + } + }} + onDismiss={() => setShowModifiedBanner(false)} + /> + )} + + {hasTabs ? ( +
+ {viewMode !== 'preview' && ( +
+ + +
+ )} + {viewMode !== 'editor' &&
} + {viewMode !== 'editor' && ( +
+ +
+ )} +
+ ) : ( + createTab(null, '')} + /> + )} +
+
+ + + + {toastMessage && } + +
+ ) +} diff --git a/src/renderer/components/DropOverlay/DropOverlay.tsx b/src/renderer/components/DropOverlay/DropOverlay.tsx new file mode 100644 index 0000000..cf35be9 --- /dev/null +++ b/src/renderer/components/DropOverlay/DropOverlay.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from 'react' + +export function DropOverlay() { + const [isVisible, setIsVisible] = useState(false) + const [dragCounter, setDragCounter] = useState(0) + + useEffect(() => { + const handleDragEnter = (e: DragEvent) => { + e.preventDefault() + setDragCounter(prev => { + const next = prev + 1 + if (next > 0) setIsVisible(true) + return next + }) + } + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault() + setDragCounter(prev => { + const next = prev - 1 + if (next <= 0) { + setIsVisible(false) + return 0 + } + return next + }) + } + + const handleDragOver = (e: DragEvent) => { + e.preventDefault() + } + + const handleDrop = () => { + setDragCounter(0) + setIsVisible(false) + } + + document.addEventListener('dragenter', handleDragEnter) + document.addEventListener('dragleave', handleDragLeave) + document.addEventListener('dragover', handleDragOver) + document.addEventListener('drop', handleDrop) + + return () => { + document.removeEventListener('dragenter', handleDragEnter) + document.removeEventListener('dragleave', handleDragLeave) + document.removeEventListener('dragover', handleDragOver) + document.removeEventListener('drop', handleDrop) + } + }, []) + + if (!isVisible) return null + + return ( +
+
+ + + +

释放文件以打开

+
+
+ ) +} diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx new file mode 100644 index 0000000..758fd27 --- /dev/null +++ b/src/renderer/components/Editor/Editor.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useRef, useCallback, useState } from 'react' +import { useTabStore } from '../../stores/tabStore' +import { useEditorStore } from '../../stores/editorStore' +import { useSearchStore } from '../../stores/searchStore' +import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine' +import type { SearchMatch } from '../../types/search' + +export function Editor() { + const textareaRef = useRef(null) + const lineNumbersRef = useRef(null) + const wrapperRef = useRef(null) + + const activeTabId = useTabStore(s => s.activeTabId) + const getActiveTab = useTabStore(s => s.getActiveTab) + const updateTabContent = useTabStore(s => s.updateTabContent) + const setModified = useTabStore(s => s.setModified) + + const searchStore = useSearchStore() + + const [lineCount, setLineCount] = useState(1) + const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 }) + const [fileSize, setFileSize] = useState('') + const updateTimerRef = useRef | null>(null) + + // 测量行高 + const lineHeightRef = useRef(20.8) + useEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + const style = getComputedStyle(textarea) + const measure = document.createElement('div') + measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;` + measure.textContent = 'X' + document.body.appendChild(measure) + const singleHeight = measure.offsetHeight + measure.textContent = 'X\nX' + const doubleHeight = measure.offsetHeight + document.body.removeChild(measure) + lineHeightRef.current = doubleHeight - singleHeight || 20.8 + }, []) + + // 加载标签内容到编辑器 + useEffect(() => { + const tab = getActiveTab() + const textarea = textareaRef.current + if (!tab || !textarea) return + + textarea.value = tab.content + updateLineNumbers(tab.content) + updateFileSizeDisplay(tab.content) + updateCursorPos() + + requestAnimationFrame(() => { + textarea.scrollTop = tab.scrollTop + textarea.scrollLeft = tab.scrollLeft + textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd) + if (lineNumbersRef.current) { + lineNumbersRef.current.scrollTop = tab.scrollTop + } + }) + }, [activeTabId, getActiveTab]) + + // 保存当前标签状态 + const saveCurrentState = useCallback(() => { + const tab = getActiveTab() + const textarea = textareaRef.current + if (!tab || !textarea) return + useTabStore.getState().updateTabScroll(tab.id, { + scrollTop: textarea.scrollTop, + scrollLeft: textarea.scrollLeft, + selectionStart: textarea.selectionStart, + selectionEnd: textarea.selectionEnd, + previewScrollTop: 0 // will be set by preview + }) + }, [getActiveTab]) + + // 更新行号 + const updateLineNumbers = useCallback((content: string) => { + const count = (content.match(/\n/g) || []).length + 1 + setLineCount(count) + }, []) + + // 更新文件大小 + const updateFileSizeDisplay = useCallback((content: string) => { + const bytes = new Blob([content]).size + if (bytes < 1024) setFileSize(bytes + ' B') + else if (bytes < 1024 * 1024) setFileSize((bytes / 1024).toFixed(1) + ' KB') + else setFileSize((bytes / (1024 * 1024)).toFixed(1) + ' MB') + }, []) + + // 更新光标位置 + const updateCursorPos = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + const text = textarea.value + const pos = textarea.selectionStart + const textBefore = text.substring(0, pos) + const lines = textBefore.split('\n') + setCursorPos({ line: lines.length, col: lines[lines.length - 1].length + 1 }) + }, []) + + // 输入事件 + const handleInput = useCallback(() => { + const textarea = textareaRef.current + const tab = getActiveTab() + if (!textarea || !tab) return + + const content = textarea.value + updateTabContent(tab.id, content) + setModified(tab.id, true) + updateLineNumbers(content) + updateFileSizeDisplay(content) + + // 防抖更新 + if (updateTimerRef.current) clearTimeout(updateTimerRef.current) + updateTimerRef.current = setTimeout(() => { + // 触发预览更新(通过 store) + }, 150) + }, [getActiveTab, updateTabContent, setModified, updateLineNumbers, updateFileSizeDisplay]) + + // 滚动同步 + const handleScroll = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + if (lineNumbersRef.current) { + lineNumbersRef.current.scrollTop = textarea.scrollTop + } + }, []) + + // Tab 键支持 + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault() + const textarea = textareaRef.current + if (!textarea) return + const start = textarea.selectionStart + const end = textarea.selectionEnd + if (start !== end) { + const lines = textarea.value.substring(start, end).split('\n') + const indented = lines.map(line => ' ' + line).join('\n') + document.execCommand('insertText', false, indented) + } else { + document.execCommand('insertText', false, ' ') + } + } + + // 搜索导航 + if (e.key === 'Enter' && searchStore.isVisible) { + e.preventDefault() + if (e.shiftKey) { + searchStore.findPrev() + } else { + searchStore.findNext() + } + } + }, [searchStore]) + + // 渲染行号 + const renderLineNumbers = () => { + const lineHeight = lineHeightRef.current + const LARGE_THRESHOLD = 2000 + if (lineCount > LARGE_THRESHOLD) { + // 虚拟化 + const textarea = textareaRef.current + if (!textarea) return null + const scrollTop = textarea.scrollTop + const viewportHeight = textarea.clientHeight + const buffer = 20 + const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer) + const endLine = Math.min(lineCount, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer) + const topPadding = startLine * lineHeight + const bottomPadding = (lineCount - endLine) * lineHeight + return ( + <> +
+ {Array.from({ length: endLine - startLine }, (_, i) => ( +
+ {startLine + i + 1} +
+ ))} +
+ + ) + } + return Array.from({ length: lineCount }, (_, i) => ( +
+ {i + 1} +
+ )) + } + + return ( +
+
+ {renderLineNumbers()} +
+